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') +

Colors

+ #include('colors.htm') +

Buttons

+ #include('buttons.htm') +

Media Controls

+ #include('media-controls.htm') +

Checkbox

+ #include('checkbox.htm') +

Icons

+ #include('icons.htm') +

Popover

+ #include('popovers.htm') +

Avatars

+ #include('avatars.htm') +

Dots

+ #include('dots.htm') +

Grid

+ #include('grid.htm') +

Modal

+ #include('modal.htm') + + #include('../../page/template/#dest/vendor.htm') + #include('../../page/template/#dest/component.htm') + + + + diff --git a/app/demo/template/avatars.htm b/app/demo/template/avatars.htm new file mode 100644 index 00000000000..0132a2e84e2 --- /dev/null +++ b/app/demo/template/avatars.htm @@ -0,0 +1,289 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
lgmdsmxs
Initials + +
+
+
JD
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+ +
+
+
J
+
+
+
+
+
Image + +
+
+
JD
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
Selected + +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
Pending + +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
Blocked + +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
Unknown + +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ +
+
+
JD
+
+
+
+
+
+
+ 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 @@ + 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 + + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
      + +
    • + +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
      + +
    • + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
      + +
    • + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + + + + + +
    +
    +
      + +
    • + +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    +
      + +
    • + +
    + +
    +
    + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    + + + + #include('#dest/vendor.htm') + #include('#dest/component.htm') + #include('#dest/app.htm') + #include('#dest/auth.htm') + + diff --git a/app/page/index.html b/app/page/index.html new file mode 100644 index 00000000000..9b8353d1ca8 --- /dev/null +++ b/app/page/index.html @@ -0,0 +1,26 @@ + + + + + + #include('meta.htm') + #include('graph.htm') + Wire for Web – modern, private communications in your browsers. + #include('#dest/style.htm') + + + + #include('svg.htm') + #include('loading.htm') + #include('wire-main.htm') + #include('#dest/debug.htm') + + #include('#dest/vendor.htm') + #include('#dest/component.htm') + #include('#dest/app.htm') + + #include('partials/template-user-profile.htm') + #include('partials/template-confirm.htm') + #include('partials/template-message.htm') + + diff --git a/app/page/template/_deploy/app.htm b/app/page/template/_deploy/app.htm new file mode 100644 index 00000000000..337f3b493b8 --- /dev/null +++ b/app/page/template/_deploy/app.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_deploy/auth.htm b/app/page/template/_deploy/auth.htm new file mode 100644 index 00000000000..3fb89d9d2a9 --- /dev/null +++ b/app/page/template/_deploy/auth.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_deploy/component.htm b/app/page/template/_deploy/component.htm new file mode 100644 index 00000000000..6b3b183fb34 --- /dev/null +++ b/app/page/template/_deploy/component.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_deploy/debug.htm b/app/page/template/_deploy/debug.htm new file mode 100644 index 00000000000..137f2aaf167 --- /dev/null +++ b/app/page/template/_deploy/debug.htm @@ -0,0 +1,55 @@ + +
    + +

    Conversation

    + +

    + ID:
    + Participants:
    +

    + +

    + +

    Calling

    + +

    Tools

    + + + +

    Traces

    + + + +

    Soundbar

    + + + + + + +

    Debug Info

    + +

    + Version: {{config.CURRENT_VERSION_ID}} +

    +
    + + diff --git a/app/page/template/_deploy/style.htm b/app/page/template/_deploy/style.htm new file mode 100644 index 00000000000..15583123479 --- /dev/null +++ b/app/page/template/_deploy/style.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_deploy/vendor.htm b/app/page/template/_deploy/vendor.htm new file mode 100644 index 00000000000..31a868eafd8 --- /dev/null +++ b/app/page/template/_deploy/vendor.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_dist/app.htm b/app/page/template/_dist/app.htm new file mode 100644 index 00000000000..7f97bf19c5b --- /dev/null +++ b/app/page/template/_dist/app.htm @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/page/template/_dist/auth.htm b/app/page/template/_dist/auth.htm new file mode 100644 index 00000000000..8a5eaaab884 --- /dev/null +++ b/app/page/template/_dist/auth.htm @@ -0,0 +1,4 @@ + + + + diff --git a/app/page/template/_dist/component.htm b/app/page/template/_dist/component.htm new file mode 100644 index 00000000000..61e6d89e40e --- /dev/null +++ b/app/page/template/_dist/component.htm @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/page/template/_dist/debug.htm b/app/page/template/_dist/debug.htm new file mode 100644 index 00000000000..8eab3f16808 --- /dev/null +++ b/app/page/template/_dist/debug.htm @@ -0,0 +1,61 @@ + +
    + +

    Conversation

    + +

    + ID:
    + Participants:
    +

    + +

    + +

    Self user

    + +

    + ID:
    +

    + +

    Calling

    + +

    Tools

    + + + +

    Traces

    + + + +

    Soundbar

    + + + + + + +

    Debug Info

    + +

    + Version: {{config.CURRENT_VERSION_ID}} +

    +
    + diff --git a/app/page/template/_dist/style.htm b/app/page/template/_dist/style.htm new file mode 100644 index 00000000000..1cac98ee618 --- /dev/null +++ b/app/page/template/_dist/style.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_dist/vendor.htm b/app/page/template/_dist/vendor.htm new file mode 100644 index 00000000000..519f37d6c8a --- /dev/null +++ b/app/page/template/_dist/vendor.htm @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/page/template/_prod/app.htm b/app/page/template/_prod/app.htm new file mode 100644 index 00000000000..337f3b493b8 --- /dev/null +++ b/app/page/template/_prod/app.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_prod/auth.htm b/app/page/template/_prod/auth.htm new file mode 100644 index 00000000000..3fb89d9d2a9 --- /dev/null +++ b/app/page/template/_prod/auth.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_prod/component.htm b/app/page/template/_prod/component.htm new file mode 100644 index 00000000000..6b3b183fb34 --- /dev/null +++ b/app/page/template/_prod/component.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_prod/debug.htm b/app/page/template/_prod/debug.htm new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/page/template/_prod/style.htm b/app/page/template/_prod/style.htm new file mode 100644 index 00000000000..15583123479 --- /dev/null +++ b/app/page/template/_prod/style.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/_prod/vendor.htm b/app/page/template/_prod/vendor.htm new file mode 100644 index 00000000000..31a868eafd8 --- /dev/null +++ b/app/page/template/_prod/vendor.htm @@ -0,0 +1 @@ + diff --git a/app/page/template/conversation/connect-requests.htm b/app/page/template/conversation/connect-requests.htm new file mode 100644 index 00000000000..c50061f33b8 --- /dev/null +++ b/app/page/template/conversation/connect-requests.htm @@ -0,0 +1,18 @@ +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/app/page/template/conversation/conversation-input.htm b/app/page/template/conversation/conversation-input.htm new file mode 100644 index 00000000000..bcf1974934c --- /dev/null +++ b/app/page/template/conversation/conversation-input.htm @@ -0,0 +1,62 @@ + +
    + + +
    + + + +
    + + +
    + +
    + +
    + + + + + + + + + + + + + + +
    + + +
    + diff --git a/app/page/template/conversation/conversation-titlebar.htm b/app/page/template/conversation/conversation-titlebar.htm new file mode 100644 index 00000000000..ae98c26e33d --- /dev/null +++ b/app/page/template/conversation/conversation-titlebar.htm @@ -0,0 +1,28 @@ +
    + +
    + + +
    + + +
    + + + + + + +
    + + +
    diff --git a/app/page/template/conversation/conversation-verified.htm b/app/page/template/conversation/conversation-verified.htm new file mode 100644 index 00000000000..2286caff533 --- /dev/null +++ b/app/page/template/conversation/conversation-verified.htm @@ -0,0 +1,8 @@ + +
    + + + + +
    + diff --git a/app/page/template/conversation/conversation.htm b/app/page/template/conversation/conversation.htm new file mode 100644 index 00000000000..87975fbf1d5 --- /dev/null +++ b/app/page/template/conversation/conversation.htm @@ -0,0 +1,22 @@ +
    + + + + +
    +
    +
    + + + + #include('conversation/message-list.htm') + #include('conversation/conversation-titlebar.htm') + #include('conversation/conversation-input.htm') + #include('conversation/participants.htm') + #include('conversation/detail-view.htm') + #include('conversation/giphy.htm') + + + + +
    diff --git a/app/page/template/conversation/detail-view.htm b/app/page/template/conversation/detail-view.htm new file mode 100644 index 00000000000..ddf8d0ef580 --- /dev/null +++ b/app/page/template/conversation/detail-view.htm @@ -0,0 +1,6 @@ + diff --git a/app/page/template/conversation/giphy.htm b/app/page/template/conversation/giphy.htm new file mode 100644 index 00000000000..ea65612d2ee --- /dev/null +++ b/app/page/template/conversation/giphy.htm @@ -0,0 +1,46 @@ + diff --git a/app/page/template/conversation/message-list.htm b/app/page/template/conversation/message-list.htm new file mode 100644 index 00000000000..64071785094 --- /dev/null +++ b/app/page/template/conversation/message-list.htm @@ -0,0 +1,18 @@ +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/app/page/template/conversation/participants.htm b/app/page/template/conversation/participants.htm new file mode 100644 index 00000000000..2c87d9c5219 --- /dev/null +++ b/app/page/template/conversation/participants.htm @@ -0,0 +1,74 @@ +
    +
    + + +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    + + + +
    + + + + +
    +
    + + + +
    + +
    + + + +
    + + + + + +
    +
    diff --git a/app/page/template/graph.htm b/app/page/template/graph.htm new file mode 100644 index 00000000000..9491a50d4f1 --- /dev/null +++ b/app/page/template/graph.htm @@ -0,0 +1,5 @@ + + + + + diff --git a/app/page/template/list/actions.htm b/app/page/template/list/actions.htm new file mode 100644 index 00000000000..8f9ffa2e908 --- /dev/null +++ b/app/page/template/list/actions.htm @@ -0,0 +1,60 @@ +
    +
      + + + +
    • + + +
    • + + + +
    • + + +
    • + + +
    • + + + +
    • + + + +
    • + + + +
    • + + + +
    +
    diff --git a/app/page/template/list/archive.htm b/app/page/template/list/archive.htm new file mode 100644 index 00000000000..55128bcb56b --- /dev/null +++ b/app/page/template/list/archive.htm @@ -0,0 +1,57 @@ +
    +
    + + +
    +
    +
      + +
    • +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      +
    • + +
    +
    +
    diff --git a/app/page/template/list/conversation-list.htm b/app/page/template/list/conversation-list.htm new file mode 100644 index 00000000000..7c1ebfb303b --- /dev/null +++ b/app/page/template/list/conversation-list.htm @@ -0,0 +1,199 @@ + +
    + + +
    + +
    + + + + + + + + + + + + + + + + + +
    +
    + + +
    + + + +
    + + +
    +
    + + + + + + + + + + + + + + + +
    + +
    + + +
    +
    +
    + +
    + + +
    + + +
    + + +
    + +
    + + +
    +
    +
    + + +
    + + +
    + + +
    +
    +
    + +
    +
    + + +
    +
      + + +
    • +
      +
      +
      +
      +
      +
    • + + + +
    • +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      +
    • + +
    +
    + +
    + diff --git a/app/page/template/list/start-ui.htm b/app/page/template/list/start-ui.htm new file mode 100644 index 00000000000..3dea8f05a5a --- /dev/null +++ b/app/page/template/list/start-ui.htm @@ -0,0 +1,167 @@ +
    +
    + + +
    + +
    + + +
    + + +
    + + +
    + + +
    +
    + +
    +
    + + + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    + + + +
    + + + +
    +

    + +
    +
    + +
    + +
    + + + + +
    +
    +
    + + + + + +
    + +
    + + +
    +
    + +
    + +
    + + +
    + +
    + + +
    +
    + + + + +
    + + + + +
    + +
    +
    + +
    diff --git a/app/page/template/loading.htm b/app/page/template/loading.htm new file mode 100644 index 00000000000..4965084d584 --- /dev/null +++ b/app/page/template/loading.htm @@ -0,0 +1,6 @@ +
    +
    +
    +
    +
    +
    diff --git a/app/page/template/meta.htm b/app/page/template/meta.htm new file mode 100644 index 00000000000..48075416912 --- /dev/null +++ b/app/page/template/meta.htm @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/page/template/modals.htm b/app/page/template/modals.htm new file mode 100644 index 00000000000..55604b44a19 --- /dev/null +++ b/app/page/template/modals.htm @@ -0,0 +1,287 @@ +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/app/page/template/partials/template-confirm.htm b/app/page/template/partials/template-confirm.htm new file mode 100644 index 00000000000..e8184a3fc2d --- /dev/null +++ b/app/page/template/partials/template-confirm.htm @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + diff --git a/app/page/template/partials/template-message.htm b/app/page/template/partials/template-message.htm new file mode 100644 index 00000000000..c99ec707ae1 --- /dev/null +++ b/app/page/template/partials/template-message.htm @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/page/template/partials/template-user-profile.htm b/app/page/template/partials/template-user-profile.htm new file mode 100644 index 00000000000..eb1aee716a3 --- /dev/null +++ b/app/page/template/partials/template-user-profile.htm @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/page/template/self/self-about.htm b/app/page/template/self/self-about.htm new file mode 100644 index 00000000000..135afd1d659 --- /dev/null +++ b/app/page/template/self/self-about.htm @@ -0,0 +1,21 @@ + diff --git a/app/page/template/self/self-profile.htm b/app/page/template/self/self-profile.htm new file mode 100644 index 00000000000..a4239d15e13 --- /dev/null +++ b/app/page/template/self/self-profile.htm @@ -0,0 +1,28 @@ +
    + +
    + +
    +
    + +
    + + +
    + #include('self/settings-menu.htm') + #include('self/self-about.htm') + +
    diff --git a/app/page/template/self/settings-menu.htm b/app/page/template/self/settings-menu.htm new file mode 100644 index 00000000000..c8fd06f083f --- /dev/null +++ b/app/page/template/self/settings-menu.htm @@ -0,0 +1,8 @@ +
    +
      +
    • +
    • +
    • +
    • +
    +
    diff --git a/app/page/template/settings.htm b/app/page/template/settings.htm new file mode 100644 index 00000000000..dc5767a2f1e --- /dev/null +++ b/app/page/template/settings.htm @@ -0,0 +1,173 @@ + diff --git a/app/page/template/svg.htm b/app/page/template/svg.htm new file mode 100644 index 00000000000..d31f5e4ebbf --- /dev/null +++ b/app/page/template/svg.htm @@ -0,0 +1,84 @@ + + + + spinner + + + + ping-color + + + + + + people-color + + + + + + camera-color + + + + + + call-color + + + + + + video-color + + + + + + giphy + + + + + + + + + + + not-verified + + + + verified + + + + + gmail + + + + + + + + list-call + + + + + + list-call-unjoined + + + + + + missed-call + + + + + + diff --git a/app/page/template/video-calling.htm b/app/page/template/video-calling.htm new file mode 100644 index 00000000000..7a2f0f4608d --- /dev/null +++ b/app/page/template/video-calling.htm @@ -0,0 +1,99 @@ +
    + + +
    + + + + + +
    + + + +
    + + + +
    + + +
    +
    + + + +
    +
    +
    +
    + + + + + + + +
    +
    +
    + +
    + +
    +
    +
    + +
    + + + +
    + + + + + + + + + + + + +
    + + +
    diff --git a/app/page/template/warning.htm b/app/page/template/warning.htm new file mode 100644 index 00000000000..5daa27a9cc5 --- /dev/null +++ b/app/page/template/warning.htm @@ -0,0 +1,153 @@ +
    + + +
    + +
    +
    + +
    + + + +
    +
    +   + +
    + +
    + + + +
    +
    + +
    + + + +
    +
    +   + +
    + +
    + + + +
    +
    + +
    + + + +
    +
    +   + +
    + +
    + + + +
    +
    +   + +
    + +
    + + + +
    +
    +   + +
    + +
    + + + +
    +
    + +
    + + + +
    + +
     
    + + + +
    +   + +
    + + +
    + + + +
    + + + +
    + +
    +   + +
    + + + +
    +   + +
    + + +
    + + + +
    + + + +
    +
    + + + + +
    +
    + + + +
    + + + +
    +
    +
    + + +
    + + +
    diff --git a/app/page/template/wire-main.htm b/app/page/template/wire-main.htm new file mode 100644 index 00000000000..850f535f87b --- /dev/null +++ b/app/page/template/wire-main.htm @@ -0,0 +1,59 @@ +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + #include('list/archive.htm') + #include('list/start-ui.htm') + #include('list/conversation-list.htm') + #include('list/actions.htm') +
    + +
    +
    +
    + +
    + + + +
    + + + + + +
    +
    +
    + #include('video-calling.htm') + #include('settings.htm') + #include('warning.htm') + #include('modals.htm') + +
    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: "'} + ] + } + + @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] Promise that resolves with a user client map + ### + _create_user_client_map: (conversation_id) -> + @get_all_users_in_conversation conversation_id + .then (user_ets) -> + user_client_map = {} + + for user_et in user_ets when user_et.devices()[0] + user_client_map[user_et.id] = (client_et.id for client_et in user_et.devices()) + + return user_client_map + + ### + Create a user client map for given IDs. + + @private + @param user_id [String] User ID + @param client_id [String] Client ID + @return [Object] User client map + ### + _create_user_client_map_from_ids: (user_id, client_id) -> + user_client_map = {} + user_client_map[user_id] = [client_id] + return user_client_map + + ### + Saves and sends a generic message to the conversation + + @private + @param conversation_et [z.entity.Conversation] Conversation to send message to + @param message_content [z.proto] Protobuf message content to be added to generic message + @return [Promise] Promise that resolves when message has been added to the conversation + ### + _send_and_save_encrypted_value: (conversation_et, message_content) => + return new Promise (resolve, reject) => + reject() if conversation_et.removed_from_conversation() + + generic_message = new z.proto.GenericMessage z.util.create_random_uuid() + + if message_content instanceof z.proto.Knock + generic_message.set 'knock', message_content + else if message_content instanceof z.proto.Text + generic_message.set 'text', message_content + else if message_content instanceof z.proto.GenericMessage + generic_message = message_content + + @_send_encrypted_value conversation_et.id, generic_message + .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 + resolve record + .catch (error) => + error_message = "Could not send OTR message of type '#{generic_message.content}' to conversation ID '#{conversation_et.id}' (#{conversation_et.display_name()}): #{error.message}" + + raygun_error = new Error 'Encryption failed' + custom_data = error_message: "Could not send OTR message of type '#{generic_message.content}'" + Raygun.send raygun_error, custom_data + + @logger.log @logger.levels.ERROR, error_message, {error: error, event: generic_message} + if error.label is z.service.BackendClientError::LABEL.UNKNOWN_CLIENT + amplify.publish z.event.WebApp.SIGN_OUT, 'unknown_sender', true + reject error + + ### + Sends a generic message to the conversation + + @private + @param conversation_id [String] Conversation ID + @param generic_message [z.protobuf.GenericMessage] Protobuf message to be encrypted and send + @return [Promise] Promise that resolves after sending the encrypted message + ### + _send_encrypted_value: (conversation_id, generic_message) => + initial_payload = null + @_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 + @logger.log @logger.levels.INFO, "Sending encrypted message to conversation '#{conversation_id}'", 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) => + @logger.log @logger.levels.INFO, "Sending updated encrypted message to conversation '#{conversation_id}'", updated_payload + return @conversation_service.post_encrypted_message conversation_id, updated_payload, true + + ### + Estimate whether message should be send as type external. + + @private + @param conversation_et [z.entitity.Conversation] Conversation entity + @param generic_message [z.protobuf.GenericMessage] Generic message that will be send + @return [Boolean] Is payload likely to be too big so that we switch to type external? + ### + _send_as_external_message: (conversation_et, generic_message) -> + estimated_number_of_clients = conversation_et.number_of_participants() * 4 + message_in_bytes = new Uint8Array(generic_message.toArrayBuffer()).length + estimated_payload_in_bytes = estimated_number_of_clients * message_in_bytes + return estimated_payload_in_bytes / 1024 > 200 + + ### + Post images to a conversation. + + @param conversation_et [z.entity.Conversation] Conversation to post the images + @param images [Object] Message content + ### + upload_images: (conversation_et, images) => + return if not @_can_upload_assets_to_conversation conversation_et + @send_encrypted_image_asset conversation_et, image for image in images + + ### + Post files to a conversation. + @param conversation_et [z.entity.Conversation] Conversation to post the files + @param files [Object] File objects + ### + upload_files: (conversation_et, files) => + return if not @_can_upload_assets_to_conversation conversation_et + @upload_file conversation_et, file for file in files + + ### + Post file to a conversation. + + @param conversation_et [z.entity.Conversation] Conversation to post the file + @param file [Object] File object + ### + upload_file: (conversation_et, file) => + return if not @_can_upload_assets_to_conversation conversation_et + message_et = null + + upload_started = Date.now() + tracking_data = + size_bytes: file.size + size_mb: z.util.bucket_values (file.size / 1024 / 1024), [0, 5, 10, 15, 20, 25] + type: z.util.get_file_extension file.name + conversation_type = if conversation_et.is_one2one() then 'one_to_one' else 'group' + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.UPLOAD_INITIATED, + $.extend tracking_data, {conversation_type: conversation_type} + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.COMPLETED_MEDIA_ACTION, + action: 'file', conversation_type: conversation_type + + @send_encrypted_asset_metadata conversation_et, file + .then (record) => + message_et = conversation_et.get_message_by_id record.mapped.id + @send_encrypted_asset conversation_et, file, record.mapped.id + .then => + upload_duration = (Date.now() - upload_started) / 1000 + @logger.log "Finished to upload asset for conversation'#{conversation_et.id} in #{upload_duration}" + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.UPLOAD_SUCCESSFUL, + $.extend tracking_data, {time: upload_duration} + .catch (error) => + @logger.log "Failed to upload asset for conversation'#{conversation_et.id}", error + if message_et.id + @send_encrypted_asset_upload_failed conversation_et, message_et.id + @update_message_as_upload_failed message_et + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.UPLOAD_FAILED, tracking_data + + ### + Delete message in conversation. + + @param conversation_et [z.entity.Conversation] Conversation to post the file + @param message_et [z.entity.Message] Message object + ### + delete_message: (message_et) => + conversation_et = @active_conversation() + if message_et? + generic_message = new z.proto.GenericMessage z.util.create_random_uuid() + generic_message.set 'deleted', @_construct_delete conversation_et.id, message_et.id + + @_send_encrypted_value @self_conversation().id, generic_message + .then => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.DELETED_MESSAGE, {mode: 'single'} + return @_delete_message conversation_et, message_et.id + .catch (error) -> + @logger.log "Failed to send delete message with id '#{message_id}' for conversation '#{conversation_et.id}'", error + + ### + Can user upload assets to conversation. + + # TODO move to conversation et + + @param conversation_et [z.entity.Conversation] Conversation to rename + ### + _can_upload_assets_to_conversation: (conversation_et) -> + return false if not conversation_et? + return false if conversation_et.removed_from_conversation() + return false if conversation_et.is_request() + return false if conversation_et.is_one2one() and conversation_et.connection().status() isnt z.user.ConnectionStatus.ACCEPTED + return true + + + ############################################################################### + # Event callbacks + ############################################################################### + + message_deleted: (event_json) => + conversation_id = event_json.data.conversation_id + message_id = event_json.data.message_id + + if conversation_id? and message_id? + conversation_et = @find_conversation_by_id conversation_id + @_delete_message conversation_et, message_id + else + @logger.log "Failed to delete message with id '#{message_id}'' for conversation '#{conversation_et.id}'" + + ### + A message or ping received in a conversation. + @param conversation_et [z.entity.Conversation] Conversation to add the event to + @param event_json [Object] JSON data of 'conversation.message-add' or 'conversation.knock' event + ### + add_event: (conversation_et, event_json) => + @_add_event_to_conversation event_json, conversation_et, (message_et) => + @_send_event_notification event_json, conversation_et, message_et + + ### + An asset was uploaded + @param event_json [Object] JSON data of 'conversation.asset-upload-complete' event + @return [z.entity.Conversation] The conversation that was created + ### + asset_upload_complete: (conversation_et, event_json) -> + message_et = conversation_et.get_message_by_id event_json.id + + if not message_et? + return @logger.log @logger.levels.ERROR, "Upload complete: Could not find message with id '#{event_json.id}'", event_json + + @update_message_as_upload_complete conversation_et, message_et, event_json.data + + ### + An asset failed + @param event_json [Object] JSON data of 'conversation.asset-upload-failed' event + @return [z.entity.Conversation] The conversation that was created + ### + asset_upload_failed: (conversation_et, event_json) -> + message_et = conversation_et.get_message_by_id event_json.id + + if not message_et? + return @logger.log @logger.levels.ERROR, "Upload failed: Could not find message with id '#{event_json.id}'", event_json + + if event_json.data.reason is z.assets.AssetUploadFailedReason.CANCELLED + @_delete_message conversation_et, message_et.id + else + @update_message_as_upload_failed message_et + + ### + An asset preview was send + @param event_json [Object] JSON data of 'conversation.asset-upload-failed' event + @return [z.entity.Conversation] The conversation that was created + ### + asset_preview: (conversation_et, event_json) -> + message_et = conversation_et.get_message_by_id event_json.id + + if not message_et? + return @logger.log @logger.levels.ERROR, 'Asset preview: Could not find message with id '#{event_json.id}'", event_json + + @update_message_with_asset_preview conversation_et, message_et, event_json.data + + ### + A conversation was created. + @param event_json [Object] JSON data of 'conversation.create' event + @return [z.entity.Conversation] The conversation that was created + ### + create: (event_json) => + conversation_et = @find_conversation_by_id event_json.id + + if not conversation_et? + conversation_et = @conversation_mapper.map_conversation event_json + @update_participating_user_ets conversation_et, (conversation_et) => + @_send_conversation_create_notification conversation_et + @save_conversation conversation_et + + return conversation_et + + ### + User were added to a group conversation. + @param conversation_et [z.entity.Conversation] Conversation to add users to + @param event_json [Object] JSON data of 'conversation.member-join' event + ### + member_join: (conversation_et, event_json) => + for user_id in event_json.data.user_ids when user_id isnt @user_repository.self().id + conversation_et.participating_user_ids.push user_id if user_id not in conversation_et.participating_user_ids() + + # Self user joins again + if @user_repository.self().id in event_json.data.user_ids + conversation_et.removed_from_conversation false + + @update_participating_user_ets conversation_et, => + @_add_event_to_conversation event_json, conversation_et, (message_et) -> + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et + + ### + Members of a group conversation were removed or left. + @param conversation_et [z.entity.Conversation] Conversation to remove users from + @param event_json [Object] JSON data of 'conversation.member-leave' event + ### + member_leave: (conversation_et, event_json) => + @_add_event_to_conversation event_json, conversation_et, (message_et) => + for user_et in message_et.user_ets() + if conversation_et.call() + if user_et.is_me + amplify.publish z.event.WebApp.CALL.STATE.DELETE, conversation_et.id + else + amplify.publish z.event.WebApp.CALL.STATE.REMOVE_PARTICIPANT, conversation_et.id, user_et.id + conversation_et.participating_user_ids.remove user_et.id + continue if not user_et.is_me + + conversation_et.removed_from_conversation true + if conversation_et.call() + amplify.publish z.event.WebApp.CALL.STATE.LEAVE, conversation_et.id + + @update_participating_user_ets conversation_et, -> + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et + + ### + Membership properties for a conversation were updated. + + @param conversation_et [z.entity.Conversation] Conversation entity that will be updated + @param event_json [Object] JSON data of 'conversation.member-update' event + @param conversation_et [z.entity.Conversation] Next conversation in list + ### + member_update: (conversation_et, event_json, next_conversation_et) => + previously_archived = conversation_et.is_archived() + next_conversation_et = @get_next_conversation conversation_et if not next_conversation_et? + + @conversation_mapper.update_self_status conversation_et, event_json.data + + if previously_archived and not conversation_et.is_archived() + @_fetch_users_and_events conversation_et + else if conversation_et.is_archived() + amplify.publish z.event.WebApp.CONVERSATION.SWITCH, conversation_et, next_conversation_et + + ### + A conversation was renamed. + @param conversation_et [z.entity.Conversation] Conversation entity that will be renamed + @param event_json [Object] JSON data of 'conversation.rename' event + ### + rename: (conversation_et, event_json) => + @_add_event_to_conversation event_json, conversation_et, (message_et) => + @conversation_mapper.update_properties conversation_et, event_json.data + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et + + + ############################################################################### + # Private + ############################################################################### + + ### + Convert a JSON event into an entity and add it to a given conversation. + + @param json [Object] Event data + @param conversation_et [z.entity.Conversation] Conversation entity the event will be added to + @param callback [Function] Function to be called on return + ### + _add_event_to_conversation: (json, conversation_et, callback) -> + message_et = @event_mapper.map_json_event json, conversation_et + @_update_user_ets message_et, (message_et) => + if conversation_et + conversation_et.add_message message_et + else + @logger.log @logger.levels.ERROR, + "Message cannot be added to unloaded conversation. Message type: #{message_et.type}" + error = new Error 'Conversation not loaded, message cannot be added' + custom_data = + message_type: message_et.type + Raygun.send error, custom_data + callback? message_et + + ### + Convert multiple JSON events into entities and add them to a given conversation. + + @param json [Object] Event data + @param conversation_et [z.entity.Conversation] Conversation entity the events will be added to + @param prepend [Boolean] Should existing messages be prepended + ### + _add_events_to_conversation: (json, conversation_et, prepend = true) -> + return if not json? + + message_ets = @event_mapper.map_json_events json, conversation_et + for message_et in message_ets + @_update_user_ets message_et + if prepend and conversation_et.messages().length > 0 + conversation_et.prepend_messages message_ets + else + conversation_et.add_messages message_ets + + ### + Check for duplicates by event IDs and cache the event ID. + + @private + @param message_et [z.entity.Message] Message entity + @param conversation_et [z.entity.Conversation] Conversation entity + @return [Boolean] Returns true if event is a duplicate + ### + _check_for_duplicate_event_by_nonce: (message_et, conversation_et) -> + return false if not message_et.nonce + event_nonce = "#{conversation_et.id}:#{message_et.nonce}:#{message_et.assets?()[0].type or message_et.super_type}" + if @processed_event_nonces[event_nonce] is undefined + @processed_event_nonces[event_nonce] = null + # @todo Maybe we need to reset "@processed_event_nonces" someday to save some memory, until now it's fine. + return false + else + @logger.log @logger.levels.WARN, "Event with nonce has been already processed : #{event_nonce}", message_et + amplify.publish z.event.WebApp.ANALYTICS.EVENT, + z.tracking.SessionEventName.INTEGER.EVENT_HIDDEN_DUE_TO_DUPLICATE_NONCE + return true + + ### + Fetch all unread events and users of a conversation. + @private + @param conversation_et [z.entity.Conversation] Conversation fetch events and users for + ### + _fetch_users_and_events: (conversation_et) -> + if not conversation_et.is_loaded() and not conversation_et.is_pending() + @update_participating_user_ets conversation_et + @_get_unread_events conversation_et + + ### + @example Look for ongoing call + has_call = @_has_active_event response.events, [z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE], [z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE] + ### + _has_active_event: (events, include_on, exclude_on) -> + event_is_active = false + + for event in events + if event.type in include_on + event_is_active = true + else if event.type in exclude_on + event_is_active = false + + return event_is_active + + ### + Forward the 'conversation.create' event to the SystemNotification repository for browser and audio notifications. + @private + @param conversation_et [z.entity.Conversation] Conversation that was created + ### + _send_conversation_create_notification: (conversation_et) -> + @user_repository.get_user_by_id conversation_et.creator, (user_et) -> + message_et = new z.entity.MemberMessage() + message_et.user user_et + message_et.member_message_type = z.message.SystemMessageType.CONVERSATION_CREATE + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et + + ### + Forward the event to the SystemNotification repository for browser and audio notifications. + + @private + @param event_json [Object] JSON data of received event + @param conversation_et [z.entity.Conversation] Conversation that was created + @param message_et [z.entity.Message] Message that has been received + ### + _send_event_notification: (event_json, conversation_et, message_et) -> + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et + + ### + Listener for incoming events from the WebSocket. + + @private + @note We check for events received multiple times via the WebSocket by event id here + @param event [Object] JSON data for event + #### + on_conversation_event: (event) => + if not event + error = new Error('Event response is undefined') + custom_data = + source: 'WebSocket' + Raygun.send error, custom_data + + @logger.log "»» Event: '#{event.type}'", {event_object: event, event_json: JSON.stringify event} + + # Ignore member join if we join a one2one conversation (accept a connection request) + if event.type is z.event.Backend.CONVERSATION.MEMBER_JOIN + connection_et = @user_repository.get_connection_by_conversation_id event.conversation + return if connection_et?.status() is z.user.ConnectionStatus.PENDING + + # Check if conversation was archived + @get_conversation_by_id event.conversation, (conversation_et) => + previously_archived = conversation_et.is_archived() + + switch event.type + when z.event.Backend.CONVERSATION.CREATE + @create event + when z.event.Backend.CONVERSATION.MEMBER_JOIN + @member_join conversation_et, event + when z.event.Backend.CONVERSATION.MEMBER_LEAVE + @member_leave conversation_et, event + when z.event.Backend.CONVERSATION.MEMBER_UPDATE + @member_update conversation_et, event + when z.event.Backend.CONVERSATION.RENAME + @rename conversation_et, event + when z.event.Backend.CONVERSATION.ASSET_UPLOAD_COMPLETE + @asset_upload_complete conversation_et, event + when z.event.Backend.CONVERSATION.ASSET_UPLOAD_FAILED + @asset_upload_failed conversation_et, event + when z.event.Backend.CONVERSATION.ASSET_PREVIEW + @asset_preview conversation_et, event + when z.event.Backend.CONVERSATION.MESSAGE_DELETE + @message_deleted event + else + @add_event conversation_et, event + + # Un-archive it also on the backend side + if not @is_handling_notifications and previously_archived and not conversation_et.is_archived() + @logger.log @logger.levels.INFO, "Unarchiving conversation '#{conversation_et.id}' with new event" + @unarchive_conversation conversation_et + + ### + Updates the user entities that are part of a message. + @param message_et [z.entity.Message] Message to be updated + @param callback [Function] Function to be called on return + ### + _update_user_ets: (message_et, callback) => + @user_repository.get_user_by_id message_et.from, (user_et) => + message_et.user user_et + if message_et.is_member() + @user_repository.get_users_by_id message_et.user_ids(), (user_ets) -> + message_et.user_ets user_ets + else if message_et.has_asset_text() + for asset_et in message_et.assets() when asset_et.is_text() + if not message_et.user() + Raygun.send new Error 'Message does not contain user when updating' + else + asset_et.theme_color = message_et.user().accent_color() + callback? message_et + + ### + Cancel asset upload. + @param message_et [z.entity.Message] message_et on which the cancel was initiated + ### + cancel_asset_upload: (message_et) => + conversation_et = @active_conversation() + @asset_service.cancel_asset_upload message_et.assets()[0].upload_id() + @_delete_message conversation_et, message_et.id + @send_encrypted_asset_upload_failed conversation_et, message_et.id, z.assets.AssetUploadFailedReason.CANCELLED + + _handle_deleted_clients: (deleted_client_map, payload) -> + return Promise.resolve() + .then => + if _.isEmpty deleted_client_map + @logger.log @logger.levels.INFO, 'No obsolete clients that need to be removed' + return payload + else + @logger.log @logger.levels.INFO, 'Removing payload for deleted clients', deleted_client_map + delete_promises = [] + for user_id, client_ids of deleted_client_map + for client_id in client_ids + @logger.log @logger.levels.WARN, "The client '#{client_id}' from '#{user_id}' is obsolete and will be removed" + delete payload.recipients[user_id][client_id] + delete_promises.push @user_repository.client_repository.delete_client_and_session user_id, client_id + delete payload.recipients[user_id] if Object.keys(payload.recipients[user_id]).length is 0 + + Promise.all delete_promises + .then -> + return payload + + _handle_missing_clients: (missing_client_map, generic_message, payload) -> + return Promise.resolve() + .then => + if _.isEmpty missing_client_map + @logger.log @logger.levels.INFO, 'No missing clients that need to be added' + return payload + else + @logger.log @logger.levels.INFO, "Adding payload for missing clients of '#{Object.keys(missing_client_map).length}' users", missing_client_map + save_promises = [] + + @cryptography_repository.encrypt_generic_message missing_client_map, generic_message, payload + .then (updated_payload) => + payload = updated_payload + for user_id, client_ids of missing_client_map + for client_id in client_ids + save_promises.push @user_repository.add_client_to_user user_id, new z.client.Client {id: client_id} + + return Promise.all save_promises + .then -> + return payload + + _update_payload_for_changed_clients: (error_response, generic_message, payload) => + return Promise.resolve() + .then => + if error_response.missing + @logger.log @logger.levels.WARN, 'Payload for clients was missing', error_response + @_handle_deleted_clients error_response.deleted, payload + .then (updated_payload) => + return @_handle_missing_clients error_response.missing, generic_message, updated_payload + else + throw error_response + + ### + Delete message from UI an database + @param conversation_et [z.entity.Conversation] Conversation that contains the message + @param message_id [String] Message to delete + ### + _delete_message: (conversation_et, message_id) => + conversation_et.remove_message_by_id message_id + @conversation_service.delete_message_from_db conversation_et.id, message_id + + ### + Delete messages from UI an database + @param conversation_et [z.entity.Conversation] Conversation that contains the message + ### + _delete_messages: (conversation_et) -> + conversation_et.remove_messages() + @conversation_service.delete_messages_from_db conversation_et.id + + ### + Update asset in UI and DB as failed + @param message_et [z.entity.Message] Message to update + ### + update_message_as_upload_failed: (message_et) => + asset_et = message_et.get_first_asset() + asset_et.status z.assets.AssetTransferState.UPLOAD_FAILED + asset_et.upload_failed_reason z.assets.AssetUploadFailedReason.FAILED + @conversation_service.update_asset_as_failed_in_db message_et.primary_key + + ### + Update asset in UI and DB as completed + + @param conversation_et [z.entity.Conversation] Conversation that contains the message + @param message_et [z.entity.Message] Message to delete + @param asset_data [Object] + @option id [Number] asset id + @option otr_key [Uint8Array] aes key + @option sha256 [Uint8Array] hash of the encrypted asset + ### + update_message_as_upload_complete: (conversation_et, message_et, asset_data) => + resource = z.assets.AssetRemoteData.v2 conversation_et.id, asset_data.id, asset_data.otr_key, asset_data.sha256 + asset_et = message_et.get_first_asset() + asset_et.original_resource resource + asset_et.status z.assets.AssetTransferState.UPLOADED + @conversation_service.update_asset_as_uploaded_in_db message_et.primary_key, asset_data + + ### + Update asset in UI and DB with preview + + @param conversation_et [z.entity.Conversation] Conversation that contains the message + @param message_et [z.entity.Message] Message to delete + @param asset_data [Object] + @option id [Number] asset id + @option otr_key [Uint8Array] aes key + @option sha256 [Uint8Array] hash of the encrypted asset + ### + update_message_with_asset_preview: (conversation_et, message_et, asset_data) => + resource = z.assets.AssetRemoteData.v2 conversation_et.id, asset_data.id, asset_data.otr_key, asset_data.sha256 + asset_et = message_et.get_first_asset() + asset_et.preview_resource resource + @conversation_service.update_asset_preview_in_db message_et.primary_key, asset_data + + + ############################################################################### + # Helpers + ############################################################################### + + ### + Archive all conversations but not the self conversation. + @note Archiving the self conversation will lead to problems on other clients (like Android). + ### + archive_all_conversations: => + @archive_conversation conversation_et for conversation_et in @conversations() when not conversation_et.is_self() + + ### + Clear and leave all conversations but not the self conversation. + ### + clear_all_conversations: => + @clear_conversation conversation_et, conversation_et.is_group() for conversation_et in @conversations_unarchived() + + ### + Un-archive all conversations (and even the self conversation). + @note Un-archiving all conversations can help to reset the client to a proper state. + ### + unarchive_all_conversations: => + @unarchive_conversation conversation_et for conversation_et in @conversations() + + + ############################################################################### + # Tracking helpers + ############################################################################### + + ### + Count of group conversations + @return [Integer] Number of group conversations + ### + get_number_of_group_conversations: -> + group_conversations = (i for conversation_et, i in @conversations() when conversation_et.is_group()) + return group_conversations.length + + ### + Count of silenced conversations + @return [Integer] Number of conversations that are silenced + ### + get_number_of_silenced_conversations: => + silenced_conversations = (i for conversation_et, i in @conversations() when conversation_et.is_muted()) + return silenced_conversations.length + + ### + Count number of pending uploads + @return [Integer] Number of pending uploads + ### + get_number_of_pending_uploads: => + return @conversations().reduce (sum, conversation_et) -> + sum + conversation_et.get_number_of_pending_uploads() + , 0 + + ### + Analyze sent text message for rich media content. + @private + @param message [String] Message content to be checked for rich media + ### + _analyze_sent_message: (message) -> + soundcloud_links = message.match z.media.MediaEmbeds.regex.soundcloud + if soundcloud_links + amplify.publish z.event.WebApp.ANALYTICS.EVENT, + z.tracking.SessionEventName.INTEGER.SOUNDCLOUD_LINKS_SENT, soundcloud_links.length + + youtube_links = message.match z.media.MediaEmbeds.regex.youtube + if youtube_links + amplify.publish z.event.WebApp.ANALYTICS.EVENT, + z.tracking.SessionEventName.INTEGER.YOUTUBE_LINKS_SENT, youtube_links.length + + ### + Analyze sent text message for rich media content. + @param client [Object] + ### + on_self_client_add: (client) => + return + self = @user_repository.self() + message_et = new z.entity.E2EEDeviceMessage() + message_et.user self + message_et.device client + message_et.device_owner self + + # TODO save message + for conversation_et in @filtered_conversations() + if conversation_et.type() in [z.conversation.ConversationType.ONE2ONE, z.conversation.ConversationType.REGULAR] + conversation_et.add_message message_et diff --git a/app/script/conversation/ConversationService.coffee b/app/script/conversation/ConversationService.coffee new file mode 100644 index 00000000000..8bbe3df6de1 --- /dev/null +++ b/app/script/conversation/ConversationService.coffee @@ -0,0 +1,420 @@ +# +# 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 service for all conversation calls to the backend REST API. +class z.conversation.ConversationService + URL_CONVERSATIONS: '/conversations' + ### + Construct a new Conversation Service. + + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client, @storage_service) -> + @logger = new z.util.Logger 'z.conversation.ConversationService', z.config.LOGGER.OPTIONS + + ### + Saves a conversation entity in the local database. + + @param conversation_et [z.entity.Conversation] Conversation entity + @return [Promise] Promise that will resolve with the primary key of the persisted conversation entity + ### + _save_conversation_in_db: (conversation_et) -> + return new Promise (resolve, reject) => + store_name = @storage_service.OBJECT_STORE_CONVERSATIONS + @storage_service.save store_name, conversation_et.id, conversation_et.serialize() + .then (primary_key) => + @logger.log @logger.levels.INFO, "Conversation '#{primary_key}' was stored for the first time" + resolve() + .catch (error) => + @logger.log @logger.levels.ERROR, "Conversation '#{conversation_et.id}' could not be stored", error + reject error + + ### + Updates a conversation entity in the database. + + @param updated_field [z.conversation.ConversationUpdateType] Property of the conversation entity which needs to be updated in the local database + @return [Promise] Promise which resolves with the conversation entity (if update was successful) or the conversation entity (if it was a new entity) + ### + _update_conversation_in_db: (conversation_et, updated_field) -> + return new Promise (resolve, reject) => + store_name = @storage_service.OBJECT_STORE_CONVERSATIONS + + switch updated_field + when z.conversation.ConversationUpdateType.ARCHIVED_STATE + entity = + archived_state: conversation_et.archived_state() + archived_timestamp: conversation_et.archived_timestamp() + when z.conversation.ConversationUpdateType.CLEARED_TIMESTAMP + entity = cleared_timestamp: conversation_et.cleared_timestamp() + when z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP + entity = last_event_timestamp: conversation_et.last_event_timestamp() + when z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP + entity = last_read_timestamp: conversation_et.last_read_timestamp() + when z.conversation.ConversationUpdateType.MUTED_STATE + entity = + muted_state: conversation_et.muted_state() + muted_timestamp: conversation_et.muted_timestamp() + + @storage_service.update store_name, conversation_et.id, entity + .then (number_of_updated_records) => + if number_of_updated_records + @logger.log @logger.levels.INFO, + "Conversation '#{conversation_et.id}' got a persistent update for property '#{updated_field}'" + resolve conversation_et + else + @_save_conversation_in_db conversation_et + .catch (error) => + @logger.log @logger.levels.ERROR, "Conversation '#{conversation_et.id}' could not be updated", error + reject error + + ############################################################################### + # Create conversations + ############################################################################### + + ### + Create a new conversation. + + @note Supply at least 2 user IDs! Do not include the requestor + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/createGroupConversation + + @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 callback [Function] Function to be called on server return + ### + create_conversation: (user_ids, name, callback) -> + @client.send_json + url: @client.create_url z.conversation.ConversationService::URL_CONVERSATIONS + type: 'POST' + data: + users: user_ids + name: name + callback: callback + + ### + Create a One:One conversation. + + @note Do not include the requestor + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/createOne2OneConversation + + @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 callback [Function] Function to be called on server return + ### + create_one_to_one_conversation: (user_ids, name, callback) -> + @client.send_json + url: @client.create_url '/conversations/one2one' + type: 'POST' + data: + users: user_ids + name: name + callback: callback + + ############################################################################### + # Get conversations + ############################################################################### + + ### + Retrieves meta information about all the conversations of a user. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/conversations + + @param limit [Integer] Number of results to return (default 100, max 100) + @param conversation_id [String] Conversation ID to start from + ### + get_conversations: (limit = 100, conversation_id = undefined) -> + @client.send_request + url: @client.create_url z.conversation.ConversationService::URL_CONVERSATIONS + type: 'GET' + data: + size: limit + start: conversation_id + + ### + Get a conversation by ID. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/conversation + + @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) -> + @client.send_request + url: @client.create_url "/conversations/#{conversation_id}" + type: 'GET' + callback: callback + + ### + Get the last (i.e. most current) event ID per conversation. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/lastEvents + @todo Implement paging for this endpoint + + @param callback [Function] Function to be called on server return + ### + get_last_events: (callback) -> + @client.send_request + url: @client.create_url '/conversations/last-events' + type: 'GET' + callback: callback + + ############################################################################### + # Send events + ############################################################################### + + ### + Remove member from conversation. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/removeMember + + @param conversation_id [String] ID of 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 + ### + delete_members: (conversation_id, user_id, callback) -> + @client.send_request + url: @client.create_url "/conversations/#{conversation_id}/members/#{user_id}" + type: 'DELETE' + callback: callback + + ### + Delete events from a conversation. + + @param message_id [String] ID of conversation to remove message from + @param primary_key [String] ID of the actual message + ### + delete_message_from_db: (conversation_id, message_id) -> + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'raw.conversation' + .equals conversation_id + .and (record) -> record.mapped?.id is message_id + .delete() + + ### + Delete events from a conversation. + + @param conversation_id [String] delete message for this conversation + ### + delete_messages_from_db: (conversation_id) -> + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'raw.conversation' + .equals conversation_id + .delete() + + ### + Delete events from a conversation. + + @param primary_key [String] Primary key used to find an event in the database + ### + update_asset_as_uploaded_in_db: (primary_key, asset_data) -> + @storage_service.load @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key + .then (record) => + record.mapped.data.id = asset_data.id + record.mapped.data.otr_key = asset_data.otr_key + record.mapped.data.sha256 = asset_data.sha256 + record.mapped.data.status = z.assets.AssetTransferState.UPLOADED + @storage_service.update @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key, record + .then => + @logger.log 'Updated asset message_et (uploaded)', primary_key + + ### + Delete events from a conversation. + + @param primary_key [String] Primary key used to find an event in the database + ### + update_asset_preview_in_db: (primary_key, asset_data) -> + @storage_service.load @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key + .then (record) => + record.mapped.data.preview_id = asset_data.id + record.mapped.data.preview_otr_key = asset_data.otr_key + record.mapped.data.preview_sha256 = asset_data.sha256 + @storage_service.update @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key, record + .then => + @logger.log 'Updated asset message_et (preview)', primary_key + + ### + Delete events from a conversation. + + @param primary_key [String] Primary key used to find an event in the database + ### + update_asset_as_failed_in_db: (primary_key, reason) -> + @storage_service.load @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key + .then (record) => + record.mapped.data.status = z.assets.AssetTransferState.UPLOAD_FAILED + record.mapped.data.reason = reason + @storage_service.update @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key, record + .then => + @logger.log 'Updated asset message_et (failed)', primary_key + + ### + Loads conversation states from the local database. + + ### + load_conversation_states_from_db: => + return new Promise (resolve, reject) => + @storage_service.get_all @storage_service.OBJECT_STORE_CONVERSATIONS + .then (conversation_states) => + @logger.log @logger.levels.INFO, "Loaded '#{conversation_states.length}' local conversation states", conversation_states + resolve conversation_states + .catch (error) => + @logger.log @logger.levels.ERROR, 'Failed to load local conversation states', error + reject error + + ### + Load conversation events. + + @param conversation_id [String] ID of conversation + @param offset [String] Timestamp that loaded events have to undercut + @param limit [Number] Amount of events to load + @return [Promise] Promise that resolves with the retrieved records + ### + load_events_from_db: (conversation_id, offset, limit = z.config.MESSAGES_FETCH_LIMIT) -> + return new Promise (resolve, reject) => + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'raw.conversation' + .equals conversation_id + .reverse() + .sortBy 'meta.timestamp' + .then (records) -> + records = (record for record in records when record.meta.timestamp < offset) if offset + has_further_events = records.length > limit + resolve [records.slice(0, limit), has_further_events] + .catch (error) => + @logger.log @logger.levels.ERROR, + "Failed to get events for conversation '#{conversation_et.id}': #{error.message}", error + reject error + + ### + Load all unread events of a conversation. + + @param conversation_et [z.entity.Conversation] Conversation entity + @param offset [String] Timestamp that loaded events have to undercut + @return [Promise] Promise that resolves with the retrieved records + ### + load_unread_events_from_db: (conversation_et, offset) -> + return new Promise (resolve, reject) => + conversation_id = conversation_et.id + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'raw.conversation' + .equals conversation_id + .reverse() + .sortBy 'raw.time' + .then (records) -> + records = (record for record in records when record.meta.timestamp < offset) if offset + records = (record for record in records when record.meta.timestamp > conversation_et.last_read_timestamp()) + resolve records + .catch (error) => + @logger.log @logger.levels.ERROR, + "Failed to get unread events for conversation '#{conversation_et.id}': #{error.message}", error + reject error + + ### + Add users to an existing conversation. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/addMembers + + @param conversation_id [String] ID of conversation to add users to + @param user_ids [Array] IDs of users to be added to the conversation + @return [Promise] Promise that resolves with the server response + ### + post_members: (conversation_id, user_ids) -> + @client.send_json + url: @client.create_url "/conversations/#{conversation_id}/members" + type: 'POST' + data: + users: user_ids + + ### + Post an encrypted message to a conversation. + @note If "recipients" are not specified you will receive a list of all missing OTR recipients (user-client-map). + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/postOtrMessage + @example How to send "recipients" payload + "recipients": { + "": { + "": "" + } + } + + @param conversation_id [String] ID of conversation to send message in + @param payload [Object] Payload to be posted + @option [OtrRecipients] recipients Map with per-recipient data + @option [String] sender Client ID of the sender + @param force_sending [Boolean] Should the backend ignore missing clients + @return [Promise] Promise that resolve when the message was sent + ### + post_encrypted_message: (conversation_id, payload, force_sending) -> + url = @client.create_url "/conversations/#{conversation_id}/otr/messages" + url = "#{url}?ignore_missing=true" if force_sending + + @client.send_json + url: url + type: 'POST' + data: payload + + ### + Saves or updates a conversation entity in the local database. + + @param conversation_et [z.entity.Conversation] Conversation entity + @param updated_field [z.conversation.ConversationUpdateType] Property of the conversation entity which needs to be updated in the local database + @return [Promise] Promise which resolves with the conversation entity (if update was successful) or the conversation entity (if it was a new entity) + ### + save_conversation_in_db: (conversation_et, updated_field) => + if updated_field + @_update_conversation_in_db conversation_et, updated_field + else + @_save_conversation_in_db conversation_et + + ### + Update conversation properties. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/updateConversation + + @param conversation_id [String] ID of conversation to rename + @param name [String] New conversation name + @param callback [Function] Function to be called on server return + ### + update_conversation_properties: (conversation_id, name, callback) -> + @client.send_json + url: @client.create_url "/conversations/#{conversation_id}" + type: 'PUT' + data: + name: name + callback: callback + + ### + Update self membership properties. + + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/conversations/updateSelf + + @param conversation_id [String] ID of conversation to update + @param payload [Object] Updated properties + @param callback [Function] Function to be called on server return + ### + update_member_properties: (conversation_id, payload, callback) -> + @client.send_json + url: @client.create_url "/conversations/#{conversation_id}/self" + type: 'PUT' + data: payload + callback: (response, error) -> + if callback? + callback + conversation: conversation_id + data: data + , error diff --git a/app/script/conversation/ConversationStatus.coffee b/app/script/conversation/ConversationStatus.coffee new file mode 100644 index 00000000000..3563f85217e --- /dev/null +++ b/app/script/conversation/ConversationStatus.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.conversation ?= {} + +# Enum of a user's participation status in a conversation. +z.conversation.ConversationStatus = + CURRENT_MEMBER: 0 + PAST_MEMBER: 1 diff --git a/app/script/conversation/ConversationType.coffee b/app/script/conversation/ConversationType.coffee new file mode 100644 index 00000000000..8a844115a98 --- /dev/null +++ b/app/script/conversation/ConversationType.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.conversation ?= {} + +# Enum of different conversation types. +z.conversation.ConversationType = + REGULAR: 0 + SELF: 1 + ONE2ONE: 2 + CONNECT: 3 diff --git a/app/script/conversation/ConversationUnreadType.coffee b/app/script/conversation/ConversationUnreadType.coffee new file mode 100644 index 00000000000..72e4ae76cf5 --- /dev/null +++ b/app/script/conversation/ConversationUnreadType.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.conversation ?= {} + +# Enum of different types of unread indicators for a conversation. +z.conversation.ConversationUnreadType = + UNREAD: 'unread' + PING: 'ping' + CONNECT: 'connect' + MISSED_CALL: 'missed-call' diff --git a/app/script/conversation/ConversationUpdateType.coffee b/app/script/conversation/ConversationUpdateType.coffee new file mode 100644 index 00000000000..94739892ccd --- /dev/null +++ b/app/script/conversation/ConversationUpdateType.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.conversation ?= {} + +z.conversation.ConversationUpdateType = + ARCHIVED_STATE: 'archived_state' + ARCHIVED_TIMESTAMP: 'archived_timestamp' + CLEARED_TIMESTAMP: 'cleared_timestamp' + LAST_EVENT_TIMESTAMP: 'last_event_timestamp' + LAST_READ_TIMESTAMP: 'last_read_timestamp' + MUTED_STATE: 'mute_state' + MUTED_TIMESTAMP: 'muted_timestamp' diff --git a/app/script/conversation/EventMapper.coffee b/app/script/conversation/EventMapper.coffee new file mode 100644 index 00000000000..d55eca4b7e4 --- /dev/null +++ b/app/script/conversation/EventMapper.coffee @@ -0,0 +1,452 @@ +# +# 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 ?= {} + +# Event Mapper to convert all server side JSON events into core entities. +class z.conversation.EventMapper + ### + Construct a new Event Mapper. + + @param asset_service [z.assets.AssetService] Asset handling service + ### + constructor: (@asset_service) -> + @logger = new z.util.Logger 'z.conversation.EventMapper', z.config.LOGGER.OPTIONS + + ### + Convert multiple JSON events into message entities. + + @param json [Object] Event data + @param conversation_et [z.entity.Conversation] Conversation entity the events belong to + + @return [Array] Mapped message entities + ### + map_json_events: (json, conversation_et) -> + events = (@map_json_event event, conversation_et for event in json.events.reverse() when event isnt undefined) + return events.filter (x) -> x isnt undefined + + map_json_event: (event, conversation_et) => + try + return @_map_json_event event, conversation_et + catch error + @logger.log @logger.levels.ERROR, 'Cannot map event', {error: error, event: event} + return undefined + + ### + Convert JSON event into a message entity. + + @param event [Object] Event data + @param conversation_et [z.entity.Conversation] Conversation entity the events belong to + + @return [z.entity.Message] Mapped message entity + ### + _map_json_event: (event, conversation_et) -> + switch event.type + when z.event.Backend.CONVERSATION.ASSET_META + message_et = @_map_event_asset_meta event + when z.event.Backend.CONVERSATION.ASSET_ADD + message_et = @_map_event_asset_add event + when z.event.Backend.CONVERSATION.MESSAGE_ADD + message_et = @_map_event_message_add event + when z.event.Backend.CONVERSATION.MEMBER_JOIN + message_et = @_map_event_member_join event, conversation_et + when z.event.Backend.CONVERSATION.MEMBER_LEAVE + message_et = @_map_event_member_leave event + when z.event.Backend.CONVERSATION.MEMBER_UPDATE + message_et = @_map_event_member_update() + when z.event.Backend.CONVERSATION.RENAME + message_et = @_map_event_rename event + when z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE + message_et = @_map_event_voice_channel_activate() + when z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE + message_et = @_map_event_voice_channel_deactivate event + when z.event.Backend.CONVERSATION.KNOCK + message_et = @_map_event_ping event + when z.event.Backend.CONVERSATION.LOCATION + message_et = @_map_event_location event + when z.event.Client.CONVERSATION.UNABLE_TO_DECRYPT + message_et = @_map_system_event_unable_to_decrypt event + else + message_et = @_map_event_ignored() + + message_et.id = event.id + message_et.type = event.type + message_et.from = event.from + message_et.timestamp = new Date(event.time).getTime() + message_et.primary_key = "#{conversation_et.id}@#{message_et.from}@#{message_et.timestamp}" + + if window.isNaN message_et.timestamp + @logger.log @logger.levels.ERROR, 'Could not get timestamp for message', event + + return message_et + + ############################################################################### + # Event mappers + ############################################################################### + + ### + Maps JSON data of conversation.asset_add message into message entity + + @private + + @param event [Object] Message data + + @return [z.entity.NormalMessage] Normal message entity + ### + _map_event_asset_add: (event) -> + message_et = new z.entity.ContentMessage() + if event.data?.info.tag is z.assets.ImageSizeType.PREVIEW + message_et.assets.push @_map_asset_preview_image event.data + if event.data?.info.tag is z.assets.ImageSizeType.MEDIUM + message_et.assets.push @_map_asset_medium_image event + message_et.nonce = event.data.info.nonce + return message_et + + ### + Maps JSON data of conversation.asset_add message into message entity + + @private + + @param event [Object] Message data + + @return [z.entity.NormalMessage] Normal message entity + ### + _map_event_asset_meta: (event) -> + message_et = new z.entity.ContentMessage() + message_et.assets.push @_map_asset_file event + message_et.nonce = event.data.info.nonce + return message_et + + ### + Maps JSON data of conversation.connect_request message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.NormalMessage] Normal message entity + ### + _map_event_connect_request: (event) -> + message_et = new z.entity.ContentMessage() + asset_et = @_map_asset_text event.data + message_et.visible false if not asset_et.text + message_et.assets.push asset_et + return message_et + + ### + Maps JSON data of other message types currently ignored into message entity + + @private + + @return [z.entity.SpecialMessage] Special message entity + ### + _map_event_ignored: -> + message_et = new z.entity.SystemMessage() + message_et.visible false + return message_et + + ### + Maps JSON data of conversation.member_join message into message entity + + @private + + @param event [Object] Message data received as JSON + @param conversation_et [z.entity.conversation] Conversation entity the event belongs to + + @return [z.entity.MemberMessage] Member message entity + ### + _map_event_member_join: (event, conversation_et) -> + message_et = new z.entity.MemberMessage() + if conversation_et.type() in [z.conversation.ConversationType.CONNECT, z.conversation.ConversationType.ONE2ONE] + if event.from is conversation_et.creator and event.data.user_ids.length is 1 + message_et.member_message_type = z.message.SystemMessageType.CONNECTION_ACCEPTED + event.data.user_ids = conversation_et.participating_user_ids() + else + message_et.visible false + else + creator_index = event.data.user_ids.indexOf event.from + if event.from is conversation_et.creator and creator_index isnt -1 + event.data.user_ids.splice creator_index, 1 + message_et.member_message_type = z.message.SystemMessageType.CONVERSATION_CREATE + + message_et.user_ids event.data.user_ids + + return message_et + + ### + Maps JSON data of conversation.member_join message into message entity + + @private + + @return [z.entity.MemberMessage] Member message entity + ### + _map_event_member_leave: (event) -> + message_et = new z.entity.MemberMessage() + message_et.user_ids event.data.user_ids + return message_et + + ### + Maps JSON data of conversation.member_update message into message entity + + @private + + @return [z.entity.MemberMessage] Member message entity + ### + _map_event_member_update: -> + message_et = new z.entity.MemberMessage() + return message_et + + ### + Maps JSON data of conversation.message_add message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.NormalMessage] Normal message entity + ### + _map_event_message_add: (event) -> + message_et = new z.entity.ContentMessage() + message_et.assets.push @_map_asset_text event.data + message_et.nonce = event.data.nonce + return message_et + + ### + Maps JSON data of conversation.knock message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.PingMessage] Ping message entity + ### + _map_event_ping: (event) -> + message_et = new z.entity.PingMessage() + message_et.nonce = event.data.nonce + return message_et + + ### + Maps JSON data of conversation.location message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.LocationMessage] Location message entity + ### + _map_event_location: (event) -> + message_et = new z.entity.ContentMessage() + asset_et = new z.entity.Location() + asset_et.longitude = event.data.location.longitude + asset_et.latitude = event.data.location.latitude + asset_et.name = event.data.location.name + asset_et.zoom = event.data.location.zoom + message_et.assets.push asset_et + message_et.nonce = event.data.nonce + return message_et + + ### + Maps JSON data of conversation.rename message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.RenameMessage] Rename message entity + ### + _map_event_rename: (event) -> + message_et = new z.entity.RenameMessage() + message_et.name = event.data.name + return message_et + + ### + Maps JSON data of conversation.voice-channel-activate message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.CallMessage] Call message entity + ### + _map_event_voice_channel_activate: -> + message_et = new z.entity.CallMessage() + message_et.call_message_type = z.message.CallMessageType.ACTIVATED + message_et.visible false + return message_et + ### + Maps JSON data of conversation.voice-channel-deactivate message into message entity + + @private + + @param event [Object] Message data received as JSON + + @return [z.entity.CallMessage] Call message entity + ### + _map_event_voice_channel_deactivate: (event) -> + message_et = new z.entity.CallMessage() + message_et.call_message_type = z.message.CallMessageType.DEACTIVATED + message_et.finished_reason = event.data.reason + message_et.visible message_et.finished_reason is z.calling.enum.CallFinishedReason.MISSED + return message_et + + ############################################################################### + # Asset mappers + ############################################################################### + + ### + Maps JSON data of text asset into asset entity + + @private + + @param data [Object] Asset data received as JSON + + @return [z.entity.Text] Text asset entity + ### + _map_asset_text: (data) -> + asset_et = new z.entity.Text data.id + asset_et.key = "text##{data.nonce}" + asset_et.text = data.content or data.message + asset_et.previews @_map_link_previews data.previews + return asset_et + + ### + Map link previews + + @private + + @param previews [Array] base64 encoded proto previews + + @return [Array] + ### + _map_link_previews: (link_previews = []) -> + return link_previews + .map (encoded_link_preview) -> z.proto.LinkPreview.decode64 encoded_link_preview + .map (link_preview) => @_map_link_preview link_preview + .filter (link_preview_et) -> link_preview_et? + + ### + Map link preview + + @private + + @param preview [z.proto.LinkPreview] + + @return [z.entity.LinkPreview] + ### + _map_link_preview: (link_preview = {}) -> + switch link_preview.preview + when 'article' + article = link_preview.article + + link_preview_et = new z.entity.LinkPreview() + link_preview_et.title = article.title + link_preview_et.summary = article.summary + link_preview_et.permanent_url = article.permanent_url + link_preview_et.original_url = link_preview.url + link_preview_et.url_offset = link_preview.url_offset + + if article.image?.uploaded? + {asset_token, asset_id, otr_key, sha256} = article.image.uploaded + otr_key = new Uint8Array otr_key.toArrayBuffer() + sha256 = new Uint8Array sha256.toArrayBuffer() + link_preview_et.image_resource z.assets.AssetRemoteData.v3 asset_id, otr_key, sha256, asset_token + + return link_preview_et + + ### + Maps JSON data of preview image asset into asset entity + + @private + + @param data [Object] Asset data received as JSON + + @return [z.entity.PreviewImage] Preview image asset entity + ### + _map_asset_preview_image: (data) -> + asset_et = new z.entity.PreviewImage data.id + asset_et.correlation_id = data.info.correlation_id + asset_et.content_type = data.content_type + asset_et.encoded_data = data.data + asset_et.width = data.info.original_width + asset_et.height = data.info.original_height + asset_et.original_width = data.info.original_width + asset_et.original_height = data.info.original_height + return asset_et + + ### + Maps JSON data of medium image asset into asset entity + + @private + + @param data [Object] Asset data received as JSON + + @return [z.entity.MediumImage] Medium image asset entity + ### + _map_asset_medium_image: (data) -> + asset_et = new z.entity.MediumImage data.data.id + asset_et.correlation_id = data.data.info.correlation_id + asset_et.width = data.data.info.width + asset_et.height = data.data.info.height + asset_et.original_width = data.data.info.original_width + asset_et.original_height = data.data.info.original_height + asset_et.ratio = asset_et.original_height / asset_et.original_width + asset_et.resource z.assets.AssetRemoteData.v2 data.conversation, asset_et.id, data.data.otr_key, data.data.sha256 + asset_et.dummy_url = z.util.dummy_image asset_et.original_width, asset_et.original_height + return asset_et + + ### + Maps JSON data of file asset into asset entity + + @private + + @param data [Object] Asset data received as JSON + + @return [z.entity.MediumImage] File asset entity + ### + _map_asset_file: (data) -> + asset_et = new z.entity.File data.data.id + asset_et.correlation_id = data.data.info.correlation_id + asset_et.conversation_id = data.conversation + + # original + asset_et.file_size = data.data.content_length + asset_et.file_type = data.data.content_type + asset_et.file_name = data.data.info.name + asset_et.meta = data.data.meta + asset_et.original_resource z.assets.AssetRemoteData.v2 asset_et.conversation_id, asset_et.id, data.data.otr_key, data.data.sha256, + if data.data.preview_id? + asset_et.preview_resource z.assets.AssetRemoteData.v2 asset_et.conversation_id, data.data.preview_id, data.data.preview_otr_key, data.data.preview_sha256 + asset_et.status data.data.status or z.assets.AssetTransferState.UPLOADING # TODO + return asset_et + + ### + Maps JSON data of local decrypt errors to message entity + + @private + + @param data [Object] Error data received as JSON + + @return [z.entity.MediumImage] Medium image asset entity + ### + _map_system_event_unable_to_decrypt: (event) -> + message_et = new z.entity.DecryptErrorMessage() + # error_code style "3690 (f0c0272e8f053774)" + message_et.error_code = event.error_code?.substring(0, 4) + message_et.client_id = event.error_code?.substring(5).replace(/[()]/g, '') + return message_et diff --git a/app/script/cryptography/CryptographyError.coffee b/app/script/cryptography/CryptographyError.coffee new file mode 100644 index 00000000000..ce4b43abed1 --- /dev/null +++ b/app/script/cryptography/CryptographyError.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.cryptography ?= {} + +class z.cryptography.CryptographyError + constructor: (message, type) -> + @name = @constructor.name + @message = message + @stack = (new Error()).stack + @type = type + + @:: = new Error() + @::constructor = @ + @::TYPE = + BROKEN_EXTERNAL: 'z.cryptography.CryptographyError::TYPE.BROKEN_EXTERNAL' + IGNORED_ASSET: 'z.cryptography.CryptographyError::TYPE.IGNORED_ASSET' + IGNORED_HOT_KNOCK: 'z.cryptography.CryptographyError::TYPE.IGNORED_HOT_KNOCK' + IGNORED_PREVIEW: 'z.cryptography.CryptographyError::TYPE.IGNORED_PREVIEW' + MISSING_MESSAGE: 'z.cryptography.CryptographyError::TYPE.MISSING_MESSAGE' + PREVIOUSLY_STORED: 'z.cryptography.CryptographyError::TYPE.PREVIOUSLY_STORED' + UNHANDLED_TYPE: 'z.cryptography.CryptographyError::TYPE.UNHANDLED_TYPE' diff --git a/app/script/cryptography/CryptographyErrorType.coffee b/app/script/cryptography/CryptographyErrorType.coffee new file mode 100644 index 00000000000..cf05dc887f3 --- /dev/null +++ b/app/script/cryptography/CryptographyErrorType.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.cryptography ?= {} + +z.cryptography.CryptographyErrorType = + DUPLICATE_MESSAGE: '1701' + INVALID_MESSAGE_SESSION_NOT_MATCHING: '1976' + INVALID_MESSAGE_SESSION_MISSING: '2237' + INVALID_SIGNATURE: '8550' + OUTDATED_MESSAGE: '2521' + PRE_KEY_NOT_FOUND: '3337' + REMOTE_IDENTITY_CHANGED: '3690' + TOO_DISTANT_FUTURE: '1300' diff --git a/app/script/cryptography/CryptographyMapper.coffee b/app/script/cryptography/CryptographyMapper.coffee new file mode 100644 index 00000000000..be11a719f15 --- /dev/null +++ b/app/script/cryptography/CryptographyMapper.coffee @@ -0,0 +1,228 @@ +# +# 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.cryptography ?= {} + +# Cryptography Mapper to convert all server side JSON encrypted events into core entities +class z.cryptography.CryptographyMapper + # Construct a new Cryptography Mapper. + constructor: -> + @logger = new z.util.Logger 'z.cryptography.CryptographyMapper', z.config.LOGGER.OPTIONS + + ### + OTR to JSON mapper. + @param generic_message [z.proto.GenericMessage] Received ProtoBuffer message + @param event [z.event.Backend.CONVERSATION.OTR-ASSET-ADD, z.event.Backend.CONVERSATION.OTR-MESSAGE-ADD] Event + @return [Object] Promise that resolves with the mapped event + ### + map_generic_message: (generic_message, event) => + mapped = undefined + + Promise.resolve() + .then -> + if not generic_message + error_message = "Failed to map OTR event '#{event.id}' as decrypted generic message is missing" + throw new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.MISSING_MESSAGE + .then -> + mapped = + conversation: event.conversation + id: generic_message.message_id + from: event.from + time: event.time + .then => + return @_map_generic_message generic_message, event + .then (specific_content) -> + $.extend mapped, specific_content + return mapped + + _map_generic_message: (generic_message, event) -> + switch generic_message.content + when 'asset' + return @_map_asset generic_message.asset, generic_message.message_id, event.data?.id + when 'cleared' + return @_map_cleared generic_message.cleared + when 'deleted' + return @_map_deleted generic_message.deleted + when 'external' + return @_map_external generic_message.external, event + when 'image' + return @_map_image generic_message.image, event.data.id + when 'knock' + return @_map_knock generic_message.knock, generic_message.message_id + when 'lastRead' + return @_map_last_read generic_message.lastRead + when 'location' + return @_map_location generic_message.location, generic_message.message_id + when 'text' + return @_map_text generic_message.text, generic_message.message_id + else + error_message = "Skipped event '#{generic_message.message_id}' of unhandled type '#{generic_message.content}'" + throw new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.UNHANDLED_TYPE + + _map_asset: (asset, event_nonce, event_id) -> + if asset.uploaded? + return @_map_asset_uploaded asset.uploaded, event_id + else if asset.not_uploaded? + return @_map_asset_not_uploaded asset.not_uploaded + else if asset.preview? + return @_map_asset_preview asset.preview, event_id + else if asset.original? + return @_map_asset_original asset.original, event_nonce + else + error_message = 'Ignored asset preview' + @logger.log @logger.levels.INFO, error_message + throw new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.IGNORED_ASSET + + _map_asset_meta_data: (original) -> + if original.audio? + return {} = + duration: original.audio.duration_in_millis.toNumber() / 1000 + loudness: new Uint8Array(original.audio.normalized_loudness?.toArrayBuffer() or []) + + _map_asset_original: (original, event_nonce) -> + return {} = + data: + content_length: original.size.toNumber() + content_type: original.mime_type + info: + name: original.name + nonce: event_nonce + meta: @_map_asset_meta_data(original) + type: z.event.Backend.CONVERSATION.ASSET_META + + _map_asset_not_uploaded: (not_uploaded) -> + return {} = + data: + reason: not_uploaded + type: z.event.Backend.CONVERSATION.ASSET_UPLOAD_FAILED + + _map_asset_uploaded: (uploaded, event_id) -> + return {} = + data: + id: event_id + otr_key: new Uint8Array uploaded.otr_key?.toArrayBuffer() + sha256: new Uint8Array uploaded.sha256?.toArrayBuffer() + type: z.event.Backend.CONVERSATION.ASSET_UPLOAD_COMPLETE + + _map_asset_preview: (preview, event_id) -> + return {} = + data: + id: event_id + otr_key: new Uint8Array preview.remote.otr_key?.toArrayBuffer() + sha256: new Uint8Array preview.remote.sha256?.toArrayBuffer() + type: z.event.Backend.CONVERSATION.ASSET_PREVIEW + + _map_cleared: (cleared) -> + return {} = + conversation: cleared.conversation_id + data: + cleared_timestamp: cleared.cleared_timestamp.toString() + type: z.event.Backend.CONVERSATION.MEMBER_UPDATE + + _map_deleted: (deleted) -> + return {} = + data: + conversation_id: deleted.conversation_id + message_id: deleted.message_id + type: z.event.Backend.CONVERSATION.MESSAGE_DELETE + + ### + Unpacks a specific generic message which is wrapped inside an external generic message. + + @note Wrapped messages get the 'message_id' of their wrappers (external message) + @param external [z.proto.GenericMessage] Generic message of type 'external' + @param event [JSON] Backend event of type 'conversation.otr-message-add' + ### + _map_external: (external, event) -> + data = + text: z.util.base64_to_array event.data.data + otr_key: new Uint8Array external.otr_key.toArrayBuffer() + sha256: new Uint8Array external.sha256.toArrayBuffer() + + z.assets.AssetCrypto.decrypt_aes_asset data.text.buffer, data.otr_key.buffer, data.sha256.buffer + .then (external_message_buffer) -> + return z.proto.GenericMessage.decode external_message_buffer + .then (generic_message) => + @logger.log @logger.levels.INFO, "Received external message of type '#{generic_message.content}'", generic_message + return @_map_generic_message generic_message, event + .catch (error) -> + throw new z.cryptography.CryptographyError error.message, z.cryptography.CryptographyError::TYPE.BROKEN_EXTERNAL + + _map_image: (image, event_id) -> + if image.tag is 'medium' + return @_map_image_medium image, event_id + else + error_message = 'Ignored image preview' + @logger.log @logger.levels.INFO, error_message + throw new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.IGNORED_PREVIEW + + _map_image_medium: (image, event_id) -> + return {} = + data: + content_length: image.size + content_type: image.mime_type + id: event_id + info: + tag: image.tag + width: image.width + height: image.height + nonce: event_id + original_width: image.original_width + original_height: image.original_height + public: false + otr_key: new Uint8Array image.otr_key?.toArrayBuffer() + sha256: new Uint8Array image.sha256?.toArrayBuffer() + type: z.event.Backend.CONVERSATION.ASSET_ADD + + _map_knock: (knock, event_id) -> + if knock.hot_knock + error_message = 'Ignored hot knock' + @logger.log @logger.levels.INFO, error_message + throw new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.IGNORED_HOT_KNOCK + else + return {} = + data: + nonce: event_id + type: z.event.Backend.CONVERSATION.KNOCK + + _map_last_read: (last_read) -> + return {} = + conversation: last_read.conversation_id + data: + last_read_timestamp: last_read.last_read_timestamp.toString() + type: z.event.Backend.CONVERSATION.MEMBER_UPDATE + + _map_location: (location, event_id) -> + return {} = + data: + location: + longitude: location.longitude + latitude: location.latitude + name: location.name + zoom: location.zoom + nonce: event_id + type: z.event.Backend.CONVERSATION.LOCATION + + _map_text: (text, event_id) -> + return {} = + data: + content: "#{text.content}" + nonce: event_id + previews: text.link_preview.map (preview) -> preview.encode64() + type: z.event.Backend.CONVERSATION.MESSAGE_ADD diff --git a/app/script/cryptography/CryptographyRepository.coffee b/app/script/cryptography/CryptographyRepository.coffee new file mode 100644 index 00000000000..7b3c8e9e6b6 --- /dev/null +++ b/app/script/cryptography/CryptographyRepository.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.cryptography ?= {} + +# Cryptography repository for all cryptography interactions with the cryptography service. +class z.cryptography.CryptographyRepository + ### + Construct a new Cryptography repository. + @param cryptography_service [z.cryptography.CryptographyService] Backend REST API cryptography service implementation + @param storage_repository [z.storage.StorageRepository] Repository for all storage interactions + ### + constructor: (@cryptography_service, @storage_repository) -> + @logger = new z.util.Logger 'z.cryptography.CryptographyRepository', z.config.LOGGER.OPTIONS + + @cryptography_mapper = new z.cryptography.CryptographyMapper() + + @current_client = undefined + @cryptobox = undefined + return @ + + + ############################################################################### + # Initialization + ############################################################################### + + ### + Initialize the repository. + @return [Promise] Promise that will resolve with the repository after initialization + ### + init: => + return Promise.resolve() + .then => + @logger.log @logger.levels.INFO, "Initialize Cryptobox with our storage repository on '#{@storage_repository.storage_service.db_name}'", @storage_repository + @cryptobox = new cryptobox.Cryptobox @storage_repository + @logger.log @logger.levels.INFO, 'Initialized repository' + return @ + + + ############################################################################### + # Pre-keys + ############################################################################### + + ### + Generate all keys need for client registration. + @return [Promise] Promise that resolves with an array of last resort key, pre-keys, and signaling keys + ### + generate_client_keys: => + return new Promise (resolve, reject) => + last_resort_key = undefined + pre_keys = undefined + signaling_keys = undefined + + @_generate_last_resort_key() + .then (key) => + last_resort_key = key + @logger.log @logger.levels.INFO, 'Generated last resort key', last_resort_key + return @_generate_pre_keys() + .then (keys) => + pre_keys = keys + @logger.log @logger.levels.INFO, "Number of generated pre-keys: #{pre_keys.length}", pre_keys + return @_generate_signaling_keys() + .then (keys) => + signaling_keys = keys + @logger.log @logger.levels.INFO, 'Generated signaling keys', signaling_keys + resolve [last_resort_key, pre_keys, signaling_keys] + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to generate client keys: #{error.message}", error + reject error + + ### + Get a pre-key for a user client. + + @param user_id [String] ID of user + @param client_id [String] ID of client to request pre-key for + @return [Promise] Promise that resolves with a pre-key for the client + ### + get_user_pre_key: (user_id, client_id) -> + @cryptography_service.get_user_pre_key user_id, client_id + .then (response) -> + return response.prekey + .catch (error) -> + if error.code is z.service.BackendClientError::STATUS_CODE.NOT_FOUND + throw new z.user.UserError 'Pre-key not found', z.user.UserError::TYPE.PRE_KEY_NOT_FOUND + else + error_message = "Failed to get pre-key from backend: #{error.message}" + throw new z.user.UserError error_message, z.user.UserError::TYPE.REQUEST_FAILURE + + ### + Get a pre-key for client of in the user client map. + @param user_client_map [Object] User client map to request pre-keys for + @return [Promise] Promise that resolves a map of pre-keys for the requested clients + ### + get_users_pre_keys: (user_client_map) -> + @cryptography_service.get_users_pre_keys user_client_map + .then (response) -> + return response + .catch (error) -> + error_message = "Failed to get pre-key from backend: #{error.message}" + throw new z.user.UserError error_message, z.user.UserError::TYPE.REQUEST_FAILURE + + ### + Construct the pre-key. + + @private + @param id [String] ID of pre-key to be constructed + @return [Promise} Promise that will resolve with the new pre-key as object + ### + _construct_pre_key_promise: (id) => + @cryptobox.new_prekey id + .then (pre_key_bundle) -> + pre_key_model = + id: id + key: z.util.array_to_base64 pre_key_bundle + return pre_key_model + + ### + Construct the last resort pre-key. + @private + @return [Promise} Promise that will resolve with the new pre-key as object + ### + _generate_last_resort_key: => + return @_construct_pre_key_promise Proteus.keys.PreKey.MAX_PREKEY_ID + + ### + Generate the pre-keys. + + @private + @return [Promise} Promise that will resolve with an arrays of all the generated new pre-keys as object + ### + _generate_pre_keys: => + return Promise.all (@_construct_pre_key_promise i for i in [0...1]) + + ### + Generate the signaling keys + + @private + @return [Object] Object containing the signaling keys + ### + _generate_signaling_keys: -> + random_bytes = new Uint8Array sodium.crypto_auth_hmacsha256_KEYBYTES + crypto.getRandomValues random_bytes + + hmac = sodium.crypto_auth_hmacsha256 random_bytes, sodium.crypto_hash_sha256 'salt' + encryption_key = sodium.to_base64 hmac + mac_key = sodium.to_base64 hmac + + signaling_keys = + enckey: encryption_key + mackey: mac_key + + return signaling_keys + + + ############################################################################### + # Sessions + ############################################################################### + + ### + Get session. + @param user_id [String] User ID + @param client_id [String] ID of client to retrieve session for + @return [Promise] Promise that resolves with the session + ### + get_session: (user_id, client_id) -> + return Promise.resolve() + .then => + return @load_session(user_id, client_id) or @_initiate_new_session user_id, client_id + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to get session for client '#{client_id}' of user '#{user_id}': #{error.message}", error + + ### + Get sessions. + @param user_client_map [Object] User client map to get sessions for + @return [Promise>] Promise that resolves with an array of sessions + ### + get_sessions: (user_client_map, use_local_sessions = true) => + return new Promise (resolve, reject) => + cryptobox_session_map = {} + missing_session_map = {} + + if use_local_sessions + [cryptobox_session_map, missing_session_map] = @_get_sessions_local user_client_map + @logger.log @logger.levels.INFO, "Found local sessions for '#{Object.keys(cryptobox_session_map).length}' users", cryptobox_session_map + else + missing_session_map = user_client_map + + @_get_sessions_missing cryptobox_session_map, missing_session_map + .then (cryptobox_session_map) -> + resolve cryptobox_session_map + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to get sessions: #{error.message}", user_client_map + reject error + + ### + Loads the session from Cryptobox. + + @param user_id [String] ID of user + @param client_id [String] ID of client to retrieve session for + @return [cryptobox.CryptoboxSession] Retrieved session + ### + load_session: (user_id, client_id) => + session_id = @_construct_session_id user_id, client_id + return @cryptobox.session_load session_id + + ### + Resets a session. + + @param user_id [String] User ID of our chat partner + @param client_id [String] Client ID of our chat partner + @return [Promise] Promise that will resolve with the ID of the reset session + ### + reset_session: (user_id, client_id) => + return Promise.resolve() + .then => + cryptobox_session = @load_session user_id, client_id + + if cryptobox_session + @logger.log @logger.levels.INFO, "Deleting session for client '#{client_id}' of user '#{user_id}'", cryptobox_session + @cryptobox.session_delete cryptobox_session.id + .then -> return cryptobox_session.id + else + @logger.log @logger.levels.INFO, "We cannot delete the session for client '#{client_id}' of user '#{user_id}' because it was not found" + return undefined + + ### + Save a session. + @note Sessions MUST be saved AFTER encrypting messages, but BEFORE sending the encrypted message across the network. + @param session [cryptobox.CryptoboxSession] Session to be saved + ### + save_session: (cryptobox_session) => + @logger.log @logger.levels.INFO, "Persisting session '#{cryptobox_session.id}'", cryptobox_session + return @cryptobox.session_save cryptobox_session + + ### + Save sessions. + @param session [Array] Array of sessions to be saved + ### + save_sessions: (cryptobox_sessions) => + cryptobox_sessions.forEach (cryptobox_session) => @save_session cryptobox_session + + ### + Construct a session ID. + + @private + @param user_id [String] User ID for the remote participant + @param client_id [String] Client ID of the remote participant + @return [String] Client ID + ### + _construct_session_id: (user_id, client_id) -> + return "#{user_id}@#{client_id}" + + ### + Get local session for a user client map. + + @private + @param user_client_map [Object] User client map + @return [Array] An array containing two user client maps. The first contains the found sessions. + ### + _get_sessions_local: (user_client_map) -> + cryptobox_session_map = {} + missing_session_map = {} + for user_id, client_ids of user_client_map + cryptobox_session_map[user_id] = {} + for client_id in client_ids + session = @load_session user_id, client_id + + if session + cryptobox_session_map[user_id][client_id] = session + else + missing_session_map[user_id] ?= [] + missing_session_map[user_id].push client_id + return [cryptobox_session_map, missing_session_map] + + ### + Get missing session for a user client map. + + @private + @param user_client_map [Object] User client map + @param missing_session_map [Object] Client session map + @return [Promise] Promise that resolves with a client session map + ### + _get_sessions_missing: (cryptobox_session_map, missing_session_map) -> + if Object.keys(missing_session_map).length > 0 + @logger.log @logger.levels.INFO, "Missing sessions for '#{Object.keys(missing_session_map).length}' users", missing_session_map + return @_initiate_new_sessions cryptobox_session_map, missing_session_map + else + return Promise.resolve cryptobox_session_map + + ### + Initiate a new session for a given client. + + @private + @param user_id [String] User ID for the remote participant + @param client_id [String] Client ID of the remote participant + @return [Promise] Promise that resolves with the new session + ### + _initiate_new_session: (user_id, client_id) -> + @get_user_pre_key user_id, client_id + .then (pre_key) => + return @_session_from_prekey user_id, client_id, pre_key.key + .catch (error) => + if error instanceof z.user.UserError + if error.type is z.user.UserError::TYPE.PRE_KEY_NOT_FOUND + @logger.log @logger.levels.WARN, + "Client '#{client_id}' does no longer exist on the backend, should ignored and removed locally" + amplify.publish z.event.WebApp.CLIENT.DELETE, user_id, client_id + else if error.type is z.user.UserError::TYPE.REQUEST_FAILURE + @logger.log @logger.levels.WARN, + "Failed to request pre-key for client '#{client_id}' of user '#{user_id}'': #{error.message}", error + else + @logger.log @logger.levels.ERROR, + "Failed to initialize session from pre-key for client '#{client_id}' of user '#{user_id}': #{error.message}", error + return undefined + + ### + Initiate new sessions for a given map. + + @private + @param cryptobox_session_map [Object] User client map of containing the known sessions + @param user_client_map [Object] User client map of missing sessions + @return [Promise] Promise that resolves with a user client map containing the new sessions + ### + _initiate_new_sessions: (cryptobox_session_map, user_client_map) -> + @get_users_pre_keys user_client_map + .then (user_pre_key_map) => + @logger.log @logger.levels.INFO, "Fetched pre-keys for '#{Object.keys(user_pre_key_map).length}' users", user_pre_key_map + for user_id, client_pre_keys of user_pre_key_map + cryptobox_session_map[user_id] ?= {} + for client_id, pre_key of client_pre_keys when pre_key + cryptobox_session_map[user_id][client_id] = @_session_from_prekey user_id, client_id, pre_key.key + return cryptobox_session_map + .catch (error) => + if error.type is z.user.UserError::TYPE.REQUEST_FAILURE + @logger.log @logger.levels.WARN, + "Failed to request pre-keys for user '#{user_id}'': #{error.message}", error + throw error + + ### + Create a session from a message. + @private + @param user_id [String] User ID + @param client_id [String] ID of client to initialize session for + @param message [ArrayBuffer] Serialised OTR message + @return [cryptobox.CryptoboxSession] New cryptography session + ### + _session_from_message: (user_id, client_id, message) => + session_id = @_construct_session_id user_id, client_id + cryptobox_session = @cryptobox.session_from_message session_id, message + return cryptobox_session + + ### + Create a session from a pre-key. + @private + @param user_id [String] User ID + @param client_id [String] ID of client to initialize session for + @param serialized_pre_key_bundle [String] Base 64-encoded and serialized pre-key bundle + @return [cryptobox.CryptoboxSession] New cryptography session + ### + _session_from_prekey: (user_id, client_id, encoded_pre_key_bundle) => + decoded_pre_key_bundle = sodium.from_base64 encoded_pre_key_bundle + session_id = @_construct_session_id user_id, client_id + cryptobox_session = @cryptobox.session_from_prekey session_id, decoded_pre_key_bundle.buffer + return cryptobox_session + + + ############################################################################### + # Encryption + ############################################################################### + + ### + Bundles and encrypts the generic message for all given clients. + + @param user_client_map [Object] Contains all users and their known clients + @param generic_message [z.proto.GenericMessage] Proto buffer message to be encrypted + @return [Promise] Promise that resolves with the encrypted payload + ### + encrypt_generic_message: (user_client_map, generic_message, payload) => + return Promise.resolve() + .then => + if payload + use_local_sessions = false + @logger.log @logger.levels.INFO, 'Skip local sessions when encrypting message' + else + payload = @_construct_payload @current_client().id + use_local_sessions = true + @logger.log @logger.levels.INFO, 'Encrypt message using local sessions' + return @get_sessions user_client_map, use_local_sessions + .then (cryptobox_session_map) => + return @_add_payload_recipients payload, generic_message, cryptobox_session_map + + ### + Add the encrypted message for recipients to the payload message. + + @private + @param payload [Object] Payload to add encrypted message for recipients to + @param cryptobox_sessions [Array] Sessions for all the recipients of message + @param generic_message [z.proto.GenericMessage] ProtoBuffer message to be send + @return [Object] Payload to send to backend + ### + _add_payload_recipients: (payload, generic_message, cryptobox_session_map) -> + for user_id, client_session_map of cryptobox_session_map + payload.recipients[user_id] ?= {} + for client_id, cryptobox_session of client_session_map + payload.recipients[user_id][client_id] = @_encrypt_payload_for_session cryptobox_session, generic_message + return payload + + ### + Construct the payload for an encrypted message. + + @private + @param sender [String] Client ID of message sender + @return [Object] Payload to send to backend + ### + _construct_payload: (sender) -> + return {} = + sender: sender + recipients: {} + + ### + Encrypt the generic message for a given session. + @note We created the convention that whenever we fail to encrypt for a specific client, we send a bomb emoji (no fun!) + + @private + @param cryptobox_session [cryptobox.CryptoboxSession] Cryptographic session + @param generic_message [z.proto.GenericMessage] ProtoBuffer message + @return [String] Encrypted message as BASE64 encoded string + ### + _encrypt_payload_for_session: (cryptobox_session, generic_message) -> + try + generic_message_encrypted = cryptobox_session.encrypt generic_message.toArrayBuffer() + generic_message_encrypted_base64 = z.util.array_to_base64 generic_message_encrypted + @save_session cryptobox_session + return generic_message_encrypted_base64 + catch error + ids = z.client.Client.dismantle_user_client_id cryptobox_session.id + # Note: We created the convention that whenever we fail to encrypt for a specific client, we send a bomb emoji (no fun!) + @logger.log @logger.levels.ERROR, + "Could not encrypt OTR message of type '#{generic_message.content}' for user ID '#{ids.user_id}' with client ID '#{ids.client_id}': #{error.message}", error + return '💣' + + + ############################################################################### + # Decryption + ############################################################################### + + ### + @return [cryptobox.CryptoboxSession, z.proto.GenericMessage] Cryptobox session along with the decrypted message in ProtocolBuffer format + ### + decrypt_event: (event) => + return new Promise (resolve, reject) => + if not event.data + error_message = new Error "Encrypted event with ID '#{event.id}' does not contain its data payload" + @logger.log @logger.levels.ERROR, error_message, event + reject new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.MISSING_MESSAGE + return + + if event.type is z.event.Backend.CONVERSATION.OTR_ASSET_ADD + ciphertext = event.data.key + else if event.type is z.event.Backend.CONVERSATION.OTR_MESSAGE_ADD + ciphertext = event.data.text + + primary_key = @storage_repository.construct_primary_key event.conversation, event.from, event.time + @storage_repository.load_event_for_conversation primary_key + .then (loaded_event) => + if loaded_event is undefined + resolve @_decrypt_message event, ciphertext + else + error_message = "Skipped decryption of event '#{event.type}' (#{primary_key}) because it was previously stored" + @logger.log @logger.levels.INFO, error_message + reject new z.cryptography.CryptographyError error_message, z.cryptography.CryptographyError::TYPE.PREVIOUSLY_STORED + .catch (decrypt_error) => + # Get error information + receiving_client_id = event.data.recipient + remote_client_id = event.data.sender + remote_user_id = event.from + + # Handle error + if decrypt_error instanceof Proteus.errors.DecryptError.DuplicateMessage or decrypt_error instanceof Proteus.errors.DecryptError.OutdatedMessage + # We don't need to show duplicate message errors to the user + return resolve [undefined, undefined] + + else if decrypt_error instanceof Proteus.errors.DecryptError.InvalidMessage or decrypt_error instanceof Proteus.errors.DecryptError.InvalidSignature + # Session is broken, let's see what's really causing it... + session_id = @_construct_session_id remote_user_id, remote_client_id + @logger.log @logger.levels.ERROR, + "Session '#{session_id}' broken or out of sync. Reset the session and decryption is likely to work again.\r\n" + + "Try: wire.app.repository.cryptography.reset_session('#{remote_user_id}', '#{remote_client_id}');" + + if decrypt_error instanceof Proteus.errors.DecryptError.InvalidMessage + @logger.log @logger.levels.ERROR, + "Message is for client ID '#{receiving_client_id}' and we have client ID '#{@current_client().id}'." + + else if decrypt_error instanceof Proteus.errors.DecryptError.RemoteIdentityChanged + # Remote identity changed... Is there a man in the middle or do we mess up with clients? + session = @load_session remote_user_id, remote_client_id + remote_fingerprint = session.session.remote_identity.public_key.fingerprint() + + message = "Fingerprints do not match: We expect this fingerprint '#{remote_fingerprint}' from user ID '#{remote_user_id}' with client ID '#{remote_client_id}'" + @logger.log @logger.levels.ERROR, message, session + + # Show error in JS console + @logger.log @logger.levels.ERROR, + "Decryption of '#{event.type}' (#{primary_key}) failed: #{decrypt_error.message}", {error: decrypt_error, event: event} + + # Report error to Localytics and Raygun + hashed_error_message = z.util.murmurhash3 decrypt_error.message, 42 + error_code = hashed_error_message.toString().substr 0, 4 + @_report_decrypt_error event, decrypt_error, error_code + + unable_to_decrypt_event = + conversation: event.conversation + id: z.util.create_random_uuid() + type: z.event.Client.CONVERSATION.UNABLE_TO_DECRYPT + from: remote_user_id + time: event.time + error: "#{decrypt_error.message} (#{remote_client_id})" + error_code: "#{error_code} (#{remote_client_id})" + + # Show error message in message view + amplify.publish z.event.WebApp.EVENT.INJECT, unable_to_decrypt_event + + resolve [undefined, undefined] + + ### + Save an encrypted event. + + @note IMPORTANT: + Session should only be saved after the plaintext is saved somewhere safe, as, after saving, + the session will be unable to decrypt the message again. + + @param generic_message [z.proto.GenericMessage] Received ProtoBuffer message + @param event [JSON] JSON of 'z.event.Backend.CONVERSATION.OTR-ASSET-ADD' or 'z.event.Backend.CONVERSATION.OTR-MESSAGE-ADD' event + @return [Promise] Promise that will resolve with the saved record + ### + save_encrypted_event: (generic_message, event) => + @cryptography_mapper.map_generic_message generic_message, event + .then (mapped) => + primary_key = @storage_repository.construct_primary_key event.conversation, event.from, event.time + return @storage_repository.save_decrypted_conversation_event primary_key, event, mapped + .then (primary_key) => + @logger.log @logger.levels.INFO, "Saved '#{generic_message.content}' (OTR) with primary key '#{primary_key}'" + return @storage_repository.load_event_for_conversation primary_key + .catch (error) => + @logger.log @logger.levels.ERROR, "Saving encrypted message failed: #{error.message}", error + if error instanceof z.cryptography.CryptographyError + return undefined + else + throw error + + ### + Save an unencrypted event. + @param event [Object] JSON of unencrypted backend event + @return [Promise] Promise that will resolve with the saved record + ### + save_unencrypted_event: (event) -> + @storage_repository.save_unencrypted_conversation_event event + .then (primary_key) => + @logger.log @logger.levels.INFO, "Saved unencrypted event '#{event.type}' with primary key '#{primary_key}'" + @storage_repository.load_event_for_conversation primary_key + .catch (error) => + @logger.log @logger.levels.ERROR, "Saving unencrypted message failed: #{error.message}", error + throw error + + ### + @return [z.proto.GenericMessage] Decrypted message in ProtocolBuffer format + ### + _decrypt_message: (event, ciphertext) => + user_id = event.from + client_id = event.data.sender + + session = @load_session user_id, client_id + msg_bytes = sodium.from_base64(ciphertext).buffer + + decrypted_message = undefined + + if session + decrypted_message = session.decrypt msg_bytes + else + [session, decrypted_message] = @_session_from_message user_id, client_id, msg_bytes + + generic_message = z.proto.GenericMessage.decode decrypted_message + @save_session session + return generic_message + + ### + Report decryption error to Localytics and stack traces to Raygun. + @note We currently do not want to report duplicate message errors. + ### + _report_decrypt_error: (event, decrypt_error, error_code) => + remote_client_id = event.data.sender + remote_user_id = event.from + session_id = @_construct_session_id remote_user_id, remote_client_id + + attributes = + cause: "#{error_code}: #{decrypt_error.message}" + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.E2EE.CANNOT_DECRYPT_MESSAGE, attributes + + if decrypt_error not instanceof Proteus.errors.DecryptError.DuplicateMessage and decrypt_error not instanceof Proteus.errors.DecryptError.TooDistantFuture + custom_data = + client_local_class: @current_client().class + client_local_type: @current_client().type + error_code: error_code + event_type: event.type + session_id: session_id + + raygun_error = new Error "Decryption failed: #{decrypt_error.message}" + raygun_error.stack = decrypt_error.stack + Raygun.send raygun_error, custom_data diff --git a/app/script/cryptography/CryptographyService.coffee b/app/script/cryptography/CryptographyService.coffee new file mode 100644 index 00000000000..b7a59617170 --- /dev/null +++ b/app/script/cryptography/CryptographyService.coffee @@ -0,0 +1,55 @@ +# +# 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.cryptography ?= {} + +# Cryptography service for all cryptography related calls to the backend REST API. +class z.cryptography.CryptographyService + ### + Construct a new Cryptography Service. + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client) -> + @logger = new z.util.Logger 'z.cryptography.CryptographyService', z.config.LOGGER.OPTIONS + + ### + Gets a pre-key for a client of a user + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/getPrekey + + @param user_id [String] User ID + @param client_id [String] Client ID + @return [Promise] Promise that resolves with a pre-key for the given client of the a user + ### + get_user_pre_key: (user_id, client_id) -> + @client.send_request + type: 'GET' + url: @client.create_url "/users/#{user_id}/prekeys/#{client_id}" + + ### + Gets a pre-key for each client of a user client map + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/getMultiPrekeyBundles + + @param user_client_map [Object] User client map to request pre-keys for + @return [Promise] Promise that resolves with a pre-key for each client of the given map + ### + get_users_pre_keys: (user_client_map) -> + @client.send_json + type: 'POST' + url: @client.create_url '/users/prekeys' + data: user_client_map diff --git a/app/script/entity/Connection.coffee b/app/script/entity/Connection.coffee new file mode 100644 index 00000000000..33a79c679bf --- /dev/null +++ b/app/script/entity/Connection.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.entity ?= {} + +class z.entity.Connection + constructor: -> + @conversation_id = null + @from = null + @last_update = null + @message = null + @status = ko.observable z.user.ConnectionStatus.UNKNOWN + @to = null diff --git a/app/script/entity/Conversation.coffee b/app/script/entity/Conversation.coffee new file mode 100644 index 00000000000..8c577a34a2b --- /dev/null +++ b/app/script/entity/Conversation.coffee @@ -0,0 +1,511 @@ +# +# 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.entity ?= {} + +# Conversation entity. +class z.entity.Conversation + ### + Construct a new conversation entity. + @param conversation_id [String] Conversation ID + ### + constructor: (conversation_id = '') -> + @id = conversation_id + @creator = undefined + @type = ko.observable() + @name = ko.observable() + @input = ko.observable z.storage.get_value("#{z.storage.StorageKey.CONVERSATION.INPUT}|#{@id}") or '' + @input.subscribe (text) => + z.storage.set_value "#{z.storage.StorageKey.CONVERSATION.INPUT}|#{@id}", text + + @is_pending = ko.observable false + @is_loaded = ko.observable false + + @all_user_ids = ko.observableArray [] + @participating_user_ets = ko.observableArray [] # Does not include us + @participating_user_ids = ko.observableArray [] + @self = undefined + @number_of_participants = ko.computed => return @participating_user_ids().length + + @is_group = ko.computed => @type() is z.conversation.ConversationType.REGULAR + @is_one2one = ko.computed => @type() is z.conversation.ConversationType.ONE2ONE + @is_request = ko.computed => @type() is z.conversation.ConversationType.CONNECT + @is_self = ko.computed => @type() is z.conversation.ConversationType.SELF + + # in case this is a one2one conversation this is the connection to that user + @connection = ko.observable new z.entity.Connection() + @connection.subscribe (connection_et) => @participating_user_ids [connection_et.to] + + ############################################################################### + # E2EE conversation states + ############################################################################### + + @archived_state = ko.observable false + @muted_state = ko.observable false + + @archived_timestamp = ko.observable 0 + @cleared_timestamp = ko.observable 0 + @last_event_timestamp = ko.observable 0 + @last_read_timestamp = ko.observable 0 + @muted_timestamp = ko.observable 0 + + ############################################################################### + # Conversation states for view + ############################################################################### + + @is_muted = ko.computed => + return @muted_state() + + @is_archived = ko.computed => + archived = @last_event_timestamp() <= @archived_timestamp() + if archived then return @archived_state() else return @archived_state() and @muted_state() + + @is_cleared = ko.computed => + return @last_event_timestamp() <= @cleared_timestamp() + + @is_verified = ko.computed => + return false if @participating_user_ets().length is 0 + return @participating_user_ets().every (user_et) -> user_et.is_verified() + + @removed_from_conversation = ko.observable false + @removed_from_conversation.subscribe (is_removed) => + @archived_state false if not is_removed + + ############################################################################### + # Messages + ############################################################################### + + @messages_unordered = ko.observableArray() + @messages = ko.computed => @messages_unordered().sort (a, b) -> a.timestamp - b.timestamp + @messages.subscribe => @update_latest_from_message @get_last_message() + + @creation_message = undefined + + @has_further_messages = ko.observable true + + @messages_visible = ko.pureComputed => + return [] if @id is '' + message_ets = (message_et for message_et in @messages() when message_et.visible()) + + first_message = @get_first_message() + if not @has_further_messages() and not (first_message?.is_member() and first_message.is_creation()) + @creation_message ?= @_creation_message() + message_ets.unshift @creation_message if @creation_message? + return message_ets + .extend trackArrayChanges: true + + # Call related + @call = ko.observable undefined + @has_active_call = ko.computed => + has_active_call = false + if @call()?.state() in z.calling.enum.CallStateGroups.IS_ACTIVE and not @call().is_ongoing_on_another_client() + has_active_call = true + return has_active_call + + @unread_events = ko.computed => + unread_event = [] + for message_et in @messages() when message_et.visible() by -1 + break if message_et.timestamp <= @last_read_timestamp() + unread_event.push message_et + return unread_event + + @number_of_unread_events = ko.computed => + return @unread_events().length + + @number_of_unread_messages = ko.computed => + return (message_et for message_et in @unread_events() when not message_et.user().is_me).length + + @unread_type = ko.computed => + return z.conversation.ConversationUnreadType.CONNECT if @connection().status() is z.user.ConnectionStatus.SENT + unread_type = z.conversation.ConversationUnreadType.UNREAD + return unread_type if @number_of_unread_messages() <= 0 + for message in @unread_events() by -1 + return z.conversation.ConversationUnreadType.MISSED_CALL if message.finished_reason is z.calling.enum.CallFinishedReason.MISSED + return z.conversation.ConversationUnreadType.PING if message.is_ping() + return unread_type + @unread_type.extend rateLimit: 50 + + ### + Display name strategy: + + @note 'One-to-One Conversations' and 'Connection Requests' + We should not use the conversation name received from the backend as fallback as it will always contain the + name of the user who received the connection request initially + + - Name of the other participant + - Name of the other user of the associated connection + - "..." if neither of those has been attached yet + + 'Group Conversation' + + - Conversation name received from backend + - If unnamed, we will create a name from the participant names + - Join the user's first names to a comma separated list or uses the user's first name if only one user participating + - "..." if the user entities have not yet been attached yet + ### + @display_name = ko.pureComputed + read: -> + if @type() in [z.conversation.ConversationType.CONNECT, z.conversation.ConversationType.ONE2ONE] + return @participating_user_ets()[0].name() if @participating_user_ets()[0]?.name() + return z.localization.Localizer.get_text z.string.truncation + else if @is_group() + return @name() if @name() + return (@participating_user_ets().map (user_et) -> user_et.first_name()).join ', ' if @participating_user_ets().length > 0 + return z.localization.Localizer.get_text z.string.conversation_list_empty_conversation if @participating_user_ids().length is 0 + return z.localization.Localizer.get_text z.string.truncation + else + return @name() + write: (value) -> return + owner: @ + + amplify.subscribe z.event.WebApp.CONVERSATION.LOADED_STATES, @_subscribe_to_states_updates + + _subscribe_to_states_updates: => + @archived_state.subscribe => + amplify.publish z.event.WebApp.CONVERSATION.STORE, @, z.conversation.ConversationUpdateType.ARCHIVED_STATE + @cleared_timestamp.subscribe => + amplify.publish z.event.WebApp.CONVERSATION.STORE, @, z.conversation.ConversationUpdateType.CLEARED_TIMESTAMP + @last_event_timestamp.subscribe => + amplify.publish z.event.WebApp.CONVERSATION.STORE, @, z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP + @last_read_timestamp.subscribe => + amplify.publish z.event.WebApp.CONVERSATION.STORE, @, z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP + @muted_state.subscribe => + amplify.publish z.event.WebApp.CONVERSATION.STORE, @, z.conversation.ConversationUpdateType.MUTED_STATE + + ############################################################################### + # Lifecycle + ############################################################################### + + # Remove all message from conversation unless there are unread events + release: => + if @number_of_unread_events() is 0 + @remove_messages() + @is_loaded false + @has_further_messages true + + ############################################################################### + # E2EE state setters + ############################################################################### + + ### + Set the timestamp of a given type. + + @note This will only increment timestamps + + @param timestamp [String] Timestamp to be set + @param type [z.conversation.ConversationUpdateType] Type of timestamp to be updated + @return [String] Timestamp value + ### + set_timestamp: (timestamp, type) => + switch type + when z.conversation.ConversationUpdateType.ARCHIVED_TIMESTAMP + entity_timestamp = @archived_timestamp + when z.conversation.ConversationUpdateType.CLEARED_TIMESTAMP + entity_timestamp = @cleared_timestamp + when z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP + entity_timestamp = @last_event_timestamp + when z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP + entity_timestamp = @last_read_timestamp + when z.conversation.ConversationUpdateType.MUTED_TIMESTAMP + entity_timestamp = @muted_timestamp + + updated_timestamp = @_increment_time_only entity_timestamp(), timestamp + if updated_timestamp + entity_timestamp updated_timestamp + return updated_timestamp + + ### + Increment only on timestamp update + + @param current_timestamp [z.entity.Conversation] Current timestamp + @param updated_timestamp [String] Timestamp from update + @return [String, Boolean] Updated timestamp or false if not increased + ### + _increment_time_only: (current_timestamp, updated_timestamp) -> + if updated_timestamp > current_timestamp then return updated_timestamp else return false + + ############################################################################### + # Messages + ############################################################################### + + ### + Adds a single message to the conversation. + @param message_et [z.entity.Message] Message entity to be added to the conversation + ### + add_message: (message_et) -> + @_update_last_read_from_message message_et + @messages_unordered.push @_check_for_duplicate_nonce message_et, @get_last_message() + + ### + Adds multiple messages to the conversation. + @param message_ets [z.entity.Message[]] Array of message entities to be added to the conversation + ### + add_messages: (message_ets) -> + for message_et, i in message_ets + message_et = @_check_for_duplicate_nonce message_ets[i - 1], message_et + + # in order to avoid multiple db writes check the messages from the end and stop once + # we found a message from self user + for message_et in message_ets by -1 + if message_et.user()?.is_me + @_update_last_read_from_message message_et + break + + z.util.ko_array_push_all @messages_unordered, message_ets + + ### + Prepends messages with new batch of messages. + @param message_ets [z.entity.Message[]] Array of messages to be added to conversation + ### + prepend_messages: (message_ets) -> + last_message_et = message_ets[message_ets.length - 1] + last_message_et = @_check_for_duplicate_nonce last_message_et, @get_first_message() + for message_et, i in message_ets by -1 + message_et = @_check_for_duplicate_nonce message_ets[i - 1], message_et + z.util.ko_array_unshift_all @messages_unordered, message_ets + + ### + Removes a single message from the conversation by message id. + @param message_id [String] ID of the message entity to be removed from the conversation + ### + remove_message_by_id: (message_id) -> + message_et = @get_message_by_id message_id + @messages_unordered.remove message_et if message_et? + + ### + Removes a single message from the conversation. + @param message_et [z.entity.Message] Message entity to be removed from the conversation + ### + remove_message: (message_et) -> + @messages_unordered.remove message_et + + ### + Removes all messages from the conversation. + ### + remove_messages: -> + @messages_unordered.removeAll() + + ### + Replace a message in the conversation. + + @param old_message_et [z.entity.Message] Message to be replaced + @param new_message_et [z.entity.Message] Message replacing the old one + ### + replace_message: (old_message_et, new_message_et) -> + @messages()[@messages.indexOf old_message_et] = new_message_et + @messages.valueHasMutated() + + ### + Checks for message duplicates by nonce and returns the message. + + @private + @note If a message is send to the backend multiple times by a client they will be in the conversation multiple times + + @param message_et [z.entity.Message] Message entity to be added to the conversation + @param other_message_et [z.entity.Message] Other message entity to compare with + ### + _check_for_duplicate_nonce: (message_et, other_message_et) -> + return message_et if not message_et? or not other_message_et? + if message_et.has_nonce() and other_message_et.has_nonce() and message_et.nonce is other_message_et.nonce + sorted_messages = z.entity.Message.sort_by_timestamp [message_et, other_message_et] + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.EVENT_HIDDEN_DUE_TO_DUPLICATE_NONCE + if message_et.type is z.event.Backend.CONVERSATION.ASSET_META and other_message_et.type is z.event.Backend.CONVERSATION.ASSET_META + # android sends to meta messages with the same content. we would store both and but only update the first one + # whenever we reload the conversation the nonce check would hide the older one and we would show the non updated message + # to fix that we hide the newer one + sorted_messages[1].visible false # hide newer + else + sorted_messages[0].visible false # hide older + return message_et + + + ############################################################################### + # Generated messages + ############################################################################### + + ### + Creates the placeholder message after clearing a conversation. + @note Only create the message if the group participants have been set + @private + ### + _creation_message: => + return undefined if @participating_user_ets().length is 0 + message_et = new z.entity.MemberMessage() + message_et.type = z.message.SuperType.MEMBER + message_et.user_ids @participating_user_ids() + message_et.user_ets @participating_user_ets().slice 0 + + if @type() in [z.conversation.ConversationType.CONNECT, z.conversation.ConversationType.ONE2ONE] + if @participating_user_ets()[0].sent() + message_et.member_message_type = z.message.SystemMessageType.CONNECTION_REQUEST + else + message_et.member_message_type = z.message.SystemMessageType.CONNECTION_ACCEPTED + else + message_et.member_message_type = z.message.SystemMessageType.CONVERSATION_CREATE + if @creator is @self.id + message_et.user @self + else + message_et.user_ets.push @self + user_et = ko.utils.arrayFirst @participating_user_ets(), (user_et) => + return user_et.id is @creator + if user_et + message_et.user user_et + else + message_et.member_message_type = z.message.SystemMessageType.CONVERSATION_RESUME + return message_et + + ### + Creates a E2EE message of type z.message.E2EEMessageType.ALL_VERIFIED. + @private + ### + _verified_message: -> + message_et = new z.entity.Message() + message_et.type = z.message.SuperType.ALL_VERIFIED + return message_et + + ### + Creates a E2EE message of type z.message.E2EEMessageType.ALL_VERIFIED. + @private + ### + _new_device_message: -> + message_et = new z.entity.DeviceMessage() + return message_et + + ### + Creates a E2EE message of type z.message.E2EEMessageType.ALL_VERIFIED. + @private + ### + _unverified_device_message: -> + message_et = new z.entity.DeviceMessage() + message_et.unverified true + return message_et + + + ############################################################################### + # Update last activity + ############################################################################### + + ### + Update information about last activity from multiple messages. + @param message_ets [z.entity.Message[]] Array of messages to be added to conversation + ### + update_latest_from_messages: (message_ets) -> + last_message = message_ets[message_ets.length - 1] + @update_latest_from_message last_message + + ### + Update information about last activity from single message. + @param message_et [z.entity.Message] Message to be added to conversation + ### + update_latest_from_message: (message_et) -> + if message_et? and message_et.visible() + @set_timestamp message_et.timestamp, z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP + + ### + Update last read if message sender is self + @private + @param message_et [z.entity.Message] + ### + _update_last_read_from_message: (message_et) -> + if message_et.user()?.is_me and message_et.timestamp + @set_timestamp message_et.timestamp, z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP + + ############################################################################### + # Get messages + ############################################################################### + + ### + Get all messages. + @return [z.entity.Message[ko.observableArray]] Array of all message in the conversation + ### + get_all_messages: -> + return @messages() + + ### + Returns a message with an image if found by correlation ID and image type. + + @param message_et [z.entity.Message] Message with image for which a correlating message should be found + @param message_ets [Array] Pool of message to search in first + @return [z.entity.ContentMessage] Correlating content message containing image + ### + get_correlating_image_message: (message_et, message_ets) => + image_message_et = @_get_correlating_image_message message_et, message_ets if message_ets?.length > 0 + image_message_et = @_get_correlating_image_message message_et, @messages() if not image_message_et? + return image_message_et + + ### + Get the first message of the conversation. + @return [z.entity.Message, undefined] First message entity or undefined + ### + get_first_message: -> + return @messages()[0] + + ### + Get the last message of the conversation. + @return [z.entity.Message, undefined] Last message entity or undefined + ### + get_last_message: -> + return @messages()[@messages().length - 1] + + ### + Get a message by it's unique ID. + @param id [String] ID of message to be retrieved + @return [z.entity.Message, undefined] Message with ID or undefined + ### + get_message_by_id: (id) -> + for message_et in @messages() + return message_et if message_et.id is id + + ### + Returns a message with an image if found by correlation ID and image type. + + @private + @param message_et [z.entity.Message] Message with image for which a correlating message should be found + @param message_ets [Array] Pool of message to search in + @return [z.entity.ContentMessage] Correlating content message containing image + ### + _get_correlating_image_message: (input_message_et, message_ets) -> + input_asset_et = input_message_et.get_first_asset() + for other_message_et in message_ets when other_message_et.has_asset_image() + continue if other_message_et.id is input_message_et.id + other_asset_et = other_message_et.get_first_asset() + if other_asset_et.correlation_id is input_asset_et.correlation_id + return other_message_et if input_asset_et.is_medium_image() is other_asset_et.is_preview_image() + + ### + Get Number of pending uploads for this conversation. + ### + get_number_of_pending_uploads: -> + pending_uploads = (message_et for message_et in @messages() when message_et.assets?()[0]?.pending_upload?()) + return pending_uploads.length + + ############################################################################### + # Serialization + ############################################################################### + + serialize: => + return {} = + id: @id + archived_state: @archived_state() + archived_timestamp: @archived_timestamp() + cleared_timestamp: @cleared_timestamp() + last_event_timestamp: @last_event_timestamp() + last_read_timestamp: @last_read_timestamp() + muted_state: @muted_state() + muted_timestamp: @muted_timestamp() diff --git a/app/script/entity/User.coffee b/app/script/entity/User.coffee new file mode 100644 index 00000000000..b359180ebb0 --- /dev/null +++ b/app/script/entity/User.coffee @@ -0,0 +1,140 @@ +# +# 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.entity ?= {} + +# User entity. +# Please note: The own user has a "tracking_id" & "locale" +class z.entity.User + + + THEME: + BLUE: 'theme-blue' + GREEN: 'theme-green' + ORANGE: 'theme-orange' + PINK: 'theme-pink' + PURPLE: 'theme-purple' + RED: 'theme-red' + YELLOW: 'theme-yellow' + + # TODO remove (don't hardcode color values), set and handle this in CSS classes + ACCENT_COLOR: + BLUE: '#2391d3' + GREEN: '#00c800' + ORANGE: '#ff8900' + PINK: '#fe5ebd' + PURPLE: '#9c00fe' + RED: '#fb0807' + YELLOW: '#febf02' + + ### + Construct a new user entity. + + @param user_id [String] User ID + ### + constructor: (user_id = '') -> + @id = user_id + @is_me = false + + @joaat_hash = -1 + + @accent_id = ko.observable z.config.ACCENT_ID.BLUE + @accent_theme = ko.computed => + switch @accent_id() + when z.config.ACCENT_ID.BLUE then return @THEME.BLUE + when z.config.ACCENT_ID.GREEN then return @THEME.GREEN + when z.config.ACCENT_ID.ORANGE then return @THEME.ORANGE + when z.config.ACCENT_ID.PINK then return @THEME.PINK + when z.config.ACCENT_ID.PURPLE then return @THEME.PURPLE + when z.config.ACCENT_ID.RED then return @THEME.RED + when z.config.ACCENT_ID.YELLOW then return @THEME.YELLOW + else return @THEME.BLUE + , @, deferEvaluation: true + + @accent_color = ko.computed => + switch @accent_id() + when z.config.ACCENT_ID.BLUE then return @ACCENT_COLOR.BLUE + when z.config.ACCENT_ID.GREEN then return @ACCENT_COLOR.GREEN + when z.config.ACCENT_ID.ORANGE then return @ACCENT_COLOR.ORANGE + when z.config.ACCENT_ID.PINK then return @ACCENT_COLOR.PINK + when z.config.ACCENT_ID.PURPLE then return @ACCENT_COLOR.PURPLE + when z.config.ACCENT_ID.RED then return @ACCENT_COLOR.RED + when z.config.ACCENT_ID.YELLOW then return @ACCENT_COLOR.YELLOW + else return @ACCENT_COLOR.BLUE + , @, deferEvaluation: true + + @phone = ko.observable() + @display_phone = ko.computed => + formatted_number = PhoneFormat.formatNumberForMobileDialing '', @phone() + return formatted_number or @phone() + , deferEvaluation: true + + @email = ko.observable() + + @name = ko.observable '' + @first_name = ko.computed => + return @name().split(' ')[0] + @last_name = ko.computed => + parts = @name().split(' ') + return parts.pop() if parts.length > 1 + @initials = ko.computed => + initials = '' + if @first_name()? and @last_name()? + initials = z.util.get_first_character(@first_name()) + z.util.get_first_character(@last_name()) + else + initials = @first_name().slice 0, 2 + return initials.toUpperCase() + + @mutual_friend_ets = ko.observableArray [] + @mutual_friend_ids = ko.observableArray [] + @mutual_friends_total = ko.observable 0 + + @picture_preview = ko.observable '' + @picture_medium = ko.observable '' + @raw_pictures = ko.observable [] + + # TODO: Quickfix for picture previews. Tiago thinks about a better solution! + # TODO: Make sure that this function returns only an URL (without url('...')) + @picture_preview_url = => + return @picture_preview() if @picture_preview().length is 0 + url = @picture_preview().substr(0, @picture_preview().indexOf('?')) + return "#{url}?access_token=#{wire.auth.client.access_token}&conv_id=#{@id}')" + + @picture_medium_url = => + return @picture_medium() if @picture_medium().length is 0 + url = @picture_medium().substr(0, @picture_medium().indexOf('?')) + return "#{url}?access_token=#{wire.auth.client.access_token}&conv_id=#{@id}')" + + @connection_level = ko.observable z.user.ConnectionLevel.UNKNOWN + @connection = ko.observable new z.entity.Connection() + + # connection state shorthands TODO add others too since this is used very often? + @blocked = ko.computed => @connection().status() is z.user.ConnectionStatus.BLOCKED + @connected = ko.computed => @connection().status() is z.user.ConnectionStatus.ACCEPTED + @sent = ko.computed => @connection().status() is z.user.ConnectionStatus.SENT + + # e2ee + @devices = ko.observableArray() + @is_verified = ko.computed => + return false if @devices().length is 0 + return @devices().every (client_et) -> client_et.meta.is_verified() + + add_client: (client_et) -> + is_existent = ko.utils.arrayFirst @devices(), (exisiting_client) -> exisiting_client.id is client_et.id + @devices.push client_et if not is_existent diff --git a/app/script/entity/message/Asset.coffee b/app/script/entity/message/Asset.coffee new file mode 100644 index 00000000000..968c124be02 --- /dev/null +++ b/app/script/entity/message/Asset.coffee @@ -0,0 +1,121 @@ +# +# 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.entity ?= {} + +# Asset entity. +class z.entity.Asset + + ### + Construct a new asset. + + @param id [String] Asset ID + ### + constructor: (@id) -> + @key = '' + @type = '' + + ### + Check if asset is a medium image. + + @return [Boolean] Is asset of type medium image + ### + is_medium_image: -> + return @type is z.assets.AssetType.MEDIUM_IMAGE + + ### + Check if asset is a preview image. + + @return [Boolean] Is asset of type preview image + ### + is_preview_image: -> + return @type is z.assets.AssetType.PREVIEW_IMAGE + + ### + Check if asset is a text. + + @return [Boolean] Is asset of type text + ### + is_text: -> + return @type is z.assets.AssetType.TEXT + + ### + Check if asset is a file. + + @return [Boolean] Is asset of type file + ### + is_file: -> + return @type is z.assets.AssetType.FILE and not @is_video() and not @is_audio() + + ### + Check if asset is a location. + + @return [Boolean] Is asset of type location + ### + is_location: -> + return @type is z.assets.AssetType.LOCATION + + ### + Check if asset is a video. + + @return [Boolean] Is asset of type video + ### + is_video: -> + if @type is z.assets.AssetType.FILE and @file_type?.startsWith('video') and not z.util.Environment.browser.firefox + can_play_audio = document.createElement('video').canPlayType @file_type + return true if can_play_audio isnt '' + return false + ### + Check if asset is a audio. + + @return [Boolean] Is asset of type audio + ### + is_audio: -> + if @type is z.assets.AssetType.FILE and @file_type?.startsWith 'audio' + can_play_audio = document.createElement('audio').canPlayType @file_type + return true if can_play_audio isnt '' + return false + + ### + Replace access token in image url. + + @param url [String] asset url + @param access_token [String] + @return url [String] + ### + _replace_access_token: (url, access_token) -> + token = url.match /access_token=([^&\s]*)/i + return url.replace token[1], access_token + + ### + Get current asset url. + + @param url [String] asset url + @return url [String] + ### + generate_asset_url: (url) => + @_replace_access_token url, wire.auth.client.access_token + + ### + Process asset before rendering it + + @todo Implement + ### + render: -> + return 'Not implemented' diff --git a/app/script/entity/message/CallMessage.coffee b/app/script/entity/message/CallMessage.coffee new file mode 100644 index 00000000000..0d090fb0172 --- /dev/null +++ b/app/script/entity/message/CallMessage.coffee @@ -0,0 +1,50 @@ +# +# 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.entity ?= {} + +# Call message entity based on z.entity.Message. +class z.entity.CallMessage extends z.entity.Message + # Construct a new content message. + constructor: -> + super() + @super_type = z.message.SuperType.CALL + @call_message_type = '' + @finished_reason = '' + + @caption = ko.computed => + return z.localization.Localizer.get_text z.string.conversation_voice_channel_deactivate_you if @user().is_me + return z.localization.Localizer.get_text z.string.conversation_voice_channel_deactivate + , @, deferEvaluation: true + + ### + Check if call message is call activation. + + @return [Boolean] Is message of type activate + ### + is_call_activation: -> + return @call_message_type is z.message.CallMessageType.ACTIVATED + + ### + Check if call message is call activation. + + @return [Boolean] Is message of type deactivate + ### + is_call_deactivation: -> + return @call_message_type is z.message.CallMessageType.DEACTIVATED diff --git a/app/script/entity/message/ContentMessage.coffee b/app/script/entity/message/ContentMessage.coffee new file mode 100644 index 00000000000..0984c4f0560 --- /dev/null +++ b/app/script/entity/message/ContentMessage.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.entity ?= {} + +# Content Message based on z.entity.Message. +class z.entity.ContentMessage extends z.entity.Message + # Construct a new content message. + constructor: -> + super() + + @assets = ko.observableArray [] + @nonce = null + @super_type = z.message.SuperType.CONTENT + + + ### + Add another content asset to the message. + + @param asset_et [z.entity.Asset] New content asset + ### + add_asset: (asset_et) => + @assets.push asset_et + + ### + Get the first asset attached to the message. + + @return [z.entity.Message] The first asset attached to the message + ### + get_first_asset: -> + return @assets()[0] + + ### + Gets the first asset attached to the message of a specified asset type. + + @param asset_type [z.assets.AssetType] Type the asset should be of + + @return [z.entity.Asset] First matching asset + ### + get_first_asset_of_type: (asset_type) -> + return asset_et for asset_et in @assets() when asset_et.type is asset_type + + ### + Gets the first asset attached to the message of a specified image type. + + @param image_type [z.assets.ImageType] Type the image asset should be of + + @return [z.entity.Asset] First matching asset + ### + get_first_image_of_type: (image_type) -> + if image_type is z.assets.ImageSizeType.MEDIUM + return asset_et for asset_et in @assets() when asset_et.type is z.asset.AssetType.MEDIUM_IMAGE + else if image_type is z.assets.ImageSizeType.PREVIEW + return asset_et for asset_et in @assets() when asset_et.type is z.asset.AssetType.PREVIEW_IMAGE + + ### + Check if message can be deleted. + + @return [Boolean] + ### + is_deletable: -> + asset = @assets()[0] + return true if not asset.is_file() + return asset.status() not in [z.assets.AssetTransferState.DOWNLOADING, z.assets.AssetTransferState.UPLOADING] diff --git a/app/script/entity/message/DecryptErrorMessage.coffee b/app/script/entity/message/DecryptErrorMessage.coffee new file mode 100644 index 00000000000..26b6ebdd6a6 --- /dev/null +++ b/app/script/entity/message/DecryptErrorMessage.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.entity ?= {} + +class z.entity.DecryptErrorMessage extends z.entity.Message + @::RECOVERABLE_STATES = [ + z.cryptography.CryptographyErrorType.DUPLICATE_MESSAGE + z.cryptography.CryptographyErrorType.INVALID_MESSAGE_SESSION_NOT_MATCHING + z.cryptography.CryptographyErrorType.INVALID_MESSAGE_SESSION_MISSING + z.cryptography.CryptographyErrorType.INVALID_SIGNATURE + z.cryptography.CryptographyErrorType.OUTDATED_MESSAGE + z.cryptography.CryptographyErrorType.PRE_KEY_NOT_FOUND + z.cryptography.CryptographyErrorType.TOO_DISTANT_FUTURE + ] + + constructor: -> + super() + @super_type = z.message.SuperType.UNABLE_TO_DECRYPT + + @error_code = '' + @client_id = '' + + @caption = ko.pureComputed => + caption_id = z.string.conversation_unable_to_decrypt_1 + caption_id = z.string.conversation_unable_to_decrypt_2 if @error_code is z.cryptography.CryptographyErrorType.REMOTE_IDENTITY_CHANGED + return z.localization.Localizer.get_text { + id: caption_id + replace: { + placeholder: '%@name', content: "#{ z.util.escape_html @user().first_name()}" + } + } + + @link = ko.pureComputed => + return z.localization.Localizer.get_text z.string.url_decrypt_error_2 if @error_code is '3690' + return z.localization.Localizer.get_text z.string.url_decrypt_error_1 + + @is_recoverable = ko.pureComputed => + return @error_code in @RECOVERABLE_STATES + + @is_resetting_session = ko.observable false + + @error_message = ko.pureComputed => + parts = [] + + if @error_code + error_text = z.localization.Localizer.get_text z.string.conversation_unable_to_decrypt_error_message + parts.push "#{error_text}: #{@error_code}" + + if @client_id + parts.push "ID: #{z.util.print_devices_id(@client_id)}" + + return "(#{parts.join(' ')})" if parts.length diff --git a/app/script/entity/message/File.coffee b/app/script/entity/message/File.coffee new file mode 100644 index 00000000000..2f631249714 --- /dev/null +++ b/app/script/entity/message/File.coffee @@ -0,0 +1,128 @@ +# +# 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.entity ?= {} + +# Medium image asset entity. +class z.entity.File extends z.entity.Asset + ### + Construct a new medium image asset. + + @param id [String] Asset ID + ### + constructor: (id) -> + super id + @type = z.assets.AssetType.FILE + @logger = new z.util.Logger 'z.entity.File', z.config.LOGGER.OPTIONS + + # z.assets.AssetTransferState + @status = ko.observable() + + @file_name = '' + @file_size = '' + @file_type = '' + + # contains asset meta data as object + @meta = {} + + # asset url, instance of an otr asset this has to be decrypted + @original_resource = ko.observable() + @preview_resource = ko.observable() + + @download_progress = ko.pureComputed => @original_resource()?.download_progress() + @cancel_download = => @original_resource()?.cancel_download() + + @upload_id = ko.observable() + @upload_progress = ko.observable() + @uploaded_on_this_client = ko.observable false + @upload_failed_reason = ko.observable() + @pending_upload = ko.pureComputed => + return @status() is z.assets.AssetTransferState.UPLOADING and @uploaded_on_this_client() + + # update progress + @upload_id.subscribe (id) => + amplify.subscribe 'upload' + id, @on_progress if id + + @status.subscribe (status) => + if status is z.assets.AssetTransferState.UPLOADED + amplify.unsubscribe 'upload' + @upload_id, @on_progress + + on_progress: (progress) => + @upload_progress progress + + ### + Loads and decrypts otr asset preview + + @return [Promise] Returns a promise that resolves with the asset as blob + ### + load_preview: => + @preview_resource()?.load() + + ### + Loads and decrypts otr asset + + @return [Promise] Returns a promise that resolves with the asset as blob + ### + load: => + @status z.assets.AssetTransferState.DOWNLOADING + @original_resource()?.load() + .then (blob) => + @status z.assets.AssetTransferState.UPLOADED + return blob + .catch (error) => + @status z.assets.AssetTransferState.UPLOADED + throw error + + ### + Loads and decrypts otr asset as initiates download + + @return [Promise] Returns a promise that resolves with the asset as blob + ### + download: => + return if @status() isnt z.assets.AssetTransferState.UPLOADED + + download_started = Date.now() + tracking_data = + size_bytes: @file_size + size_mb: z.util.bucket_values (@file_size / 1024 / 1024), [0, 5, 10, 15, 20, 25] + type: z.util.get_file_extension @file_name + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.DOWNLOAD_INITIATED, tracking_data + + @load() + .then (blob) => + return z.util.download_blob blob, @file_name + .then => + download_duration = (Date.now() - download_started) / 1000 + @logger.log "Downloaded asset in #{download_duration} seconds" + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.DOWNLOAD_SUCCESSFUL, + $.extend tracking_data, {time: download_duration} + .catch (error) => + @logger.log @logger.levels.ERROR, 'Failed to download asset', error + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.DOWNLOAD_FAILED, tracking_data + + cancel: (message_et) => + amplify.publish z.event.WebApp.CONVERSATION.ASSET.CANCEL, message_et + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.UPLOAD_CANCELLED, + size_bytes: @file_size + size_mb: z.util.bucket_values (@file_size / 1024 / 1024), [0, 5, 10, 15, 20, 25] + type: z.util.get_file_extension @file_name + + reload: => + @logger.log 'Restart upload' diff --git a/app/script/entity/message/LinkPreview.coffee b/app/script/entity/message/LinkPreview.coffee new file mode 100644 index 00000000000..6f1903be310 --- /dev/null +++ b/app/script/entity/message/LinkPreview.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.entity ?= {} + +class z.entity.LinkPreview + constructor: -> + + @title = '' + @summary = '' + @orginal_url = '' + @permanent_url = '' + @url_offset = 0 + + # z.assets.AssetRemoteData + @image_resource = ko.observable() diff --git a/app/script/entity/message/Location.coffee b/app/script/entity/message/Location.coffee new file mode 100644 index 00000000000..dfa82ab0e20 --- /dev/null +++ b/app/script/entity/message/Location.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.entity ?= {} + +class z.entity.Location extends z.entity.Asset + constructor: -> + super() + @type = z.assets.AssetType.LOCATION + + @latitude = '' + @longitude = '' + @name = '' + @zoom = '' + + @link_src = ko.pureComputed => + z.location.get_maps_url @latitude, @longitude, @name, @zoom diff --git a/app/script/entity/message/MediumImage.coffee b/app/script/entity/message/MediumImage.coffee new file mode 100644 index 00000000000..f3c6bca39d9 --- /dev/null +++ b/app/script/entity/message/MediumImage.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.entity ?= {} + +# Medium image asset entity. +class z.entity.MediumImage extends z.entity.Asset + ### + Construct a new medium image asset. + + @param id [String] Asset ID + ### + constructor: (id) -> + super id + @type = z.assets.AssetType.MEDIUM_IMAGE + + @correlation_id = '' + + @width = '0px' + @height = '0px' + + # z.assets.AssetRemoteData + @resource = ko.observable() diff --git a/app/script/entity/message/MemberMessage.coffee b/app/script/entity/message/MemberMessage.coffee new file mode 100644 index 00000000000..f686166873d --- /dev/null +++ b/app/script/entity/message/MemberMessage.coffee @@ -0,0 +1,111 @@ +# +# 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.entity ?= {} + +### +Member message entity based on z.entity.SystemMessage. + +@todo Refactor for a proper SystemMessage entities +### +class z.entity.MemberMessage extends z.entity.SystemMessage + # Construct a new member message. + constructor: -> + super() + @super_type = z.message.SuperType.MEMBER + @member_message_type = z.message.SystemMessageType.NORMAL + + @user_ets = ko.observableArray() + @user_ids = ko.observableArray() + + # Users joined the conversation without sender + @joined_user_ets = ko.pureComputed => + return (user_et for user_et in @user_ets() when user_et.id isnt @user().id) + + # Users joined the conversation without self + @remote_user_ets = ko.pureComputed => + return (user_et for user_et in @user_ets() when not user_et.is_me) + + @_generate_name_string = (declension = z.string.Declension.ACCUSATIVE) => + names_string = (z.util.get_first_name user_et, declension for user_et in @joined_user_ets()).join ', ' + return names_string.replace /,(?=[^,]*$)/, " #{z.localization.Localizer.get_text z.string.and}" + + @_get_caption_connection = (connection_status) -> + switch connection_status + when z.user.ConnectionStatus.BLOCKED + identifier = z.string.conversation_connection_blocked + when z.user.ConnectionStatus.SENT then return '' + else + identifier = z.string.conversation_connection_accepted + return z.localization.Localizer.get_text identifier + + @_get_caption_with_names = (key, declension) => + return z.localization.Localizer.get_text { + id: key + replace: {placeholder: '%@names', content: @_generate_name_string declension} + } + + @show_large_avatar = => + large_avatar_types = [ + z.message.SystemMessageType.CONNECTION_ACCEPTED + z.message.SystemMessageType.CONNECTION_REQUEST + ] + return @member_message_type in large_avatar_types + + @other_user = ko.computed => + if @user_ets().length is 1 then @user_ets()[0] else new z.entity.User() + + @caption = ko.computed => + return '' if @user_ets().length is 0 + + switch @member_message_type + when z.message.SystemMessageType.CONNECTION_ACCEPTED, z.message.SystemMessageType.CONNECTION_REQUEST + return @_get_caption_connection @other_user().connection().status() + when z.message.SystemMessageType.CONVERSATION_CREATE + return @_get_caption_with_names z.string.conversation_create_you if @user().is_me + return @_get_caption_with_names z.string.conversation_create, z.string.Declension.DATIVE + when z.message.SystemMessageType.CONVERSATION_RESUME + return @_get_caption_with_names z.string.conversation_resume, z.string.Declension.DATIVE + + switch @type + when z.event.Backend.CONVERSATION.MEMBER_LEAVE + if @other_user().id is @user().id + return z.localization.Localizer.get_text z.string.conversation_member_leave_left_you if @user().is_me + return z.localization.Localizer.get_text z.string.conversation_member_leave_left + return @_get_caption_with_names z.string.conversation_member_leave_removed_you if @user().is_me + return @_get_caption_with_names z.string.conversation_member_leave_removed + when z.event.Backend.CONVERSATION.MEMBER_JOIN + return @_get_caption_with_names z.string.conversation_member_join_you if @user().is_me + return @_get_caption_with_names z.string.conversation_member_join + + , @, deferEvaluation: true + + is_connection: => + return @member_message_type in [ + z.message.SystemMessageType.CONNECTION_ACCEPTED + z.message.SystemMessageType.CONNECTION_REQUEST + ] + + is_creation: => + return @member_message_type in [ + z.message.SystemMessageType.CONNECTION_ACCEPTED + z.message.SystemMessageType.CONNECTION_REQUEST + z.message.SystemMessageType.CONVERSATION_CREATE + z.message.SystemMessageType.CONVERSATION_RESUME + ] diff --git a/app/script/entity/message/Message.coffee b/app/script/entity/message/Message.coffee new file mode 100644 index 00000000000..bfc79e32a84 --- /dev/null +++ b/app/script/entity/message/Message.coffee @@ -0,0 +1,209 @@ +# +# 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.entity ?= {} + +# Base message entity. +class z.entity.Message + # Construct a new base message entity. + constructor: -> + @from = '' + @id = '0' + @primary_key = undefined + @super_type = '' + @timestamp = Date.now() + @type = '' + @user = ko.observable new z.entity.User() + @visible = ko.observable true + + @display_timestamp_short = => + date = moment.unix @timestamp / 1000 + return date.local().format 'HH:mm' + + @sender_name = ko.computed => + z.util.get_first_name @user() + , @, deferEvaluation: true + + @accent_color = ko.computed => + return "accent-color-#{@user().accent_id()}" + , @, deferEvaluation: true + + ### + Check if message contains a preview image asset. + + @return [Boolean] Message contains a preview image + ### + has_asset: -> + if @is_content() + return true for asset_et in @assets() when asset_et.type is z.assets.AssetType.FILE + return false + + ### + Check if message contains any image asset. + + @return [Boolean] Message contains any image + ### + has_asset_image: -> + if @is_content() + return true for asset_et in @assets() when asset_et.is_medium_image() or asset_et.is_preview_image() + return false + + ### + Check if message contains a medium image asset. + + @return [Boolean] Message contains a medium image + ### + has_asset_medium_image: -> + if @is_content() + return true for asset_et in @assets() when asset_et.is_medium_image() + return false + + ### + Check if message contains a preview image asset. + + @return [Boolean] Message contains a preview image + ### + has_asset_preview_image: -> + if @is_content() + return true for asset_et in @assets() when asset_et.is_preview_image() + return false + + ### + Check if message contains a preview image asset. + + @return [Boolean] Message contains a preview image + ### + has_asset_file: -> + if @is_content() + return true for asset_et in @assets() when asset_et.is_file() + return false + + ### + Check if message contains a text asset. + + @return [Boolean] Message contains text + ### + has_asset_text: -> + if @is_content() + return true for asset_et in @assets() when asset_et.is_text() + return false + + ### + Check if message contains a nonce. + + @return [Boolean] Message contains a nonce + ### + has_nonce: -> + return @super_type in [z.message.SuperType.CONTENT] + + ### + Check if message is a call message. + + @return [Boolean] Is message of type call + ### + is_call: -> + return @super_type is z.message.SuperType.CALL + + ### + Check if message is a content message. + + @return [Boolean] Is message of type content + ### + is_content: -> + return @super_type is z.message.SuperType.CONTENT + + ### + Check if message is a member message. + + @return [Boolean] Is message of type member + ### + is_member: -> + return @super_type is z.message.SuperType.MEMBER + + ### + Check if message is a ping message. + + @return [Boolean] Is message of type ping + ### + is_ping: -> + return @super_type is z.message.SuperType.PING + + ### + Check if message is a system message. + + @return [Boolean] Is message of type system + ### + is_system: -> + return @super_type is z.message.SuperType.SYSTEM + + ### + Check if message is a e2ee message. + + @return [Boolean] Is message of type system + ### + is_device: -> + return @super_type is z.message.SuperType.DEVICE + + ### + Check if message is a e2ee message. + + @return [Boolean] Is message of type system + ### + is_all_verified: -> + return @super_type is z.message.SuperType.ALL_VERIFIED + + ### + Check if message is a e2ee message. + + @return [Boolean] Is message of type system + ### + is_unable_to_decrypt: -> + return @super_type is z.message.SuperType.UNABLE_TO_DECRYPT + + ### + Triggers event to delete message. + ### + delete: => + active_conversation = wire.app.repository.conversation.active_conversation() + type = 'text' + + if @is_system() + type = 'system' + else if @is_ping() + type = 'ping' + else if @has_asset_image() + type = 'image' + else if @has_asset() + type = 'file' + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.SELECTED_MESSAGE, + context: 'single' + conversation_type: if active_conversation.is_one2one() then 'one_to_one' else 'group' + type: type + + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.DELETE_MESSAGE, + action: => amplify.publish z.event.WebApp.CONVERSATION.MESSAGE.DELETE, @ + + ### + Sort messages by timestamp + + @return [Boolean] Is message of type system + ### + @sort_by_timestamp: (message_ets) -> + message_ets.sort (m1, m2) -> m1.timestamp > m2.timestamp diff --git a/app/script/entity/message/NewDeviceMessage.coffee b/app/script/entity/message/NewDeviceMessage.coffee new file mode 100644 index 00000000000..e6f5670135b --- /dev/null +++ b/app/script/entity/message/NewDeviceMessage.coffee @@ -0,0 +1,61 @@ +# +# 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.entity ?= {} + +# E2EE new device message entity based on z.entity.Message. +class z.entity.NewDeviceMessage extends z.entity.Message + # Construct a new content message. + constructor: -> + super() + @type = z.message.SuperType.NEW_DEVICE + @device = ko.observable() + @device_owner = ko.observable new z.entity.User() + + @unverified = ko.observable false + + # TODO + # You started using this device -> settings + # You started using a new device -> settings + # John started using a new device -> profile + # You unverified one of John's devices -> profile + # You unverified one of your devices -> settings + + @caption = ko.computed => + return z.localization.Localizer.get_text z.string.conversation_device_unverified if @unverified() + return z.localization.Localizer.get_text z.string.conversation_device_started_using_you if @device_owner().is_me + return z.localization.Localizer.get_text z.string.conversation_device_started_using + + @caption_device = ko.computed => + if @unverified() + return z.localization.Localizer.get_text z.string.conversation_device_your_devices if @device_owner().is_me + return z.localization.Localizer.get_text { + id: z.string.conversation_device_user_devices + replace: {placeholder: '%@name', content: @device_owner().first_name()} + } + else + # TODO current device + return z.localization.Localizer.get_text z.string.conversation_device_a_new_device + + click_on_device: => + # TODO device + if @device_owner()?.is_me + amplify.publish z.event.WebApp.PROFILE.SETTINGS.SHOW + else + amplify.subscribe z.event.WebApp.SHORTCUT.PEOPLE diff --git a/app/script/entity/message/PingMessage.coffee b/app/script/entity/message/PingMessage.coffee new file mode 100644 index 00000000000..1665edf1bc5 --- /dev/null +++ b/app/script/entity/message/PingMessage.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.entity ?= {} + +class z.entity.PingMessage extends z.entity.Message + constructor: -> + super() + @super_type = z.message.SuperType.PING + @animated = ko.observable false + + @caption = ko.computed => + string = if @user().is_me then z.string.conversation_ping_you else z.string.conversation_ping + return z.localization.Localizer.get_text string + , @, deferEvaluation: true + + @animation = ko.computed -> + return 'ping-animation ping-animation-soft' + , @, deferEvaluation: true diff --git a/app/script/entity/message/PreviewImage.coffee b/app/script/entity/message/PreviewImage.coffee new file mode 100644 index 00000000000..133ee7b4f35 --- /dev/null +++ b/app/script/entity/message/PreviewImage.coffee @@ -0,0 +1,54 @@ +# +# 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.entity ?= {} + +# Preview image asset entity. +class z.entity.PreviewImage extends z.entity.Asset + ### + Construct a new medium image asset. + + @param id [String] Asset ID + ### + constructor: (id) -> + super id + @type = z.assets.AssetType.PREVIEW_IMAGE + + @correlation_id = '' + + @content_type = '' + @encoded_data = '' + + @width = '0px' + @height = '0px' + + # Process preview image before rendering it. + render: -> + src = "data:#{@content_type};base64,#{@encoded_data}" + url = "url(#{src})" + + config = + id: "preview-image-#{@correlation_id}" + width: window.parseInt @width, 10 + height: window.parseInt @height, 10 + preview_url: src + + image = new zeta.webapp.module.image.animation.ImageBuilder().build config + zeta.webapp.module.image.animation.onresize() + return image.clone().wrap('

    ').parent().html() diff --git a/app/script/entity/message/RenameMessage.coffee b/app/script/entity/message/RenameMessage.coffee new file mode 100644 index 00000000000..68e21875a7a --- /dev/null +++ b/app/script/entity/message/RenameMessage.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.entity ?= {} + +# Rename message entity based on z.entity.SystemMessage. +class z.entity.RenameMessage extends z.entity.SystemMessage + # Construct a new system message. + constructor: -> + super() + @system_message_type = z.message.SystemMessageType.CONVERSATION_RENAME + @caption = ko.computed => + return z.localization.Localizer.get_text z.string.conversation_rename_you if @user().is_me + return z.localization.Localizer.get_text z.string.conversation_rename diff --git a/app/script/entity/message/SystemMessage.coffee b/app/script/entity/message/SystemMessage.coffee new file mode 100644 index 00000000000..ddb8d1f2409 --- /dev/null +++ b/app/script/entity/message/SystemMessage.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.entity ?= {} + +# System message entity based on z.entity.Message. +class z.entity.SystemMessage extends z.entity.Message + # Construct a new system message. + constructor: -> + super() + @super_type = z.message.SuperType.SYSTEM diff --git a/app/script/entity/message/Text.coffee b/app/script/entity/message/Text.coffee new file mode 100644 index 00000000000..edf367a7d19 --- /dev/null +++ b/app/script/entity/message/Text.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.entity ?= {} + +# Text asset entity. +class z.entity.Text extends z.entity.Asset + ### + Construct a new text asset. + + @param id [String] Asset ID + ### + constructor: (id) -> + super(id) + @type = z.assets.AssetType.TEXT + + # Raw message text + @text = '' + + # Can be used to theme media embeds + @theme_color = undefined + + # Processed text including media embeds, link highlight and markdown + @processed_text = undefined + + # Array of z.entity.LinkPreview instances + @previews = ko.observableArray() + + @should_render_text = ko.pureComputed => + has_link_previews = @previews().length > 0 + return not has_link_previews or (has_link_previews and not z.links.LinkPreviewHelpers.contains_only_link(@text)) + + # Process text before rendering it. + render: -> + @processed_text ?= z.util.render_message @text, @theme_color + return @processed_text diff --git a/app/script/event/Backend.coffee b/app/script/event/Backend.coffee new file mode 100644 index 00000000000..4050ef86b8e --- /dev/null +++ b/app/script/event/Backend.coffee @@ -0,0 +1,63 @@ +# +# 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.event ?= {} + +### +Enum of different backend events. +### +z.event.Backend = + CALL: + STATE: 'call.state' + FLOW_ADD: 'call.flow-add' + REMOTE_SDP: 'call.remote-sdp' + REMOTE_CANDIDATES_ADD: 'call.remote-candidates-add' + REMOTE_CANDIDATES_UPDATE: 'call.remote-candidates-update' + FLOW_ACTIVE: 'call.flow-active' + FLOW_DELETE: 'call.flow-delete' + CONVERSATION: + ASSET_ADD: 'conversation.asset-add' + ASSET_META: 'conversation.asset-meta' + ASSET_PREVIEW: 'conversation.asset-preview' + ASSET_UPLOAD_COMPLETE: 'conversation.asset-upload-complete' + ASSET_UPLOAD_FAILED: 'conversation.asset-upload-failed' + CLIENT_MESSAGE_ADD: 'conversation.client-message-add' + CONNECT_REQUEST: 'conversation.connect-request' + CREATE: 'conversation.create' + KNOCK: 'conversation.knock' + LOCATION: 'conversation.location' + MEMBER_JOIN: 'conversation.member-join' + MEMBER_LEAVE: 'conversation.member-leave' + MEMBER_UPDATE: 'conversation.member-update' + MESSAGE_ADD: 'conversation.message-add' + MESSAGE_DELETE: 'conversation.message-delete' + OTR_ASSET_ADD: 'conversation.otr-asset-add' + OTR_MESSAGE_ADD: 'conversation.otr-message-add' + RENAME: 'conversation.rename' + TYPING: 'conversation.typing' + VOICE_CHANNEL_ACTIVATE: 'conversation.voice-channel-activate' + VOICE_CHANNEL_DEACTIVATE: 'conversation.voice-channel-deactivate' + USER: + ACTIVATE: 'user.activate' + CONNECTION: 'user.connection' + UPDATE: 'user.update' + DELETE: 'user.delete' + CLIENT: + ADD: 'user.client-add' + REMOVE: 'user.client-remove' diff --git a/app/script/event/Client.coffee b/app/script/event/Client.coffee new file mode 100644 index 00000000000..ade047ff566 --- /dev/null +++ b/app/script/event/Client.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.event ?= {} + +z.event.Client = + CONVERSATION: + UNABLE_TO_DECRYPT: 'conversation.unable-to-decrypt' diff --git a/app/script/event/EventError.coffee b/app/script/event/EventError.coffee new file mode 100644 index 00000000000..1bccbf77af9 --- /dev/null +++ b/app/script/event/EventError.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.event ?= {} + +class z.event.EventError + constructor: (message, type) -> + @name = @constructor.name + @message = message + @stack = (new Error()).stack + @type = type + + @:: = new Error() + @::constructor = @ + @::TYPE = + DATABASE_FAILURE: 'z.event.EventError::TYPE.DATABASE_FAILURE' + DATABASE_NOT_FOUND: 'z.event.EventError::TYPE.DATABASE_NOT_FOUND' + LAST_ID_NOT_SPECIFIED: 'z.event.EventError::TYPE.LAST_ID_NOT_SPECIFIED' + MISSING_CLIENT_ID: 'z.event.EventError::TYPE.MISSING_CLIENT_ID' + NO_NOTIFICATIONS: 'z.event.EventError::TYPE.NO_NOTIFICATIONS' + REQUEST_FAILURE: 'z.event.EventError::TYPE.REQUEST_FAILURE' diff --git a/app/script/event/EventRepository.coffee b/app/script/event/EventRepository.coffee new file mode 100644 index 00000000000..5b8ba80b8b1 --- /dev/null +++ b/app/script/event/EventRepository.coffee @@ -0,0 +1,393 @@ +# +# 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.event ?= {} + +# Event repository to handle all backend event channels. +class z.event.EventRepository + @::NOTIFICATION_SOURCE = + INJECTION: 'Injection' + SOCKET: 'WebSocket' + STREAM: 'Notification Stream' + + + ### + Construct a new Event Repository. + @param web_socket_service [z.event.WebSocketService] WebSocket service + @param notification_service [z.event.NotificationService] Service handling the notification stream + @param cryptography_repository [z.cryptography.CryptographyRepository] Repository for all cryptography interactions + @param user_repository [z.user.UserRepository] Repository for all user and connection interactions + ### + constructor: (@web_socket_service, @notification_service, @cryptography_repository, @user_repository) -> + @logger = new z.util.Logger 'z.event.EventRepository', z.config.LOGGER.OPTIONS + + @current_client = undefined + + @notifications_handled = 0 + @notifications_loaded = ko.observable false + @notifications_promises = [] + @notifications_total = 0 + @notifications_queue = ko.observableArray [] + @notifications_blocked = false + + @notifications_queue.subscribe (notifications) => + if notifications.length > 0 + return if @notifications_blocked + + notification = @notifications_queue()[0] + @notifications_blocked = true + @_handle_notification notification + .catch (error) => + @logger.log @logger.levels.WARN, 'We failed to handle a notification but will continue with queue', error + .then => + @notifications_blocked = false + @notifications_queue.shift() + @notifications_handled++ + + if @notifications_handled % 5 is 0 + replace = [@notifications_handled, @notifications_total] + amplify.publish z.event.WebApp.APP.UPDATE_INIT, z.string.init_events_progress, false, replace + + else if @notifications_loaded() and not @can_handle_web_socket() + @logger.log @logger.levels.INFO, "Done handling '#{@notifications_total}' notifications from the stream" + if @is_recovering() + @is_recovering false + else + amplify.publish z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, false + @find_ongoing_calls() + @can_handle_web_socket true + @notifications_loaded false + @notifications_promises[0] @last_notification_id() + + @web_socket_buffer = [] + @can_handle_web_socket = ko.observable false + @can_handle_web_socket.subscribe (was_handled) => + @_handle_buffered_notifications() if was_handled + + @last_notification_id = ko.observable undefined + @last_notification_id.subscribe (last_notification_id) => + @logger.log @logger.levels.INFO, "Last notification ID updated to '#{last_notification_id}'" + @notification_service.save_last_notification_id_to_db last_notification_id if last_notification_id + + @is_recovering = ko.observable false + @is_recovering.subscribe (is_recovering) => + if is_recovering + amplify.publish z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, true + @can_handle_web_socket false + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECOVERY + else + amplify.publish z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, false + @can_handle_web_socket true + amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.CONNECTIVITY_RECOVERY + + amplify.subscribe z.event.WebApp.CONNECTION.RECONNECT, @reconnect + amplify.subscribe z.event.WebApp.CONNECTION.ONLINE, @recover_from_notification_stream + amplify.subscribe z.event.WebApp.EVENT.INJECT, @inject_event + + + ############################################################################### + # WebSocket handling + ############################################################################### + + # Initiate the WebSocket connection. + connect: => + if not @current_client().id + throw new z.event.EventError 'Missing client id', z.event.EventError::TYPE.MISSING_CLIENT_ID + + @web_socket_service.client_id = @current_client().id + @web_socket_service.connect (notification) => + if @can_handle_web_socket() + @notifications_queue.push notification + else + @_buffer_web_socket_notification notification + + ### + Close the WebSocket connection. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the disconnect + ### + disconnect: (trigger) => + @web_socket_service.reset trigger + + ### + Re-connect the WebSocket connection. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the disconnect + ### + reconnect: (trigger) => + @can_handle_web_socket false + @web_socket_service.reconnect trigger + + ### + Buffer an incoming notification. + @param notification [Object] Notification data + ### + _buffer_web_socket_notification: (notification) => + @web_socket_buffer.push notification + + # Handle buffered notifications. + _handle_buffered_notifications: => + @logger.log @logger.levels.INFO, "Received '#{@web_socket_buffer.length}' notifications via WebSocket while recovering from stream" + z.util.ko_array_push_all @notifications_queue, @web_socket_buffer + @web_socket_buffer.length = 0 + + + ############################################################################### + # Notification Stream handling + ############################################################################### + + ### + Get notifications for the current client from the stream. + @param notification_id [String] Event ID to start from + @return [Promise] Promise that resolves when all new notifications from the stream have been handled + ### + get_notifications: (last_notification_id, limit = 10000) -> + return new Promise (resolve, reject) => + _got_notifications = (response) => + if response.notifications.length > 0 + last_notification_id = response.notifications[response.notifications.length - 1].id + + notifications = (notification for notification in response.notifications) + @logger.log @logger.levels.INFO, "Added '#{notifications.length}' notifications to the queue" + z.util.ko_array_push_all @notifications_queue, notifications + + if @notifications_promises.length is 0 + @notifications_promises = [resolve, reject] + + @notifications_total += notifications.length + + if response.has_more + @get_notifications last_notification_id, 5000 + else + @notifications_loaded true + @logger.log @logger.levels.INFO, "Fetched '#{@notifications_total}' notifications from the backend" + amplify.publish z.event.WebApp.APP.UPDATE_INIT, z.string.init_events_expectation, true, [@notifications_total] + + else + error_message = "No notifications found since '#{last_notification_id}'" + @logger.log @logger.levels.INFO, error_message, response + reject new z.event.EventError error_message, z.event.EventError::TYPE.NO_NOTIFICATIONS + + @notification_service.get_notifications @current_client().id, last_notification_id, limit + .then (response) -> _got_notifications response + .catch (response) => + # When asking for notifications with a since set to a notification ID that does not belong to our client ID, + # we will get a 404 AND notifications + if response.notifications + _got_notifications response + else if error.code is z.service.BackendClientError::STATUS_CODE.NOT_FOUND + error_message = "No notifications found since '#{last_notification_id}'" + @logger.log @logger.levels.INFO, error_message, response + reject new z.event.EventError error_message, z.event.EventError::TYPE.NO_NOTIFICATIONS + else + error_message = "Failed to get notifications: #{error.message}" + @logger.log @logger.levels.ERROR, error_message, error + reject new z.event.EventError error_message, z.event.EventError::TYPE.REQUEST_FAILURE + + ### + Get the last notification ID for a given client. + @note This API endpoint is currently broken on the backend + @return [Promise] Promise that resolves with the last known notification ID matching a client + ### + get_last_notification_id: -> + return new Promise (resolve, reject) => + @notification_service.get_notifications_last @current_client?().id + .then (response) -> + resolve response.id + .catch reject + ### + Will retrieve missed notifications from the stream after a connectivity loss. + ### + recover_from_notification_stream: => + @is_recovering true + @update_from_notification_stream() + .then (number_of_notifications) => + @is_recovering false if number_of_notifications is 0 + @logger.log @logger.levels.INFO, "Retrieved '#{number_of_notifications}' notifications from stream after connectivity loss" + .catch (error) => + if error.type isnt z.event.EventError::TYPE.NO_NOTIFICATIONS + @logger.log @logger.levels.ERROR, "Failed to recover from notification stream: #{error.message}", error + @is_recovering false + # @todo What do we do in this case? + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + + ### + Fetch all missed events from the notification stream since the last ID stored in database. + @return [Promise] Promise that resolves with the total number of notifications + ### + update_from_notification_stream: => + return new Promise (resolve, reject) => + @notification_service.get_last_notification_id_from_db() + .then (last_notification_id) => + @last_notification_id last_notification_id + @notifications_total = 0 + return @get_notifications @last_notification_id(), 500 + .then (last_notification_id) => + if last_notification_id + @logger.log @logger.levels.INFO, "ID of last notification fetched from stream is '#{last_notification_id}'" + resolve @notifications_total + .catch (error) => + @can_handle_web_socket true + if error.type in [z.event.EventError::TYPE.NO_NOTIFICATIONS, z.event.EventError::TYPE.DATABASE_NOT_FOUND] + amplify.publish z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, false + @find_ongoing_calls() + @logger.log @logger.levels.INFO, 'No notifications found for this user', error + resolve 0 + else + @logger.log @logger.levels.ERROR, "Failed to handle notification stream: #{error.message}", error + reject error + + ### + Method to return an array of Conversation IDs which have a certain active conversation type. + + Example: + If the notifications for a conversation are for example "call.on", "call.off" and "call.on" then the call is active + because the last event which was seen was a "call.on". But if it would be "call.off" then the conversation would not + be marked as active and it's ID would not be returned. + + @param include_on [Array] List of event types to look for + @param exclude_on [Array] Remove activate state on these events + ### + get_conversation_ids_with_active_events: (include_on, exclude_on) => + return new Promise (resolve, reject) => + @cryptography_repository.storage_repository.load_events_by_types _.flatten [include_on, exclude_on] + .then (records) -> + raw_events = (record.raw for record in records) + + filtered_conversations = {} + + for event in raw_events + conversation_id = event.conversation + if event.type in include_on + filtered_conversations[conversation_id] = null + else if event.type in exclude_on + delete filtered_conversations[conversation_id] + resolve Object.keys filtered_conversations + .catch (error) => + @logger.log @logger.levels.ERROR, "Something failed: #{error?.message}", error + reject error + + ### + Check for conversations with ongoing calls. + @return [Promise] Promise that resolves when conversation that could contain a call have been identified + ### + find_ongoing_calls: => + @logger.log @logger.levels.INFO, 'Checking for ongoing calls' + @get_conversation_ids_with_active_events [z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE], [z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE] + .then (response) => + @logger.log @logger.levels.INFO, "Identified '#{response.length}' conversations that possibly have an ongoing call", response + amplify.publish z.event.WebApp.CALL.STATE.CHECK, conversation_id for conversation_id in response + .catch (error) => + @logger.log @logger.levels.ERROR, 'Could not check for active calls', error + + + ############################################################################### + # Notification/Event handling + ############################################################################### + + ### + Inject event into a conversation. + @note Don't add unable to decrypt to self conversation + @param event [Object] Event payload to be injected + ### + inject_event: (event) => + if event.conversation isnt @user_repository.self().id + @_handle_event event, @NOTIFICATION_SOURCE.INJECTION + + ### + Publish the given event. + @param event [Object] Mapped event + ### + _distribute_event: (event) -> + switch event.type.split('.')[0] + when 'call' + amplify.publish z.event.WebApp.CALL.EVENT_FROM_BACKEND, event + when 'conversation' + amplify.publish z.event.WebApp.CONVERSATION.EVENT_FROM_BACKEND, event + else + amplify.publish event.type, event + + if event.conversation + @logger.log @logger.levels.INFO, "Distributed '#{event.type}' event for conversation '#{event.conversation}'", event + else + @logger.log @logger.levels.INFO, "Distributed '#{event.type}' event", event + + ### + Handle a single event from the notification stream or WebSocket. + @param event [JSON] Backend event extracted from notification stream + @return [Promise] Promise that resolves with boolean whether the event was saved + ### + _handle_event: (event, source) -> + return new Promise (resolve, reject) => + sending_client = event.data?.sender + if sending_client + log_message = "Received encrypted event '#{event.type}' from client '#{sending_client}' of user '#{event.from}'" + else if event.from + log_message = "Received unencrypted event '#{event.type}' from user '#{event.from}'" + else + log_message = "Received call event '#{event.type}' in conversation '#{event.conversation}'" + @logger.log @logger.levels.INFO, log_message, {event_object: event, event_json: JSON.stringify event} + + if event.type in z.event.EventTypeHandling.IGNORE + @logger.log "Event ignored: '#{event.type}'", {event_object: event, event_json: JSON.stringify event} + return resolve true + else if event.type in z.event.EventTypeHandling.DECRYPT + promise = @cryptography_repository.decrypt_event(event).then (generic_message) => + @cryptography_repository.save_encrypted_event generic_message, event + else if event.type in z.event.EventTypeHandling.STORE + promise = @cryptography_repository.save_unencrypted_event event + else + promise = Promise.resolve {raw: event} + + promise.then (record) => + if record and (source is @NOTIFICATION_SOURCE.SOCKET or @is_recovering or record.raw.type.startsWith 'conversation') + @_distribute_event record.mapped or record.raw + resolve record + .catch (error) => + if error.type is z.cryptography.CryptographyError::TYPE.PREVIOUSLY_STORED + resolve true + else + @logger.log @logger.levels.ERROR, + "Failed to handle '#{event.type}' event '#{event.id or 'no ID'}' from '#{source}': '#{error.message}'", event + reject error + + ### + Handle all events from the payload of an incoming notification. + @param event [Object] Event data + @return [String] ID of the handled notification + ### + _handle_notification: (notification) => + return new Promise (resolve, reject) => + events = notification.payload + source = if @can_handle_web_socket() then @NOTIFICATION_SOURCE.SOCKET else @NOTIFICATION_SOURCE.STREAM + + @logger.log @logger.levels.INFO, + "Handling notification '#{notification.id}' from '#{source}' containing '#{events.length}' events", notification + + if events.length is 0 + @logger.log @logger.levels.WARN, 'Notification payload does not contain any events' + @last_notification_id notification.id + resolve @last_notification_id() + else + Promise.all (@_handle_event event, source for event in events) + .then => + @last_notification_id notification.id + resolve @last_notification_id() + .catch (error) => + @logger.log @logger.levels.ERROR, + "Failed to handle notification '#{notification.id}' from '#{source}': #{error.message}", error + reject error diff --git a/app/script/event/EventTypeHandling.coffee b/app/script/event/EventTypeHandling.coffee new file mode 100644 index 00000000000..53f7436cbe0 --- /dev/null +++ b/app/script/event/EventTypeHandling.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.event ?= {} + +z.event.EventTypeHandling = + DECRYPT: [ + z.event.Backend.CONVERSATION.OTR_ASSET_ADD + z.event.Backend.CONVERSATION.OTR_MESSAGE_ADD + ] + IGNORE: [ + z.event.Backend.CONVERSATION.CLIENT_MESSAGE_ADD + z.event.Backend.CONVERSATION.TYPING + ] + STORE: [ + z.event.Backend.CONVERSATION.ASSET_ADD + z.event.Backend.CONVERSATION.HOT_KNOCK + z.event.Backend.CONVERSATION.KNOCK + z.event.Backend.CONVERSATION.LOCATION + z.event.Backend.CONVERSATION.MEMBER_JOIN + z.event.Backend.CONVERSATION.MEMBER_LEAVE + z.event.Backend.CONVERSATION.MESSAGE_ADD + z.event.Backend.CONVERSATION.RENAME + z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE + z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE + z.event.Client.CONVERSATION.UNABLE_TO_DECRYPT + ] diff --git a/app/script/event/NotificationService.coffee b/app/script/event/NotificationService.coffee new file mode 100644 index 00000000000..0e14e3f199c --- /dev/null +++ b/app/script/event/NotificationService.coffee @@ -0,0 +1,88 @@ +# +# 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.event ?= {} + +# Notification Service for all notification stream calls to the backend REST API. +class z.event.NotificationService + PRIMARY_KEY_LAST_NOTIFICATION: 'z.storage.StorageKey.NOTIFICATION.LAST_ID' + URL_NOTIFICATIONS: '/notifications' + URL_NOTIFICATIONS_LAST: '/notifications/last' + ### + Construct a new Notification Service. + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client, @storage_service) -> + @logger = new z.util.Logger 'z.event.NotificationService', z.config.LOGGER.OPTIONS + + ### + Get notifications from the stream. + + @param size [Integer] Maximum number of notifications to return + @param client_id [String] Only return notifications targeted at the given client + @param since [String] Only return notifications more recent than this notification ID (like "7130304a-c839-11e5-8001-22000b0fe035") + @return [Promise] Promise that resolves with the notifications + ### + get_notifications: (client_id, notification_id, size) -> + @client.send_request + url: @client.create_url @URL_NOTIFICATIONS + type: 'GET' + data: + client: client_id + since: notification_id + size: size + + ### + Get the last notification for a given client. + @param client_id [String] Only return notifications targeted at the given client + ### + get_notifications_last: (client_id) -> + @client.send_request + url: @client.create_url @URL_NOTIFICATIONS_LAST + type: 'GET' + data: + client: client_id + + ### + Load last notifications id from storage. + @return [Promise] Promise that resolves with the stored last notification ID. + ### + get_last_notification_id_from_db: => + return new Promise (resolve, reject) => + @storage_service.load @storage_service.OBJECT_STORE_AMPLIFY, @PRIMARY_KEY_LAST_NOTIFICATION + .then (record) => + if record?.value + resolve record.value + else + error_message = 'Last notification ID not found in storage' + @logger.log @logger.levels.WARN, error_message + reject new z.event.EventError error_message, z.event.EventError::TYPE.DATABASE_NOT_FOUND + .catch (error) => + error_message = "Failed to get last notification ID from storage: #{error.message}" + @logger.log @logger.levels.ERROR, error_message, error + reject new z.event.EventError error_message, z.event.EventError::TYPE.DATABASE_FAILURE + + ### + Load last notifications id from storage. + @param notification_id [String] Notification ID to be stored + @return [Promise] Promise that will resolve with the stored record + ### + save_last_notification_id_to_db: (notification_id) => + payload = value: notification_id + return @storage_service.save @storage_service.OBJECT_STORE_AMPLIFY, @PRIMARY_KEY_LAST_NOTIFICATION, payload diff --git a/app/script/event/WebApp.coffee b/app/script/event/WebApp.coffee new file mode 100644 index 00000000000..605ba62c42d --- /dev/null +++ b/app/script/event/WebApp.coffee @@ -0,0 +1,187 @@ +# +# 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.event ?= {} + +# Enum of diffent webapp events. +z.event.WebApp = + ACTION: + SHOW: 'wire.webapp.action.show' + ANALYTICS: + EVENT: 'wire.webapp.analytics.event' + INIT: 'wire.webapp.analytics.init' + SESSION: + CLOSE: 'wire.webapp.analytics.session.close' + START: 'wire.webapp.analytics.session.start' + AUDIO: + PLAY: 'wire.webapp.audio.play' + PLAY_IN_LOOP: 'wire.webapp.audio.play-in-loop' + STOP: 'wire.webapp.audio.stop' + APP: + UPDATE_INIT: 'wire.webapp.app.update-init' + HIDE: 'wire.webapp.app.hide' + FADE_IN: 'wire.webapp.app.fade-in' + ARCHIVE: + SHOW: 'wire.webapp.archive.show' + CLOSE: 'wire.webapp.archive.close' + CALL: + EVENT_FROM_BACKEND: 'wire.webapp.call.event-from-backend' + STATE: + CHECK: 'wire.webapp.call.state.check' + DELETE: 'wire.webapp.call.state.delete' + IGNORE: 'wire.webapp.call.state.ignore' + JOIN: 'wire.webapp.call.state.join' + LEAVE: 'wire.webapp.call.state.leave' + REMOVE_PARTICIPANT: 'wire.webapp.call.state.remove-participant' + TOGGLE: 'wire.webapp.call.state.toggle' + TOGGLE_SCREEN: 'wire.webapp.call.state.toggle-screen' + MEDIA: + MUTE_AUDIO: 'wire.webapp.call.media.mute_audio' + ADD_STREAM: 'wire.webapp.call.media.add_stream' + SIGNALING: + DELETE_FLOW: 'wire.webapp.call.signaling.delete-flow' + POST_FLOWS: 'wire.webapp.call.signaling.post-flows' + SEND_ICE_CANDIDATE_INFO: 'wire.webapp.call.signaling.send-ice-candidate-info' + SEND_LOCAL_SDP_INFO: 'wire.webapp.call.signaling.send-local-sdp-info' + CLIENT: + DELETE: 'wire.webapp.client.delete' + CONNECT: + IMPORT_CONTACTS: 'wire.webapp.connect.import-contacts' + CONNECTION: + ACCESS_TOKEN: + RENEW: 'wire.webapp.connection.access-token.renew' + RENEWED: 'wire.webapp.connection.access-token.renewed' + RECONNECT: 'wire.webapp.connection.reconnect' + ONLINE: 'wire.webapp.connection.online' + CONVERSATION: + DEBUG: 'wire.webapp.conversation.debug' + EVENT_FROM_BACKEND: 'wire.webapp.conversation.event-from-backend' + LOADED_STATES: 'wire.webapp.conversation.loaded-states' + MAP_CONNECTION: 'wire.webapp.conversation.map-connection' + PEOPLE: + HIDE: 'wire.webapp.conversation.people.hide' + SHOW: 'wire.webapp.conversation.show' + STORE: 'wire.webapp.conversation.store' + SWITCH: 'wire.webapp.conversation.switch' + DETAIL_VIEW: + SHOW: 'wire.webapp.conversation.detail-view.show' + UNREAD: 'wire.webapp.conversation.unread' + ASSET: + CANCEL: 'wire.webapp.conversation.asset.cancel' + MESSAGE: + DELETE: 'wire.webapp.conversation.message.delete' + IMAGE: + SEND: 'wire.webapp.conversation.image.send' + CONVERSATION_LIST: + SHOW: 'wire.webapp.conversation-list.show' + ARCHIVE: + HIDE: 'wire.webapp.conversation-list.archive.hide' + DEBUG: + UPDATE_LAST_CALL_STATUS: 'wire.webapp.debug.update-last-call-status' + EXTENSIONS: + SHOW: 'wire.webapp.extionsions.show' + GIPHY: + SHOW: 'wire.webapp.extionsions.giphy.show' + SEND: 'wire.webapp.extionsions.giphy.send' + EVENT: + INJECT: 'wire.webapp.event.inject' + NOTIFICATION_HANDLING_STATE: 'wire.webapp.event.notification_handling' + LIST: + BLUR: 'wire.webapp.list.blur' + SCROLL: 'wire.webapp.list.scroll' + FULLSCREEN_ANIM_DISABLED: 'wire.webapp.list.anim-disabled' + LOADED: 'wire.webapp.loaded' + PEOPLE: + HIDE: 'wire.webapp.participant-et.hide' + SHOW: 'wire.webapp.participant-et.show' + TOGGLE: 'wire.webapp.participants.toggle' + PENDING: + SHOW: 'wire.webapp.pending.show' + LEFT: + HIDE: 'wire.webapp.left.hide' + FADE_IN: 'wire.webapp.left.fade-in' + LOGOUT: + ASK_TO_CLEAR_DATA: 'wire.webapp.logout.ask-to-clear-data' + WELCOME: + SHOW: 'wire.webapp.profile.welcome.show' + UNSPLASH_LOADED: 'wire.webapp.profile.welcome.unsplash-loaded' + PROFILE: + SHOW: 'wire.webapp.profile.show' + HIDE: 'wire.webapp.profile.hide' + FADE_IN: 'wire.webapp.profile.fade-in' + SETTINGS: + SHOW: 'wire.webapp.profile.settings.show' + UPLOAD_PICTURE: 'wire.webapp.profile.upload-picture' + PROPERTIES: + CHANGE: + APP_BANNER: 'wire.webapp.properties.change.app-banner' + DEBUG: 'wire.webapp.properties.change.debug' + UPDATE: + GOOGLE: 'wire.webapp.properties.update.google' + OSX_CONTACTS: 'wire.webapp.properties.update.google' + CALL_MUTE: 'wire.webapp.properties.update.call-mute' + SEND_DATA: 'wire.webapp.properties.update.send-data' + SOUND_ALERTS: 'wire.webapp.properties.update.sound-alerts' + HAS_CREATED_CONVERSATION: 'wire.webapp.properties.update.has-created-conversation' + UPDATED: 'wire.webapp.properties.updated' + SEARCH: + HIDE: 'wire.webapp.search.hide' + ONBOARDING: 'wire.webapp.search.onboarding' + SHOW: 'wire.webapp.people-picker.show' + BADGE: + HIDE: 'wire.webapp.search.badge.hide' + SHOW: 'wire.webapp.search.badge.show' + SIGN_OUT: 'wire.webapp.logout' + SYSTEM_NOTIFICATION: + CLICK: 'wire.webapp.system-notification.click' + NOTIFY: 'wire.webapp.system-notification.notify' + REMOVE_READ: 'wire.webapp.system.notification.remove_read' + REQUEST_PERMISSION: 'wire.webapp.system-notification.request_permission' + SHOW: 'wire.webapp.system-notification.show' + TELEMETRY: + BACKEND_REQUESTS: 'wire.webapp.telemetry.backend_requests' + USER: + UNBLOCKED: 'wire.webapp.user.unblocked' + EVENT_FROM_BACKEND: 'wire.webapp.user.event-from-backend' + WARNINGS: + SHOW: 'wire.webapp.warning.show' + DISMISS: 'wire.webapp.warning.dismiss' + MODAL: 'wire.webapp.warning.modal' + WINDOW: + RESIZE: + HEIGHT: 'wire.webapp.window.resize.height' + WIDTH: 'wire.webapp.window.resize.width' + SELF: + CLIENT_ADD: 'wire.webapp.self.client-add' + CLIENT_REMOVE: 'wire.webapp.self.client-remove' + SHORTCUT: + ADD_PEOPLE: 'wire.webapp.shortcut.add-people' + ARCHIVE: 'wire.webapp.shortcut.archive' + CALL_IGNORE: 'wire.webapp.shortcut.call-ignore' + CALL_MUTE: 'wire.webapp.shortcut.call-mute' + DEBUG: 'wire.webapp.shortcut.debug' + NEXT: 'wire.webapp.shortcut.next' + PEOPLE: 'wire.webapp.shortcut.people' + PICTURE: 'wire.webapp.shortcut.picture' + PING: 'wire.webapp.shortcut.ping' + PREV: 'wire.webapp.shortcut.prev' + SILENCE: 'wire.webapp.shortcut.silence' + START: 'wire.webapp.shortcut.start' + STORAGE: + SAVE_ENTITY: 'wire.webapp.storage.save-entity' diff --git a/app/script/event/WebSocketService.coffee b/app/script/event/WebSocketService.coffee new file mode 100644 index 00000000000..66f2f7c283e --- /dev/null +++ b/app/script/event/WebSocketService.coffee @@ -0,0 +1,186 @@ +# +# 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.event ?= {} + +RECONNECT_INTERVAL = 15000 + +PING_INTERVAL = 30000 +PING_INTERVAL_THRESHOLD = 2000 + +# WebSocket Service to manage the WebSocket connection to the backend. +class z.event.WebSocketService + @::CHANGE_TRIGGER = + CLEANUP: 'z.event.WebSocketService::CHANGE_TRIGGER.CLEANUP' + CLOSE: 'z.event.WebSocketService::CHANGE_TRIGGER.CLOSE' + ERROR: 'z.event.WebSocketService::CHANGE_TRIGGER.ERROR' + LOGOUT: 'z.event.WebSocketService::CHANGE_TRIGGER.LOGOUT' + OFFLINE: 'z.event.WebSocketService::CHANGE_TRIGGER.OFFLINE' + ONLINE: 'z.event.WebSocketService::CHANGE_TRIGGER.ONLINE' + PAGE_NAVIGATION: 'z.event.WebSocketService::CHANGE_TRIGGER.PAGE_NAVIGATION' + PING_INTERVAL: 'z.event.WebSocketService::CHANGE_TRIGGER.PING_INTERVAL' + READY_STATE: 'z.event.WebSocketService::CHANGE_TRIGGER.READY_STATE' + WARNING_BAR: 'z.event.WebSocketService::CHANGE_TRIGGER.WARNING_BAR' + + + ### + Construct a new WebSocket Service. + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client) -> + @logger = new z.util.Logger 'z.event.WebSocketService', z.config.LOGGER.OPTIONS + + @client_id = undefined + @connection_url = '' + @socket = undefined + + @on_notification = undefined + + @ping_interval_id = undefined + @last_ping_time = undefined + + @reconnect_timeout_id = undefined + @reconnect_count = 0 + + ### + Establish the WebSocket connection. + @param on_notification [Function] Function to be called on incoming notifications + @return [Promise] Promise that resolves once the WebSocket connects + ### + connect: (on_notification) => + return new Promise (resolve, reject) => + @on_notification = on_notification + @connection_url = "#{@client.web_socket_url}/await?access_token=#{@client.access_token}" + @connection_url = z.util.append_url_parameter @connection_url, "client=#{@client_id}" if @client_id + + @reset z.event.WebSocketService::CHANGE_TRIGGER.CLEANUP if typeof @socket is 'object' + + @socket = new WebSocket @connection_url + @socket.binaryType = 'blob' + + # http://stackoverflow.com/a/27828483/451634 + delete @socket.URL + + @socket.onopen = => + @logger.log @logger.levels.INFO, "Connected WebSocket to: #{@client.web_socket_url}/await" + @ping_interval_id = window.setInterval @send_ping, PING_INTERVAL + resolve() + + @socket.onerror = (event) => + @logger.log @logger.levels.ERROR, 'WebSocket connection error.', event + @reset z.event.WebSocketService::CHANGE_TRIGGER.ERROR, true + + @socket.onclose = (event) => + @logger.log @logger.levels.WARN, 'Closed WebSocket connection', event + @reset z.event.WebSocketService::CHANGE_TRIGGER.CLOSE, true + + @socket.onmessage = (event) -> + if event.data instanceof Blob + blob_reader = new FileReader() + blob_reader.onload = -> on_notification JSON.parse blob_reader.result + blob_reader.readAsText event.data + + ### + Reconnect WebSocket after access token has been refreshed. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the reconnect + ### + pending_reconnect: (trigger) => + amplify.unsubscribeAll z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEWED + @logger.log @logger.levels.INFO, "Executing pending WebSocket reconnect triggered by '#{trigger}' after access token refresh" + @reconnect trigger + + ### + Try to re-establish the WebSocket connection. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the reconnect + ### + reconnect: (trigger) => + if not z.storage.get_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.EXPIRATION + @logger.log @logger.levels.INFO, 'Access token has to be refreshed before reconnecting the WebSocket' + amplify.subscribe z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEWED, => @pending_reconnect trigger + return amplify.publish z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEW + + @reconnect_count++ + reconnect = => + @logger.log @logger.levels.INFO, "Trying to re-establish WebSocket connection. Try ##{@reconnect_count}" + @connect @on_notification + .then => + @reconnect_count = 0 + @logger.log @logger.levels.INFO, "Reconnected to WebSocket triggered by '#{trigger}'" + @on_socket_reconnected() + + if @reconnect_count is 1 + reconnect() + else + @reconnect_timeout_id = setTimeout -> + reconnect() + , RECONNECT_INTERVAL + + ### + Reset the WebSocket connection. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the reset + @param reconnect [Boolean] Re-establish the WebSocket connection + ### + reset: (trigger, reconnect = false) => + if @socket?.onclose + @logger.log @logger.levels.INFO, "WebSocket reset triggered by '#{trigger}'" + @socket.onerror = undefined + @socket.onclose = undefined + @socket.close() + window.clearInterval @ping_interval_id + window.clearTimeout @reconnect_timeout_id + if reconnect + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + @reconnect trigger + + # Send a WebSocket ping. + send_ping: => + if @socket.readyState is 1 + current_time = Date.now() + @last_ping_time ?= current_time + ping_interval_diff = @last_ping_time - current_time + + if ping_interval_diff > PING_INTERVAL + PING_INTERVAL_THRESHOLD + @logger.log @logger.levels.WARN, 'Ping interval check failed', event + @reconnect z.event.WebSocketService::CHANGE_TRIGGER.PING_INTERVAL + else + @logger.log @logger.levels.INFO, 'Sending ping to WebSocket' + @socket.send 'Wire is so much nicer with internet!' + else + @logger.log @logger.levels.WARN, "WebSocket connection is closed. Current ready state: #{@socket.readyState}" + @reconnect z.event.WebSocketService::CHANGE_TRIGGER.READY_STATE + + ### + Behavior when WebSocket connection is re-established after a connection drop. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the reconnect + ### + on_socket_reconnected: => + amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + @logger.log @logger.levels.WARN, 'Re-established WebSocket connection. Recovering from Notification Stream...' + amplify.publish z.event.WebApp.CONNECTION.ONLINE + + ### + Behavior when WebSocket connection is closed. + @param trigger [z.event.WebSocketService::CHANGE_TRIGGER] Trigger of the connection close + ### + on_socket_closed: (trigger) -> + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + error = new Error "WebSocket connection lost: #{trigger}" + custom_data = + network_status: navigator.onLine + Raygun.send error, custom_data diff --git a/app/script/extension/GiphyContentSizes.coffee b/app/script/extension/GiphyContentSizes.coffee new file mode 100644 index 00000000000..fa29879b26d --- /dev/null +++ b/app/script/extension/GiphyContentSizes.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.extension ?= {} + +# Enum of different Giphy content sizes. +z.extension.GiphyContentSizes = + FIXED_HEIGHT: 'fixed_height' + FIXED_HEIGHT_STILL: 'fixed_height_still' + FIXED_HEIGHT_DOWNSAMPLED: 'fixed_height_downsampled' + FIXED_WIDTH: 'fixed_width' + FIXED_WIDTH_STILL: 'fixed_width_still' + FIXED_WIDTH_DOWNSAMPLED: 'fixed_width_downsampled' + FIXED_HEIGHT_SMALL: 'fixed_height_small' + FIXED_HEIGHT_SMALL_STILL: 'fixed_height_small_still' + FIXED_WIDTH_SMALL: 'fixed_width_small' + FIXED_WIDTH_SMALL_STILL: 'fixed_width_small_still' + DOWNSIZED: 'downsized' + DOWNSIZED_STILL: 'downsized_still' + DOWNSIZED_LARGE: 'downsized_large' + ORIGINAL: 'original' + ORIGINAL_STILL: 'original_still' diff --git a/app/script/extension/GiphyRepository.coffee b/app/script/extension/GiphyRepository.coffee new file mode 100644 index 00000000000..7a830be70c9 --- /dev/null +++ b/app/script/extension/GiphyRepository.coffee @@ -0,0 +1,143 @@ +# +# 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.extension ?= {} + +# Giphy repository for all interactions with the giphy service. +class z.extension.GiphyRepository + ### + Construct a new Giphy Repository. + + @param giphy_service [z.extension.GiphyService] Giphy REST API implementation + ### + constructor: (@giphy_service) -> + @logger = new z.util.Logger 'z.extension.GiphyRepository', z.config.LOGGER.OPTIONS + @gif_query_cache = {} + + ### + Get random GIF for a word or phrase. + + @param options [Object] + @option options [String] tag search query term or phrase + @option options [Number] retry (optional) How many retries to get the correct size. (default 3) + @option options [Number] max_size (optional) Maximum gif size in bytes (default 3MB) + ### + get_random_gif: (options) -> + return new Promise (resolve, reject) => + options = $.extend + retry: 3 + max_size: 3 * 1024 * 1024 + , options + + _get_random_gif = (retries = 0) => + if options.retry is retries + reject new Error "Unable to fetch a proper gif within #{options.retry} retries" + + @giphy_service.get_random tag: options.tag + .then (response) => + @giphy_service.get_by_id ids: response.data.id + .then (response) => + images = response.data.images + static_gif = images[z.extension.GiphyContentSizes.FIXED_WIDTH_STILL] + animation_gif = images[z.extension.GiphyContentSizes.DOWNSIZED] + + if animation_gif.size > options.max_size + @logger.log "Gif size (#{animation_gif.size}) is over maximum size (#{animation_gif.size})" + _get_random_gif retries + 1 + else + resolve + url: response.data.url + static: static_gif.url + animated: animation_gif.url + .catch (error) -> + reject error + + _get_random_gif() + + ### + Get random GIFs for a word or phrase. + + @param options [Object] + @option options [String] query search query term or phrase + @option options [Number] number amount of GIFs to receive + @option options [Number] max_size (optional) Maximum gif size in bytes (default 3MB) + @option options [Boolean] random (optional) will return an randomized result (default true) + @option options [String] sorting (optional) specify sorting ('relevant' or 'recent' default 'relevant') + ### + get_gifs: (options) => + return new Promise (resolve, reject) => + offset = 0 + result = [] + + options = $.extend + number: 6 + max_size: 3 * 1024 * 1024 + random: true + sorting: 'relevant' + , options + + if not options.query + error = new Error 'No query specified' + @logger.log @logger.levels.ERROR, error.message, error + reject error + + if options.random + options.sorting = z.util.ArrayUtil.random_element ['recent', 'relevant'] + + total = @gif_query_cache[options.query] + if total? + if options.number >= total + offset = 0 + else + range = total - options.number + offset = Math.floor Math.random() * range + + @giphy_service.get_search + query: options.query + limit: 100 + sorting: options.sorting + offset: offset + .then (response) => + + gifs = response.data + if options.random + gifs = gifs.sort -> .5 - Math.random() + + @gif_query_cache[options.query] = response.pagination.total_count + + for n in [0...options.number] + break if n is gifs.length + + gif = gifs[n] + images = gif.images + static_gif = images[z.extension.GiphyContentSizes.FIXED_WIDTH_STILL] + animation_gif = images[z.extension.GiphyContentSizes.DOWNSIZED] + + if animation_gif.size > options.max_size + continue + else + result.push + url: gif.url + static: static_gif.url + animated: animation_gif.url + + resolve result + .catch (error) => + @logger.log "Unable to fetch gif for query: #{options.query}", error + reject error diff --git a/app/script/extension/GiphyService.coffee b/app/script/extension/GiphyService.coffee new file mode 100644 index 00000000000..15e615687d6 --- /dev/null +++ b/app/script/extension/GiphyService.coffee @@ -0,0 +1,90 @@ +# +# 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.extension ?= {} + +# Giphy Service for all giphy calls to the backend REST API. +class z.extension.GiphyService + ### + Construct a new Giphy Service. + + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client) -> + @GIPHY_ENDPOINT_BASE = '/giphy/v1/gifs' + + ### + Get GIFs for IDs. + + @param [Object] + @option options [String|Array] ids A single id or comma separated list of IDs to fetch GIF size data. + @option options [Function] callback (optional) Function to be called on server return + ### + get_by_id: (options) => + ids = if _.isArray options.ids then options.ids else [options.ids] + url = "#{@GIPHY_ENDPOINT_BASE}/#{ids.join ','}" + + @client.send_json + type: 'GET' + url: @client.create_url url + callback: options.callback + + ### + Search all Giphy GIFs for a word or phrase. + + @param [Object] + @option options [String] tag The GIF tag to limit randomness by + @option options [Function] callback (optional) Function to be called on server return + ### + get_random: (options) => + url = "#{@GIPHY_ENDPOINT_BASE}/random?tag=#{encodeURIComponent options.tag}" + + @client.send_json + type: 'GET' + url: @client.create_url url + callback: options.callback + + ### + Search GIFs for a word or phrase. + + @param options [Object] + @option options [String] query search query term or phrase + @option options [Number] limit (optional) Number of results to return (maximum 100, default 25) + @option options [Number] offset (optional) Results offset (defaults 0) + @option options [String] sorting (optional) Relevant or recent + @option options [Function] callback (optional) Function to be called on server return + ### + get_search: (options) -> + + options = $.extend + limit: 25 + offset: 0 + sorting: 'relevant' + , options + + url = "#{@GIPHY_ENDPOINT_BASE}/search" + + "?q=#{encodeURIComponent options.query}" + + "&offset=#{options.offset}" + + "&limit=#{options.limit}" + + "&sort=#{options.sorting}" + + @client.send_json + type: 'GET' + url: @client.create_url url + callback: options.callback diff --git a/app/script/links/LinkPreviewError.coffee b/app/script/links/LinkPreviewError.coffee new file mode 100644 index 00000000000..3fcd7112325 --- /dev/null +++ b/app/script/links/LinkPreviewError.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.links ?= {} + +class z.links.LinkPreviewError + constructor: (type) -> + @name = @constructor.name + @stack = (new Error()).stack + @type = type + + @:: = new Error() + @::constructor = @ + @::TYPE = + NOT_SUPPORTED: 'z.links.LinkPreviewError::TYPE.NOT_SUPPORTED' + UNSUPPORTED_TYPE: 'z.links.LinkPreviewError::TYPE.UNSUPPORTED_TYPE' + NO_DATA_AVAILABLE: 'z.links.LinkPreviewError::TYPE.NO_DATA_AVAILABLE' diff --git a/app/script/links/LinkPreviewHelpers.coffee b/app/script/links/LinkPreviewHelpers.coffee new file mode 100644 index 00000000000..cf1e12632c9 --- /dev/null +++ b/app/script/links/LinkPreviewHelpers.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.links ?= {} + +z.links.LinkPreviewHelpers = + + ### + Check if the text contains only one link + + @param text [String] + ### + contains_only_link: (text) -> + text = text.trim() + urls = twttr.txt.extractUrls text + return urls.length is 1 and urls[0] is text + + ### + Get first link and link offset for given text. + + @param text [String] + ### + get_first_link_with_offset: (text) -> + links = twttr.txt.extractUrls text + first_link = links[0] + + if first_link? + link_offset = text.indexOf first_link + return [first_link, link_offset] diff --git a/app/script/links/LinkPreviewProtoBuilder.coffee b/app/script/links/LinkPreviewProtoBuilder.coffee new file mode 100644 index 00000000000..fd178c44f26 --- /dev/null +++ b/app/script/links/LinkPreviewProtoBuilder.coffee @@ -0,0 +1,47 @@ +# +# 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.links ?= {} + +z.links.LinkPreviewProtoBuilder = do -> + + ### + Create link preview proto message + + @param data [Object] open graph data + @param url [String] link entered by the user + @param offset [Number] starting index of the link + + @returns [z.proto.LinkPreview] + ### + build_from_open_graph_data = (data, url, offset = 0) -> + preview = null + + return if _.isEmpty data + + switch + when not data.type? or data.type in ['article', 'website'] and data.title + preview = new z.proto.Article data.url or url, data.title, data.description + + if preview? + return new z.proto.LinkPreview url, offset, preview + + return { + build_from_open_graph_data: build_from_open_graph_data + } diff --git a/app/script/links/LinkPreviewRepository.coffee b/app/script/links/LinkPreviewRepository.coffee new file mode 100644 index 00000000000..a486bff3c08 --- /dev/null +++ b/app/script/links/LinkPreviewRepository.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.links ?= {} + +class z.links.LinkPreviewRepository + constructor: (@asset_service) -> + @logger = new z.util.Logger 'z.links.LinkPreviewRepository', z.config.LOGGER.OPTIONS + + ### + Create link preview for given link. This will upload associated image as asset and will + resolve with an z.proto.LinkPreview instance + + @param url [String] + @param offset [Number] starting index of the link + ### + get_link_preview: (url, offset = 0) -> + open_graph_data = null + + Promise.resolve() + .then => + if window.openGraph + return @_fetch_open_graph_data url + else + throw new z.links.LinkPreviewError z.links.LinkPreviewError::TYPE.NOT_SUPPORTED + .then (data) -> + open_graph_data = data + if open_graph_data + return z.links.LinkPreviewProtoBuilder.build_from_open_graph_data data, url, offset + else + throw new z.links.LinkPreviewError z.links.LinkPreviewError::TYPE.NO_DATA_AVAILABLE + .then (link_preview) -> + if link_preview? + return link_preview + else + throw new z.links.LinkPreviewError z.links.LinkPreviewError::TYPE.UNSUPPORTED_TYPE + .then (link_preview) => + return @_fetch_preview_image link_preview, open_graph_data.image + + ### + Fetch and upload open graph images + + ### + _fetch_preview_image: (link_preview, open_graph_image) -> + if link_preview.preview is 'article' and open_graph_image?.data + return @_upload_preview_image open_graph_image.data + .then (asset) -> + link_preview.article.set 'image', asset + return link_preview + else + return link_preview + + ### + Fetch open graph data + + @param url [String] + ### + _fetch_open_graph_data: (link) -> + return new Promise (resolve, reject) -> + openGraph link, (error, data) -> + if error then reject error else resolve data + + ### + Upload open graph image as asset + + @param data_uri [String] image data as base64 encoded data URI + ### + _upload_preview_image: (data_URI) -> + Promise.resolve() + .then -> + return z.util.base64_to_blob data_URI + .then (blob) => + @asset_service.upload_image_asset blob, public: true diff --git a/app/script/localization/Localizer.coffee b/app/script/localization/Localizer.coffee new file mode 100644 index 00000000000..1da23e07909 --- /dev/null +++ b/app/script/localization/Localizer.coffee @@ -0,0 +1,106 @@ +# +# 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.localization ?= {} + +# Localizer to replace strings. +class Localizer + # Construct a new Localizer. + constructor: -> + param = z.util.get_url_parameter z.auth.URLParameter.LOCALE + z.storage.set_value z.storage.StorageKey.LOCALIZATION.LOCALE, param if param + @locale = z.storage.get_value(z.storage.StorageKey.LOCALIZATION.LOCALE) or navigator.language.substr(0, 2) or 'en' + # Moment defaults to the language loaded last. Thus we need to set the fallback to English until we use all locales. + # @see http://momentjs.com/docs/#/i18n/changing-locale/ + moment.locale [@locale, 'en'] + $.extend z.string, z.string[@locale] if z.string[@locale] + + ### + Pulls the localized string from the resources and replaces placeholders. + + @note Takes the id of the string for look up from z.string is directly for simple use. Else pass it in as the id + parameter in conjunction with a single or multiple (it supports but does not require an array) replace rules that + consist of a placeholder and the content that it should be replace with. + + @param id [String] Localization string ID + @param replace [Object | Array] Placeholders that should be replaced + @option replace [String] placeholder Content to be replaced + @option replace [String] content replacing content + ### + get_text: (valueAccessor) -> + return if not valueAccessor? + args = [] + + if valueAccessor.id? + s = valueAccessor.id + if _.isArray valueAccessor.replace + args = valueAccessor.replace + else + args.push valueAccessor.replace + else + s = valueAccessor + + if args.length isnt 0 + for i in [0...args.length] + reg = new RegExp args[i].placeholder, 'gm' + s = s.replace reg, args[i].content + + return s + +z.localization.Localizer = new Localizer() + + +# Knockout binding to localize links. +ko.bindingHandlers.l10n_href = + update: (element, valueAccessor) -> + element.setAttribute 'href', z.localization.Localizer.get_text valueAccessor() + + +# Knockout binding to localize input values. +ko.bindingHandlers.l10n_input = + update: (element, valueAccessor) -> + element.setAttribute 'value', z.localization.Localizer.get_text valueAccessor() + + +# Knockout binding to localize input placeholders. +ko.bindingHandlers.l10n_placeholder = + update: (element, valueAccessor) -> + element.setAttribute 'placeholder', z.localization.Localizer.get_text valueAccessor() + + +# Knockout binding to localize element text. +ko.bindingHandlers.l10n_text = + update: (element, valueAccessor) -> + ko.utils.setTextContent element, z.localization.Localizer.get_text valueAccessor() + +# Knockout binding to localize element html content. +ko.bindingHandlers.l10n_html = + update: (element, valueAccessor) -> + ko.utils.setHtml element, z.localization.Localizer.get_text valueAccessor() + +# Knockout binding to localize element tooltips. +ko.bindingHandlers.l10n_tooltip = + update: (element, valueAccessor) -> + element.setAttribute 'title', z.localization.Localizer.get_text valueAccessor() + + +# Knockout binding to localize element tooltips. +ko.bindingHandlers.l10n_aria_label = + update: (element, valueAccessor) -> + element.setAttribute 'aria-label', z.localization.Localizer.get_text valueAccessor() diff --git a/app/script/localization/strings-de.coffee b/app/script/localization/strings-de.coffee new file mode 100755 index 00000000000..b7c42368bf8 --- /dev/null +++ b/app/script/localization/strings-de.coffee @@ -0,0 +1,566 @@ +# +# 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/. +# + +#General terms +z.string.de.wire = 'Wire' +z.string.de.wire_osx = 'Wire für OS X' +z.string.de.wire_windows = 'Wire für Windows' +z.string.de.truncation = '…' +z.string.de.nonexistent_user = 'Gelöschte Person' +z.string.de.and = 'und' + +# About screen +z.string.de.about_copyright = '© Wire Swiss GmbH • Version' +z.string.de.about_legal = 'Datenschutz u. AGB' +z.string.de.about_privacy = 'Privatsphäre' +z.string.de.about_wire = 'wire.com' + +# Alert view when trying to set a profile image that's too small +z.string.de.alert_upload_file_format = 'Das Bild kann nicht verwendet werden. Bitte wähle eine PNG- oder JPEG-Datei.' +z.string.de.alert_upload_too_small = 'Das Bild kann nicht verwendet werden. Bitte wähle ein Bild mit mindestens 320 x 320 Pixeln.' +z.string.de.alert_upload_too_large = 'Das Bild ist zu groß. Du kannst Dateien bis zu %no MB hochladen.' +z.string.de.alert_gif_too_large = 'Das GIF ist zu groß. Die maximale Größe beträgt %no MB.' + +# Auth +# Authentication: ACCOUNT section +z.string.de.auth_account_country_code = 'Landesvorwahl' +z.string.de.auth_account_create = 'Erstellen' +z.string.de.auth_account_create_account = 'Konto erstellen' +z.string.de.auth_account_expiration = 'Du wurdest abgemeldet, da deine Session abgelaufen ist. Bitte logge dich erneut ein.' +z.string.de.auth_account_get_wire = 'Ein moderner und sicherer Messenger für Unterhaltungen, Anrufe, Bilder, Musik, Videos, GIFs und mehr.' +z.string.de.auth_account_password_forgot = 'Passwort vergessen' +z.string.de.auth_account_remember_me = 'Angemeldet bleiben' +z.string.de.auth_account_sign_in = 'Login' +z.string.de.auth_account_sign_in_email = 'E-Mail' +z.string.de.auth_account_sign_in_phone = 'Handy' +z.string.de.auth_account_terms_of_use = 'Nutzungsbedingungen' +z.string.de.auth_account_terms_of_use_detail = 'Ich akzeptiere die' + +# Authentication: VERIFY section +z.string.de.auth_verify_code_description = 'Gib den Code ein, den wir an \n%@number gesendet haben.' +z.string.de.auth_verify_code_resend = 'Keinen Code bekommen?' +z.string.de.auth_verify_code_resend_detail = 'Erneut senden' +z.string.de.auth_verify_code_resend_timer = 'Du kannst %expiration einen neuen Code anfordern.' +z.string.de.auth_verify_code_change_phone = 'Telefonnummer ändern' +z.string.de.auth_verify_email_button = 'Hinzufügen' +z.string.de.auth_verify_email_detail = 'Um Wire auf mehreren Geräten benutzen zu können, werden eine E-Mail-Adresse und Passwort benötigt.' +z.string.de.auth_verify_email_headline = 'Hallo, %name.' + +# Authentication: limit section +z.string.de.auth_limit_devices_headline = 'Geräte' +z.string.de.auth_limit_description = 'Entferne eines deiner anderen Geräte, um Wire hier zu nutzen.' +z.string.de.auth_limit_button_manage = 'Geräte verwalten' +z.string.de.auth_limit_button_sign_out = 'Abmelden' +z.string.de.auth_limit_devices_current = '(Aktuelles Gerät)' + +# Authentication: limit section +z.string.de.auth_history_headline = 'Du benutzt Wire zum ersten Mal auf diesem Gerät.' +z.string.de.auth_history_description = 'Aus Datenschutzgründen wird dein bisheriger Gesprächsverlauf nicht angezeigt.' +z.string.de.auth_history_button = 'Verstanden' + +# Authentication: POSTED section +z.string.de.auth_posted_change_email = 'E-Mail-Adresse ändern' +z.string.de.auth_posted_offline_detail = 'Überprüfe deine Internetverbindung und versuche es erneut.' +z.string.de.auth_posted_offline_headline = 'Wire ist online schöner.' +z.string.de.auth_posted_pending_detail = 'Schaue in deinen Posteingang oder sende die E-Mail zur Aktivierung erneut.' +z.string.de.auth_posted_pending_headline = 'Konto bereits erstellt' +z.string.de.auth_posted_resend = 'Erneut an %email senden' +z.string.de.auth_posted_resend_action = 'E-Mail nicht erhalten?' +z.string.de.auth_posted_resend_detail = 'Schaue in deinen Posteingang und folge den Anweisungen.' +z.string.de.auth_posted_resend_headline = 'Du hast Post.' +z.string.de.auth_posted_retry = 'E-Mail erneut an %email senden' +z.string.de.auth_posted_retry_action = 'Erneut versuchen?' +z.string.de.auth_posted_retry_detail = 'Versuche es erneut.' +z.string.de.auth_posted_retry_headline = 'Ein Fehler ist aufgetreten' +z.string.de.auth_posted_verify_later = 'Später verifizieren' + +#Authentication: Misc +z.string.de.auth_placeholder_email = 'E-Mail-Adresse' +z.string.de.auth_placeholder_name = 'Name' +z.string.de.auth_placeholder_password_put = 'Passwort' +z.string.de.auth_placeholder_password_set = 'Passwort (min. acht Zeichen)' +z.string.de.auth_placeholder_phone = 'Telefonnummer' +z.string.de.auth_hello = 'Hallo, %name.' + +# Authentication: Validation errors +z.string.de.auth_error_code = 'Ungültiger Verifizierungs-Code' +z.string.de.auth_error_country_code_invalid = 'Ungültige Landesvorwahl' +z.string.de.auth_error_email_exists = 'E-Mail-Adresse bereits vergeben' +z.string.de.auth_error_email_forbidden = 'Es tut uns leid. Diese E-Mail-Adresse ist verboten.' +z.string.de.auth_error_email_malformed = 'Bitte gib eine gültige E-Mail-Adresse ein.' +z.string.de.auth_error_email_missing = 'Bitte gib eine E-Mail-Adresse ein.' +z.string.de.auth_error_misc = 'Probleme mit der Verbindung. Versuche es erneut.' +z.string.de.auth_error_name_long = 'Gib einen kürzeren Namen ein' +z.string.de.auth_error_name_short = 'Gib deinen Namen mit mindestens zwei Zeichen ein' +z.string.de.auth_error_offline = 'Keine Internetverbindung' +z.string.de.auth_error_password_long = 'Das eingegebene Passwort ist zu lang' +z.string.de.auth_error_password_short = 'Wähle ein Passwort mit mindestens acht Zeichen.' +z.string.de.auth_error_password_wrong = 'Falsches Passwort. Bitte versuche es erneut.' +z.string.de.auth_error_phone_number_invalid = 'Ungültige Telefonnummer' +z.string.de.auth_error_phone_number_unknown = 'Unbekannte Telefonnummer' +z.string.de.auth_error_sign_in = 'Überprüfe deine Eingaben und versuche es erneut.' +z.string.de.auth_error_terms_of_use = 'Bitte akzeptiere die Nutzungsbedingungen.' + +# Call stuff +z.string.de.call_state_outgoing = 'Klingelt…' +z.string.de.call_state_connecting = 'Verbinde…' +z.string.de.call_state_incoming = 'Klingelt…' +z.string.de.call_decline = 'Ablehnen' +z.string.de.call_accept = 'Annehmen' +z.string.de.call_join = 'Beitreten' +z.string.de.call_choose_shared_screen = 'Wähle eine Bildschirm aus' + +# Calling Bar +z.string.de.call_banner_connecting = 'Verbinde…' +z.string.de.call_banner_in = 'in' +z.string.de.call_banner_incoming_1to1 = '%s.first_name ruft an' +z.string.de.call_banner_incoming_video_1to1 = '%s.first_name ruft mit Video an' +z.string.de.call_banner_incoming_group = '%s.first_name ruft %@.group an' +z.string.de.call_banner_join = 'Anruf annehmen' +z.string.de.call_banner_ongoing = 'Aktiver Anruf' +z.string.de.call_banner_outgoing = 'Rufe %@.first_name an' + +# Warnings +z.string.de.modal_button_cancel = 'Abbrechen' +z.string.de.modal_button_ok = 'Ok' + +# Block a user +z.string.de.modal_block_conversation_headline = '%@.name blockieren?' +z.string.de.modal_block_conversation_message = '%@.name wird dich auf Wire nicht finden können.' +z.string.de.modal_block_conversation_button = 'Blockieren' +# Cannot create the call because there is nobody to call (conversation_empty) +z.string.de.modal_call_conversation_empty_headline = 'Niemand um anzurufen' +z.string.de.modal_call_conversation_empty_message = 'Es ist niemand mehr in der Unterhaltung.' +# Cannot create the call because there are too many participants (conversation_full) +z.string.de.modal_call_conversation_full_headline = 'Zu viele Teilnehmer' +z.string.de.modal_call_conversation_full_message = 'Anrufe sind nur in Unterhaltungen mit bis zu %no Teilnehmern möglich.' +# Cannot video call in group conversations +z.string.de.modal_call_no_video_in_group_headline = 'Keine Videoanrufe in Gruppen' +z.string.de.modal_call_no_video_in_group_message = 'Videoanrufe sind in Gruppen nicht verfügbar.' +# Second incoming call +z.string.de.modal_call_second_incoming_headline = 'Anruf annehmen?' +z.string.de.modal_call_second_incoming_message = 'Dein aktueller Anruf wird beendet.' +z.string.de.modal_call_second_incoming_action = 'Annehmen' +# Second outgoing call +z.string.de.modal_call_second_outgoing_headline = 'Aktuellen Anruf beenden?' +z.string.de.modal_call_second_outgoing_message = 'Nur ein zeitgleicher Anruf möglich.' +z.string.de.modal_call_second_outgoing_action = 'Beenden' +# Cannot join the call because there are too many participants (voice_channel_full) +z.string.de.modal_call_voice_channel_full_headline = 'Volles Haus' +z.string.de.modal_call_voice_channel_full_message = 'Die maximale Teilnehmeranzahl beträgt %no Personen.' +# Clear a conversation +z.string.de.modal_clear_conversation_headline = 'Unterhaltungsverlauf "%@.name" löschen?' +z.string.de.modal_clear_conversation_message = 'Der Verlauf wird geleert und die Unterhaltung aus der Liste entfernt.' +z.string.de.modal_clear_conversation_option = 'Unterhaltung auch verlassen' +z.string.de.modal_clear_conversation_button = 'Löschen' +# Connected device +z.string.de.modal_connected_device_headline = 'Dein Benutzerkonto wurde verwendet:' +z.string.de.modal_connected_device_from = 'Mit:' +z.string.de.modal_connected_device_message = 'Falls du dieses Gerät nicht hinzugefügt hast, entferne es und setze dein Passwort zurück.' +z.string.de.modal_connected_device_manage_devices = 'Geräte verwalten' +# Delete message +z.string.de.modal_delete_button = 'Löschen' +z.string.de.modal_delete_headline = 'Nachricht löschen' +z.string.de.modal_delete_message = 'Die Nachricht wird nur auf deiner Seite der Unterhaltung gelöscht. Dies kann nicht rückgängig gemacht werden.' +# Too long message +z.string.de.modal_too_long_headline = 'Nachricht zu lang' +z.string.de.modal_too_long_message = 'Du kannst Nachrichten mit bis zu %no Zeichen senden.' +# Leave a conversation +z.string.de.modal_leave_conversation_headline = 'Unterhaltung "%@.name" verlassen?' +z.string.de.modal_leave_conversation_message = 'Die Personen werden benachrichtigt und die Unterhaltung aus deiner Liste entfernt.' +z.string.de.modal_leave_conversation_button = 'Verlassen' +# Logout +z.string.de.modal_logout_headline = 'Daten löschen?' +z.string.de.modal_logout_message = 'Dies entfernt alle deine persönlichen Informationen und Unterhaltungen von diesem Gerät.' +z.string.de.modal_logout_button = 'Abmelden' +# New device +z.string.de.modal_new_device_headline = '"%@.name" hat begonnen ein neues Gerät zu nutzen' +z.string.de.modal_new_device_message = 'Möchtest du die Nachrichten noch senden?' +z.string.de.modal_new_device_show_device = 'Gerät anzeigen' +z.string.de.modal_new_device_send_anyway = 'Dennoch senden' +# Session Reset +z.string.de.modal_session_reset_headline = 'Die Session wurde zurückgesetzt' +z.string.de.modal_session_reset_message_1 = 'Wenn das Problem weiterhin besteht,' +z.string.de.modal_session_reset_message_link = 'kontaktiere' +z.string.de.modal_session_reset_message_2 = 'uns.' +# Too many members in conversation +z.string.de.modal_too_many_members_headline = 'Volles Haus' +z.string.de.modal_too_many_members_message = 'An einer Unterhaltung für eine Gruppe können bis zu %max Personen teilnehmen. Hier ist noch Platz für %no Personen.' +# Whitelist screensharing +z.string.de.modal_whitelist_screensharing_headline = 'Whitelist us for screen sharing' +z.string.de.modal_whitelist_screensharing_message_1 = 'Until Firefox adds us to the list, manually add the current domain on "about:config" to "media.getusermedia.screensharing.allowed_domains". Follow the' +z.string.de.modal_whitelist_screensharing_message_link = 'FAQ' +z.string.de.modal_whitelist_screensharing_message_2 = 'that guides you through the process.' +# Parallel uploads +z.string.de.modal_uploads_parallel = 'Du kannst bis zu %no Dateien auf einmal senden.' + +# Connection requests +z.string.de.connection_request_connect = 'Kontakt hinzufügen' +z.string.de.connection_request_ignore = 'Ignorieren' +z.string.de.connection_request_message = 'Hallo %@.first_name,\nbitte füge mich als Kontakt hinzu.\n%s.first_name' + +# Conversation +z.string.de.conversation_you_nominative = 'du' +z.string.de.conversation_you_dative = 'dir' +z.string.de.conversation_you_accusative = 'dich' + +z.string.de.conversation_connection_accepted = 'Hinzugefügt' +z.string.de.conversation_connection_cancel_request = 'Kontaktanfrage abbrechen' +z.string.de.conversation_connection_blocked = 'Blockiert' +z.string.de.conversation_connection_pending = 'Ausstehend' +z.string.de.conversation_create = ' hat eine Unterhaltung mit %@names begonnen' +z.string.de.conversation_create_you = ' hast eine Unterhaltung mit %@names begonnen' +z.string.de.conversation_device_started_using = ' hat begonnen' +z.string.de.conversation_device_started_using_you = ' hast begonnen' +z.string.de.conversation_device_unverified = ' hast die Überprüfung von einem Gerät widerrufen für' +z.string.de.conversation_device_your_devices = ' deine Geräte' +z.string.de.conversation_device_user_devices = ' %@names Geräte' +z.string.de.conversation_device_a_new_device = ' ein neues Gerät' +z.string.de.conversation_device_this_device = ' dieses Gerät' +z.string.de.conversation_just_now = 'Gerade eben' +z.string.de.conversation_location_link = 'Zeige Standort' +z.string.de.conversation_member_join = ' hat %@names hinzugefügt' +z.string.de.conversation_member_join_you = ' hast %@names hinzugefügt' +z.string.de.conversation_member_leave_left = ' hat die Unterhaltung verlassen' +z.string.de.conversation_member_leave_left_you = ' hast die Unterhaltung verlassen' +z.string.de.conversation_member_leave_removed = ' hat %@names entfernt' +z.string.de.conversation_member_leave_removed_you = ' hast %@names entfernt' +z.string.de.conversation_rename = ' hat die Unterhaltung umbenannt' +z.string.de.conversation_rename_you = ' hast die Unterhaltung umbenannt' +z.string.de.conversation_resume = 'Beginne eine Unterhaltung mit %@names' +z.string.de.conversation_ping = ' hat gepingt' +z.string.de.conversation_ping_you = ' hast gepingt' +z.string.de.conversation_today = 'Heute' +z.string.de.conversation_verified = 'Überprüft' +z.string.de.conversation_voice_channel_deactivate = ' hat versucht anzurufen' +z.string.de.conversation_voice_channel_deactivate_you = ' hast versucht anzurufen' +z.string.de.conversation_yesterday = 'Gestern' +z.string.de.conversation_unable_to_decrypt_1 = 'eine Nachricht von %@name wurde nicht empfangen.' +z.string.de.conversation_unable_to_decrypt_2 = '%@names Geräte-Identität hat sich geändert. Nachricht kann nicht entschlüsselt werden.' +z.string.de.conversation_unable_to_decrypt_link = 'Warum?' +z.string.de.conversation_unable_to_decrypt_error_message = 'Fehler' +z.string.de.conversation_unable_to_decrypt_reset_session = 'Session zurücksetzen' +z.string.de.conversation_asset_uploading = 'Hochladen…' +z.string.de.conversation_asset_downloading = 'Herunterladen…' +z.string.de.conversation_asset_upload_failed = 'Hochladen fehlgeschlagen' +z.string.de.conversation_asset_upload_too_large = 'Du kannst Dateien bis zu %no senden.' +z.string.de.conversation_playback_error = 'Konnte nicht abgespielt werden' + +# Conversation list +z.string.de.conversation_list_archive = 'Archiv' +z.string.de.conversation_list_empty_conversation = 'Leere Unterhaltung' +z.string.de.conversation_list_many_connection_request = '%no Kontaktanfragen' +z.string.de.conversation_list_one_connection_request = 'Eine Kontaktanfrage' +z.string.de.conversation_list_search_header_placeholder = 'Unterhaltung beginnen' +z.string.de.conversation_list_popover_archive = 'Archivieren' +z.string.de.conversation_list_popover_block = 'Blockieren' +z.string.de.conversation_list_popover_cancel = 'Anfrage abbrechen' +z.string.de.conversation_list_popover_clear = 'Löschen' +z.string.de.conversation_list_popover_leave = 'Verlassen' +z.string.de.conversation_list_popover_notify = 'Benachrichtigen' +z.string.de.conversation_list_popover_silence = 'Stummschalten' +z.string.de.conversation_list_popover_unarchive = 'Dearchivieren' + +# Invites +z.string.de.invite_meta_key_mac = 'Cmd' +z.string.de.invite_meta_key_pc = 'Strg' +z.string.de.invite_hint_selected = 'Zum Kopieren %meta_key + C drücken' +z.string.de.invite_hint_unselected = 'Markieren und %meta_key + C drücken' +z.string.de.invite_detail = 'Der Link gilt für zwei Wochen.' +z.string.de.invite_headline = 'Lade Freunde zu Wire ein' +z.string.de.invite_message = 'Füge mich auf Wire als Kontakt hinzu.\nModerne, private Kommunikation. %url' + +# Extensions +z.string.de.extensions_bubble_button_gif = 'Gif' + +# Extensions Giphy +z.string.de.extensions_giphy_button_ok = 'Senden' +z.string.de.extensions_giphy_button_more = 'Neues Gif' +z.string.de.extensions_giphy_message = '%tag • über giphy.com' +z.string.de.extensions_giphy_no_gifs = 'Ups, kein GIF' +z.string.de.extensions_giphy_random = 'Zufällig' + +# People View +z.string.de.search_open = 'Öffnen' +z.string.de.search_open_group = 'Unterhaltung erstellen' +z.string.de.people_confirm_label = 'Zur Unterhaltung hinzufügen' +z.string.de.people_common_contacts = 'Ihr beide kennt' +z.string.de.people_people = '%no Personen' +z.string.de.people_search_placeholder = 'Nach Namen suchen' +z.string.de.people_everyone_participates = 'Alle deine Kontakte\nsind bereits in\ndieser Unterhaltung.' +z.string.de.people_no_matches = 'Kein passendes Ergebnis.\nSuche nach einen\nanderen Namen.' +z.string.de.people_invite = 'Freunde einladen' +z.string.de.people_share = 'Teile deine Kontakte' +z.string.de.people_bring_your_friends = 'Hole deine Freunde zu Wire' +z.string.de.people_invite_detail = 'Wir verwenden deine Kontaktdaten, um Kontakte für dich zu erstellen. Wir anonymisieren alle Informationen und geben sie nicht weiter.' +z.string.de.people_invite_button_contacts = 'Aus Kontakte' +z.string.de.people_invite_button_gmail = 'Aus Gmail' +z.string.de.people_invite_headline = 'Hole deine Freunde' +z.string.de.people_tabs_details = 'Details' +z.string.de.people_tabs_devices = 'Geräte' +z.string.de.people_tabs_devices_headline = 'Wire gibt jedem Gerät einen einzigartigen Fingerabdruck. Vergleiche diese mit %@.name und überprüfe deine Unterhaltung.' +z.string.de.people_tabs_devices_why_verify = 'Warum sollte ich meine Unterhaltungen verifizieren?' +z.string.de.people_tabs_no_devices_headline = '%@.name benutzt eine ältere Version von Wire. Es werden keine Geräte angezeigt.' +z.string.de.people_tabs_device_detail_all_my_devices = 'Alle meine Geräte anzeigen' +z.string.de.people_tabs_device_detail_device_fingerprint = 'Fingerabdruck des Geräts' +z.string.de.people_tabs_device_detail_headline = 'Überprüfe, ob dieser Fingerabdruck mit dem auf %bold%@.names Gerät%end übereinstimmt.' +z.string.de.people_tabs_device_detail_how_to = 'Wie mache ich das?' +z.string.de.people_tabs_device_detail_reset_session = 'Session zurücksetzen' +z.string.de.people_tabs_device_detail_show_my_device = 'Zeige meinen Fingerabdruck' +z.string.de.people_tabs_device_detail_verified = 'Ok' + +# Add people to conversation share history dialogue +z.string.de.people_add_to_headline = 'Kontakte hinzufügen und Verlauf teilen?' +z.string.de.people_add_to_message = 'Neu hinzugefügte Kontakte können den bisherigen Verlauf sehen. Erstelle eine neue Unterhaltung, um dies zu vermeiden.' + +# Block user +z.string.de.people_block_headline = 'Blockieren?' +z.string.de.people_block_message = '%@.first_name wird dich auf Wire nicht finden können.' + +# Accept a pending connection dialogue +z.string.de.people_connect_headline = 'Annehmen?' +z.string.de.people_connect_message = '%@.first_name wird zu deinen Kontakten hinzugefügt und die Unterhaltung mit ihm geöffnet.' + +# Cancel a pending request +z.string.de.people_cancel_request_headline = 'Kontaktanfrage abbrechen?' +z.string.de.people_cancel_request_message = 'Ziehe die Kontaktanfrage an %@.first_name zurück.' + +# Leave the conversation dialogue +z.string.de.people_leave_headline = 'Unterhaltung verlassen?' +z.string.de.people_leave_message = 'Du wirst keine Nachrichten in dieser Unterhaltung senden oder empfangen können.' + +# Remove from conversation dialogue +z.string.de.people_remove_headline = 'Entfernen?' +z.string.de.people_remove_message = '%@.first_name wird in dieser Unterhaltung keine Nachrichten schicken oder empfangen können.' + +# Unblock user +z.string.de.people_unblock_headline = 'Freigeben?' +z.string.de.people_unblock_message = '%@.first_name wird dich über Wire wieder finden und kontaktieren können. Du empfängst alle Nachrichten die verschickt wurden, während der Kontakt blockiert war.' + +# Button labels for the actions +z.string.de.people_button_add = 'Kontakte hinzufügen' +z.string.de.people_button_block = 'Blockieren' +z.string.de.people_button_cancel = 'Abbrechen' +z.string.de.people_button_connect = 'Hinzufügen' +z.string.de.people_button_continue = 'Weiter' +z.string.de.people_button_create = 'Unterhaltung erstellen' +z.string.de.people_button_ignore = 'Ignorieren' +z.string.de.people_button_leave = 'Verlassen' +z.string.de.people_button_open = 'Unterhaltung öffnen' +z.string.de.people_button_pending = 'Ausstehend' +z.string.de.people_button_profile = 'Profil' +z.string.de.people_button_remove = 'Entfernen' +z.string.de.people_button_unblock = 'Freigeben' +z.string.de.people_button_no = 'Nein' +z.string.de.people_button_yes = 'Ja' + +# Preferences +z.string.de.preferences_headline = 'Einstellungen' +z.string.de.preferences_account = 'Benutzerkonto' +z.string.de.preferences_password = 'Passwort' +z.string.de.preferences_password_reset = 'Passwort zurücksetzen' +z.string.de.preferences_delete = 'Benutzerkonto löschen' +z.string.de.preferences_email_sent = 'E-Mail gesendet' +z.string.de.preferences_delete_info = 'Wir sende eine E-Mail an %email. Klicke den Link, um dein Benutzerkonto dauerhaft zu löschen.' + +z.string.de.preferences_contacts = 'Kontakte' +z.string.de.preferences_share_contacts = 'Aus Gmail importieren' +z.string.de.preferences_share_osx_contacts = 'Aus Kontakte importieren' +z.string.de.preferences_contacts_detail = 'Wir verwenden deine Daten, um Kontakte für dich zu erstellen. Wir anonymisieren alle Informationen und teilen sie mit niemand anderem.' +z.string.de.preferences_data = 'Daten senden' +z.string.de.preferences_data_checkbox = 'Senden von anonymen Nutzungsdaten' +z.string.de.preferences_data_detail = 'Sende uns Fehler- und Nutzungsberichte. Hilf uns Wire durch das Senden anonymisierter Informationen zu verbessern.' +z.string.de.preferences_sound = 'Benachrichtigungen' +z.string.de.preferences_sound_all = 'Alle' +z.string.de.preferences_sound_all_hint = 'Alle Sounds' +z.string.de.preferences_sound_none = 'Keine' +z.string.de.preferences_sound_none_hint = 'Pssst!' +z.string.de.preferences_sound_some = 'Einige' +z.string.de.preferences_sound_some_hint = 'Pings und Anrufe' +z.string.de.preferences_devices = 'Geräte' +z.string.de.preferences_device_remove = 'Gerät entfernen' +z.string.de.preferences_device_remove_detail = 'Entferne dieses Gerät, wenn du es nicht mehr benutzt.' +z.string.de.preferences_device_button_remove = 'Entfernen' +z.string.de.preferences_device_button_cancel = 'Abbrechen' +z.string.de.preferences_device_activated = 'Aktiviert in ' +z.string.de.preferences_device_id = 'ID: ' +z.string.de.preferences_device_reset_session_description = 'Falls die Fingerabdrücke nicht übereinstimmen, setze die Session zurück um sicherzustellen, dass die richtigen Fingerabdrücke auf allen Geräten angezeigt werden.' +z.string.de.preferences_device_reset_session_button = 'Session zurücksetzen' +z.string.de.preferences_device_fingerprint_label = 'Schlüssel-Fingerabdruck' +z.string.de.preferences_device_fingerprint_message = 'Wire gibt jedem Gerät einen einzigartigen Fingerabdruck. Vergleiche die Fingerabdrücke zur Verifizierung deiner Geräte und Unterhaltungen.' + +# Profile +z.string.de.profile_about = 'Über' +z.string.de.profile_preferences = 'Einstellungen' +z.string.de.profile_sign_out = 'Abmelden' +z.string.de.profile_support = 'Hilfe' +z.string.de.profile_username_placeholder = 'Dein vollständiger Name' + +# Search +z.string.de.search_group_hint = 'Tippe weiter oder wähle weitere Kontakte aus, um eine Gruppe zu erstellen' +z.string.de.search_connect = 'Vorschläge' +z.string.de.search_connections = 'Kontakte' +z.string.de.search_groups = 'Gruppen' +z.string.de.search_placeholder = 'Namen oder E-Mail-Adresse suchen' +z.string.de.search_top_people = 'Top Kontakte' +z.string.de.search_try_search = 'Finde Kontakte nach Namen oder\nihrer vollständigen E-Mail-Adresse' +z.string.de.search_no_contacts_on_wire = 'Du hast keine Kontakte auf Wire.\nSuch nach Namen oder vollständigen\nE-Mail-Adressen' +z.string.de.search_others = 'Suchergebnisse' +z.string.de.search_suggestion_one = 'Kennt %@.first_name' +z.string.de.search_suggestion_two = 'Kennt %@.first_name und %@.other_name' +z.string.de.search_suggestion_many = 'Kennt %@.first_name und %no weitere' + +# Picture upload +z.string.de.upload_welcome = 'Wire ist so viel schöner mit einem Bild.' +z.string.de.upload_welcome_keep = 'Behalte dieses' +z.string.de.upload_welcome_choose = 'Wähle dein eigenes' + +# Google contacts upload +z.string.de.upload_google_headline = 'Finde Kontakte \nauf Wire.' +z.string.de.upload_google_message = 'Wir verwenden deine Daten, um Kontakte für dich zu erstellen. Wir anonymisieren alle Informationen und teilen sie mit niemand anderem.' +z.string.de.upload_google_headline_error = 'Ein Fehler ist aufgetreten.' +z.string.de.upload_google_message_error = 'Wir haben die Informationen nicht erhalten. Bitte importiere deine Kontakte erneut.' +z.string.de.upload_google_button_again = 'Erneut versuchen' + +# URLs +z.string.de.url_password_reset = 'https://wire.com/forgot/?hl=de' +z.string.de.url_legal = 'https://wire.com/legal/?hl=de' +z.string.de.url_privacy = 'https://wire.com/privacy/?hl=de' +z.string.de.url_privacy_why = 'https://wire.com/privacy/why/' +z.string.de.url_support = 'https://support.wire.com/hc/de' +z.string.de.url_terms_of_use = 'https://wire.com/legal/terms/' +z.string.de.url_wire = 'https://wire.com/?hl=de' +z.string.de.url_wire_for_web = 'https://app.wire.com' +z.string.de.url_support_calling = 'https://support.wire.com/hc/de/articles/202969412' +z.string.de.url_support_camera_access_denied = 'https://support.wire.com/hc/de/articles/202935412' +z.string.de.url_support_contact_bug = 'https://support.wire.com/hc/de/requests/new?ticket_form_id=101615' +z.string.de.url_support_history = 'https://support.wire.com/hc/de/articles/207834645' +z.string.de.url_support_mic_access_denied = 'https://support.wire.com/hc/de/articles/202590081' +z.string.de.url_support_mic_not_found = 'https://support.wire.com/hc/de/articles/202970662' +z.string.de.url_support_screen_access_denied = 'https://support.wire.com/hc/de/articles/202935412' +z.string.de.url_support_screen_whitelist = 'https://support.wire.com/hc/de/articles/202935412' +z.string.de.url_support_session = 'https://support.wire.com/hc/de' +z.string.de.url_downloads = 'https://wire.com/download/?hl=de' +z.string.de.url_app_store = 'https://wire.com/download/osx/' +z.string.de.url_decrypt_error_1 = 'https://wire.com/privacy/error-1/?hl=de' +z.string.de.url_decrypt_error_2 = 'https://wire.com/privacy/error-2/?hl=de' + +# Warnings: Permission requests & permission callbacks +z.string.de.warning_call_detail = 'Dein Browser benötigt für Anrufe Zugriff auf das Mikrofon.' +z.string.de.warning_call_headline = 'Anrufe sind ohne Mikrofon nicht möglich' +z.string.de.warning_call_unsupported_incoming = '%s.first_name ruft an. Dein Browser unterstützt keine Anrufe.' +z.string.de.warning_call_unsupported_outgoing = 'Du kannst nicht anrufen, da dein Browser keine Anfrufe unterstützt.' +z.string.de.warning_call_issues = 'Diese Version von Wire kann nicht an Anrufen teilnehmen. Nutze' +z.string.de.warning_call_upgrade_browser = 'Für Anrufe aktualisiere Google Chrome.' +z.string.de.warning_learn_more = 'Erfahre mehr' +z.string.de.warning_not_found_camera = 'Du kannst nicht anrufen, da dein Computer keine Kamera hat.' +z.string.de.warning_not_found_microphone = 'Du kannst nicht anrufen, da dein Computer kein Mikrofon hat.' +z.string.de.warning_permission_denied_camera = 'Du kannst nicht anrufen, da dein Browser keinen Zugriff auf die Kamera hat.' +z.string.de.warning_permission_denied_microphone = 'Du kannst nicht anrufen, da dein Browser keinen Zugriff auf das Mikrofon hat.' +z.string.de.warning_permission_denied_screen = 'Du kannst deinen Bildschirm nicht teilen, da dein Browser keinen Zugriff auf den Bildschirm hat.' +z.string.de.warning_permission_request_camera = '%icon Zugriff auf Kamera gewähren' +z.string.de.warning_permission_request_microphone = '%icon Zugriff auf Mikrofon gewähren' +z.string.de.warning_permission_request_notification = '%icon Benachrichtigungen zulassen' +z.string.de.warning_permission_request_screen = '%icon Zugriff auf Bildschirm gewähren' +z.string.de.warning_tell_me_how = 'Zeig mir wie' + +# Warnings: Connectivity +z.string.de.warning_connectivity_connection_lost = 'Verbindung wird wiederhergestellt. Wire kann Nachrichten möglicherweise nicht empfangen.' +z.string.de.warning_connectivity_no_internet = 'Keine Internetverbindung. Du kannst keine Nachrichten senden und empfangen.' + +# Warnings: App banner +z.string.de.warning_app_banner_win = 'Wire ist für Windows verfügbar.' +z.string.de.warning_app_banner_osx = 'Wire ist für OS X verfügbar.' +z.string.de.warning_app_banner_link = 'App herunterladen' + +# Browser notifications +z.string.de.system_notification_asset_add = 'Hat ein Bild geteilt' +z.string.de.system_notification_connection_accepted = 'Hat deine Kontaktanfrage akzeptiert' +z.string.de.system_notification_connection_request = 'Möchte dich als Kontakt hinzufügen' +z.string.de.system_notification_conversation_create = '%s.first_name hat eine Unterhaltung begonnen' +z.string.de.system_notification_conversation_rename = '%s.first_name hat die Unterhaltung in %name umbenannt' +z.string.de.system_notification_member_join_many = '%s.first_name hat %no Kontakte zur Unterhaltung hinzugefügt' +z.string.de.system_notification_member_join_one = '%s.first_name hat %@.first_name zur Unterhaltung hinzugefügt' +z.string.de.system_notification_member_leave_left = '%s.first_name hat die Unterhaltung verlassen' +z.string.de.system_notification_member_leave_removed_many = '%s.first_name hat %no Kontakte aus der Unterhaltung entfernt' +z.string.de.system_notification_member_leave_removed_one = '%s.first_name hat %@.first_name aus der Unterhaltung entfernt' +z.string.de.system_notification_ping = 'Hat gepingt' +z.string.de.system_notification_voice_channel_activate = 'Ruft an' +z.string.de.system_notification_voice_channel_deactivate = 'Hat versucht anzurufen' +z.string.de.system_notification_shared_audio = 'Hat eine Audio-Nachricht geteilt' +z.string.de.system_notification_shared_video = 'Hat ein Video geteilt' +z.string.de.system_notification_shared_file = 'Hat eine Datei geteilt' + +# Tooltips +z.string.de.tooltip_call_banner_accept = 'Anruf annehmen' +z.string.de.tooltip_call_banner_ignore = 'Ignorieren (%shortcut)' +z.string.de.tooltip_call_banner_mute = 'Mikrofon stummschalten (%shortcut)' +z.string.de.tooltip_call_banner_unmute = 'Mikrofon anschalten (%shortcut)' + +z.string.de.tooltip_conversation_call = 'Anruf' +z.string.de.tooltip_conversation_video_call = 'Videoanruf' +z.string.de.tooltip_conversation_file = 'Datei senden' +z.string.de.tooltip_conversation_people = 'Unterhaltungsübersicht (%shortcut)' +z.string.de.tooltip_conversation_picture = 'Bild senden' +z.string.de.tooltip_conversation_ping = 'Ping (%shortcut)' +z.string.de.tooltip_conversation_all_verified = 'Alle Fingerabdrücke sind überprüft' +z.string.de.tooltip_conversation_input_placeholder = 'Schreibe eine Nachricht' + +z.string.de.tooltip_conversation_list_archive = 'Archivieren (%shortcut)' +z.string.de.tooltip_conversation_list_archived = 'Archiv anzeigen (%no)' +z.string.de.tooltip_conversation_list_more = 'Mehr' +z.string.de.tooltip_conversation_list_notify = 'Benachrichtigen (%shortcut)' +z.string.de.tooltip_conversation_list_silence = 'Stummschalten (%shortcut)' +z.string.de.tooltip_conversation_list_tooltip_start = 'Unterhaltung beginnen (%shortcut)' + +z.string.de.tooltip_people_add = 'Kontakte zur Unterhaltung hinzufügen (%shortcut)' +z.string.de.tooltip_people_back = 'Zurück' +z.string.de.tooltip_people_block = 'Blockieren' +z.string.de.tooltip_people_connect = 'Als Kontakt hinzufügen' +z.string.de.tooltip_people_leave = 'Unterhaltung verlassen' +z.string.de.tooltip_people_open = 'Unterhaltung öffnen' +z.string.de.tooltip_people_profile = 'Profil öffnen' +z.string.de.tooltip_people_rename = 'Unterhaltung umbenennen' +z.string.de.tooltip_people_remove = 'Aus Unterhaltung entfernen' +z.string.de.tooltip_people_unblock = 'Freigeben' + +z.string.de.tooltip_preferences_contacts = 'Logge dich in dein Gmail-Konto ein, um deine Kontakte zu teilen' +z.string.de.tooltip_preferences_password = 'Öffne eine andere Website, um dein Passwort zurückzusetzen' +z.string.de.tooltip_preferences_sound = 'Wähle, ob du über neue Nachrichten per Sound informiert wirst' + +z.string.de.tooltip_profile_picture = 'Ändere dein Bild…' +z.string.de.tooltip_profile_preferences = 'Einstellungen…' +z.string.de.tooltip_profile_rename = 'Ändere deinen Namen' + +z.string.de.tooltip_search_close = 'Schließen (Esc)' + +# App loading +z.string.de.init_received_access_token = 'Einloggen' +z.string.de.init_received_self_user = 'Hallo, %name.' +z.string.de.init_sessions_expectation = 'Wir müssen %sessions Sessions initialisieren' +z.string.de.init_sessions_expectation_long = 'Wir müssen %sessions Sessions initialisieren' +z.string.de.init_sessions_progress = 'Initialisiere Sessions - %progress von %total' +z.string.de.init_initialized_storage = 'Sessions geladen' +z.string.de.init_initialized_cryptography = 'Verschlüsselung initialisiert' +z.string.de.init_validated_client = 'Lade deine Kontakte und Unterhaltungen' +z.string.de.init_received_user_data = 'Suche nach neuen Events' +z.string.de.init_events_expectation = 'Du hast %events neue Events' +z.string.de.init_events_expectation_long = 'Lade %events neue Events' +z.string.de.init_events_progress = 'Lade Events - %progress von %total' +z.string.de.init_updated_from_notifications = 'Fast geschafft' +z.string.de.init_app_pre_loaded = 'Viel Spaß mit Wire' diff --git a/app/script/localization/strings-init.coffee b/app/script/localization/strings-init.coffee new file mode 100644 index 00000000000..4dbc41ad383 --- /dev/null +++ b/app/script/localization/strings-init.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.string ?= {} +z.string.de ?= {} + +z.string.Declension = + ACCUSATIVE: 'accusative' + DATIVE: 'dative' + NOMINATIVE: 'nominative' diff --git a/app/script/localization/strings.coffee b/app/script/localization/strings.coffee new file mode 100644 index 00000000000..c9c04745f48 --- /dev/null +++ b/app/script/localization/strings.coffee @@ -0,0 +1,566 @@ +# +# 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/. +# + +#General terms +z.string.wire = 'Wire' +z.string.wire_osx = 'Wire for OS X' +z.string.wire_windows = 'Wire for Windows' +z.string.truncation = '…' +z.string.nonexistent_user = 'Deleted User' +z.string.and = 'and' + +# About screen +z.string.about_copyright = '© Wire Swiss GmbH • Version' +z.string.about_legal = 'Legal' +z.string.about_privacy = 'Privacy' +z.string.about_wire = 'wire.com' + +# Alert view when trying to set a profile image that's too small +z.string.alert_upload_file_format = 'Can’t use this picture.\nPlease choose a PNG or JPEG file.' +z.string.alert_upload_too_small = 'Can’t use this picture.\nPlease choose a picture that’s at least 320 x 320 px.' +z.string.alert_upload_too_large = 'This picture is too large.\nYou can upload files up to %no MB.' +z.string.alert_gif_too_large = 'Animation is too large.\nMaximum size is %no MB.' + +# Auth +# Authentication: ACCOUNT section +z.string.auth_account_country_code = 'Country Code' +z.string.auth_account_create = 'Create' +z.string.auth_account_create_account = 'Create an account' +z.string.auth_account_expiration = 'You were signed out because your session expired. Please log in again.' +z.string.auth_account_get_wire = 'Simple, private & secure messenger for chat, calls, sharing pics, music, videos, GIFs and more.' +z.string.auth_account_password_forgot = 'Forgot password' +z.string.auth_account_remember_me = 'Remember me' +z.string.auth_account_sign_in = 'Log in' +z.string.auth_account_sign_in_email = 'Email' +z.string.auth_account_sign_in_phone = 'Phone' +z.string.auth_account_terms_of_use = 'Terms of Use' +z.string.auth_account_terms_of_use_detail = 'I accept' + +# Authentication: VERIFY section +z.string.auth_verify_code_description = 'Enter the verification code\nwe sent to %@number.' +z.string.auth_verify_code_resend = 'No code showing up?' +z.string.auth_verify_code_resend_detail = 'Resend' +z.string.auth_verify_code_resend_timer = 'You can request a new code %expiration.' +z.string.auth_verify_code_change_phone = 'Change phone number' +z.string.auth_verify_email_button = 'Add' +z.string.auth_verify_email_detail = 'Using Wire in multiple devices requires an email address and password.' +z.string.auth_verify_email_headline = 'Hello, %name.' + +# Authentication: limit section +z.string.auth_limit_devices_headline = 'Devices' +z.string.auth_limit_description = 'Remove one of your other devices to start using Wire on this one.' +z.string.auth_limit_button_manage = 'Manage devices' +z.string.auth_limit_button_sign_out = 'Log out' +z.string.auth_limit_devices_current = '(Current)' + +# Authentication: limit section +z.string.auth_history_headline = 'It’s the first time you’re using Wire on this device.' +z.string.auth_history_description = 'For privacy reasons, your conversation history will not appear here.' +z.string.auth_history_button = 'OK' + +# Authentication: POSTED section +z.string.auth_posted_change_email = 'Change email' +z.string.auth_posted_offline_detail = 'Check your Internet connection and try again.' +z.string.auth_posted_offline_headline = 'Wire is nicer online.' +z.string.auth_posted_pending_detail = 'Check your email inbox or resend activation.' +z.string.auth_posted_pending_headline = 'Account already pending' +z.string.auth_posted_resend = 'Resend to %email' +z.string.auth_posted_resend_action = 'No email showing up?' +z.string.auth_posted_resend_detail = 'Check your email inbox and follow the instructions.' +z.string.auth_posted_resend_headline = 'You’ve got mail.' +z.string.auth_posted_retry = 'Re-try and send mail to %email' +z.string.auth_posted_retry_action = 'Try again?' +z.string.auth_posted_retry_detail = 'Please try again.' +z.string.auth_posted_retry_headline = 'Something went wrong' +z.string.auth_posted_verify_later = 'Verify later' + +#Authentication: Misc +z.string.auth_placeholder_email = 'Email' +z.string.auth_placeholder_name = 'Name' +z.string.auth_placeholder_password_put = 'Password' +z.string.auth_placeholder_password_set = 'Password (at least 8 characters)' +z.string.auth_placeholder_phone = 'Phone Number' +z.string.auth_hello = 'Hello, %name.' + +# Authentication: Validation errors +z.string.auth_error_code = 'Invalid Code' +z.string.auth_error_country_code_invalid = 'Invalid Country Code' +z.string.auth_error_email_exists = 'Email address already taken' +z.string.auth_error_email_forbidden = 'Sorry. This email address is forbidden.' +z.string.auth_error_email_malformed = 'Please enter a valid email address.' +z.string.auth_error_email_missing = 'Please enter an email address.' +z.string.auth_error_misc = 'Problems with the connection. Please try again.' +z.string.auth_error_name_long = 'Please enter a shorter name' +z.string.auth_error_name_short = 'Enter a name with at least 2 characters' +z.string.auth_error_offline = 'No Internet connection' +z.string.auth_error_password_long = 'The password you entered is too long' +z.string.auth_error_password_short = 'Choose a password with at least 8 characters.' +z.string.auth_error_password_wrong = 'Wrong password. Please try again.' +z.string.auth_error_phone_number_invalid = 'Invalid Phone Number' +z.string.auth_error_phone_number_unknown = 'Unknown Phone Number' +z.string.auth_error_sign_in = 'Please verify your details and try again.' +z.string.auth_error_terms_of_use = 'Please accept Wire Terms of Use.' + +# Call stuff +z.string.call_state_outgoing = 'Ringing…' +z.string.call_state_connecting = 'Connecting…' +z.string.call_state_incoming = 'Calling…' +z.string.call_decline = 'Decline' +z.string.call_accept = 'Accept' +z.string.call_join = 'Join' +z.string.call_choose_shared_screen = 'Choose a screen to share' + +# Calling Bar +z.string.call_banner_connecting = 'Connecting…' +z.string.call_banner_in = 'in' +z.string.call_banner_incoming_1to1 = '%s.first_name calling' +z.string.call_banner_incoming_video_1to1 = '%s.first_name video calling' +z.string.call_banner_incoming_group = '%s.first_name calling %@.group' +z.string.call_banner_join = 'Join call' +z.string.call_banner_ongoing = 'Ongoing call' +z.string.call_banner_outgoing = 'Calling %@.first_name' + +# Warnings +z.string.modal_button_cancel = 'Cancel' +z.string.modal_button_ok = 'Ok' + +# Block a user +z.string.modal_block_conversation_headline = 'Block %@.name?' +z.string.modal_block_conversation_message = '%@.name wont be able to contact you or invite you to a group conversation.' +z.string.modal_block_conversation_button = 'Block' +# Cannot create the call because there is nobody to call (conversation_empty) +z.string.modal_call_conversation_empty_headline = 'No one to call' +z.string.modal_call_conversation_empty_message = 'There is no one left here.' +# Cannot create the call because there are too many participants (conversation_full) +z.string.modal_call_conversation_full_headline = 'Too many people to call' +z.string.modal_call_conversation_full_message = 'Calls work in conversations with up to %no people.' +# Cannot video call in group conversations +z.string.modal_call_no_video_in_group_headline = 'No video calls in groups' +z.string.modal_call_no_video_in_group_message = 'Video calls are not available in group conversations.' +# Second incoming call +z.string.modal_call_second_incoming_headline = 'Answer call?' +z.string.modal_call_second_incoming_message = 'Your current call will end.' +z.string.modal_call_second_incoming_action = 'Answer' +# Second outgoing call +z.string.modal_call_second_outgoing_headline = 'Hang up current call?' +z.string.modal_call_second_outgoing_message = 'You can only be in one call at a time.' +z.string.modal_call_second_outgoing_action = 'Hang Up' +# Cannot join the call because there are too many participants (voice_channel_full) +z.string.modal_call_voice_channel_full_headline = 'Full house' +z.string.modal_call_voice_channel_full_message = 'There’s only room for %no people in here.' +# Clear a conversation +z.string.modal_clear_conversation_headline = 'Delete "%@.name" content?' +z.string.modal_clear_conversation_message = 'This will clear the conversation history and remove it from your list.' +z.string.modal_clear_conversation_option = 'Also leave the conversation' +z.string.modal_clear_conversation_button = 'Delete' +# Connected device +z.string.modal_connected_device_headline = 'Your account was used on:' +z.string.modal_connected_device_from = 'From:' +z.string.modal_connected_device_message = 'If you didn’t do this, remove the device and reset your password.' +z.string.modal_connected_device_manage_devices = 'manage devices' +# Delete message +z.string.modal_delete_button = 'Delete' +z.string.modal_delete_headline = 'Delete message' +z.string.modal_delete_message = 'The message will only be removed from your view of the conversation. This cannot be undone.' +# Too long message +z.string.modal_too_long_headline = 'Message too long' +z.string.modal_too_long_message = 'You can send messages up to %no characters long.' +# Leave a conversation +z.string.modal_leave_conversation_headline = 'Leave "%@.name" conversation?' +z.string.modal_leave_conversation_message = 'The participants will be notified and the conversation removed from your list.' +z.string.modal_leave_conversation_button = 'Leave' +# Logout +z.string.modal_logout_headline = 'Clear Data?' +z.string.modal_logout_message = 'Delete all your personal information and conversations on this device.' +z.string.modal_logout_button = 'Log out' +# New device +z.string.modal_new_device_headline = '"%@.name" started using a new device' +z.string.modal_new_device_message = 'Do you still want to send your messages?' +z.string.modal_new_device_show_device = 'show device' +z.string.modal_new_device_send_anyway = 'send anyway' +# Session Reset +z.string.modal_session_reset_headline = 'The session has been reset' +z.string.modal_session_reset_message_1 = 'If the problem is not resolved,' +z.string.modal_session_reset_message_link = 'contact' +z.string.modal_session_reset_message_2 = 'us.' +# Too many members in conversation +z.string.modal_too_many_members_headline = 'Full house' +z.string.modal_too_many_members_message = 'Up to %max people can join a conversation. There is room for %no more people in here.' +# Whitelist screensharing +z.string.modal_whitelist_screensharing_headline = 'Whitelist us for screen sharing' +z.string.modal_whitelist_screensharing_message_1 = 'Until Firefox adds us to the list, manually add the current domain on "about:config" to "media.getusermedia.screensharing.allowed_domains". Follow the' +z.string.modal_whitelist_screensharing_message_link = 'FAQ' +z.string.modal_whitelist_screensharing_message_2 = 'that guides you through the process.' +# Parallel uploads +z.string.modal_uploads_parallel = 'You can send up to %no files at once.' + +# Connection requests +z.string.connection_request_connect = 'Connect' +z.string.connection_request_ignore = 'Ignore' +z.string.connection_request_message = 'Hi %@.first_name,\nLet’s connect on Wire.\n%s.first_name' + +# Conversation +z.string.conversation_you_nominative = 'you' +z.string.conversation_you_dative = 'you' +z.string.conversation_you_accusative = 'you' + +z.string.conversation_connection_accepted = 'Connected' +z.string.conversation_connection_cancel_request = 'Cancel connection request' +z.string.conversation_connection_blocked = 'Blocked' +z.string.conversation_connection_pending = 'Connecting' +z.string.conversation_create = ' started a conversation with %@names' +z.string.conversation_create_you = ' started a conversation with %@names' +z.string.conversation_device_started_using = ' started using' +z.string.conversation_device_started_using_you = ' started using' +z.string.conversation_device_unverified = ' unverified one of' +z.string.conversation_device_your_devices = ' your devices' +z.string.conversation_device_user_devices = ' %@name´s devices' +z.string.conversation_device_a_new_device = ' a new device' +z.string.conversation_device_this_device = ' this device' +z.string.conversation_just_now = 'Just now' +z.string.conversation_location_link = 'Open Map' +z.string.conversation_member_join = ' added %@names' +z.string.conversation_member_join_you = ' added %@names' +z.string.conversation_member_leave_left = ' left' +z.string.conversation_member_leave_left_you = ' left' +z.string.conversation_member_leave_removed = ' removed %@names' +z.string.conversation_member_leave_removed_you = ' removed %@names' +z.string.conversation_rename = ' renamed the conversation' +z.string.conversation_rename_you = ' renamed the conversation' +z.string.conversation_resume = 'Start a conversation with %@names' +z.string.conversation_ping = ' pinged' +z.string.conversation_ping_you = ' pinged' +z.string.conversation_today = 'today' +z.string.conversation_verified = 'Verified' +z.string.conversation_voice_channel_deactivate = ' called' +z.string.conversation_voice_channel_deactivate_you = ' called' +z.string.conversation_yesterday = 'Yesterday' +z.string.conversation_unable_to_decrypt_1 = 'a message from %@name was not received.' +z.string.conversation_unable_to_decrypt_2 = '%@name´s device identity changed. Undelivered message.' +z.string.conversation_unable_to_decrypt_link = 'Why?' +z.string.conversation_unable_to_decrypt_error_message = 'Error' +z.string.conversation_unable_to_decrypt_reset_session = 'Reset session' +z.string.conversation_asset_uploading = 'Uploading…' +z.string.conversation_asset_downloading = 'Downloading…' +z.string.conversation_asset_upload_failed = 'Upload Failed' +z.string.conversation_asset_upload_too_large = 'You can send files up to %no' +z.string.conversation_playback_error = 'Unable to play' + +# Conversation list +z.string.conversation_list_archive = 'ARCHIVE' +z.string.conversation_list_empty_conversation = 'Empty conversation' +z.string.conversation_list_many_connection_request = '%no people waiting' +z.string.conversation_list_one_connection_request = '1 person waiting' +z.string.conversation_list_search_header_placeholder = 'Start a conversation' +z.string.conversation_list_popover_archive = 'Archive' +z.string.conversation_list_popover_block = 'Block' +z.string.conversation_list_popover_cancel = 'Cancel request' +z.string.conversation_list_popover_clear = 'Delete' +z.string.conversation_list_popover_leave = 'Leave' +z.string.conversation_list_popover_notify = 'Unmute' +z.string.conversation_list_popover_silence = 'Mute' +z.string.conversation_list_popover_unarchive = 'Unarchive' + +# Invites +z.string.invite_meta_key_mac = 'Cmd' +z.string.invite_meta_key_pc = 'Ctrl' +z.string.invite_hint_selected = 'Press %meta_key + C to copy' +z.string.invite_hint_unselected = 'Select and Press %meta_key + C' +z.string.invite_detail = 'The link can be used for 2 weeks.' +z.string.invite_headline = 'Invite people to Wire' +z.string.invite_message = 'Let’s connect on Wire.\nModern, private communication. %url' + +# Extensions +z.string.extensions_bubble_button_gif = 'Gif' + +# Extensions Giphy +z.string.extensions_giphy_button_ok = 'Send' +z.string.extensions_giphy_button_more = 'Try Another' +z.string.extensions_giphy_message = '%tag • via giphy.com' +z.string.extensions_giphy_no_gifs = 'Oops, no gifs' +z.string.extensions_giphy_random = 'Random' + +# People View +z.string.search_open = 'Open' +z.string.search_open_group = 'Create Group' +z.string.people_confirm_label = 'Add to conversation' +z.string.people_common_contacts = 'You both know' +z.string.people_people = '%no People' +z.string.people_search_placeholder = 'Search by name' +z.string.people_everyone_participates = 'Everyone you’re\nconnected to is already in\nthis conversation.' +z.string.people_no_matches = 'No matching results.\nTry entering a different name.' +z.string.people_invite = 'Invite people' +z.string.people_share = 'Share Contacts' +z.string.people_bring_your_friends = 'Bring your Friends to Wire' +z.string.people_invite_detail = 'Sharing your contacts helps you connect with others. We anonymize all the information and do not share it with anoyone else.' +z.string.people_invite_button_contacts = 'From Contacts' +z.string.people_invite_button_gmail = 'From Gmail' +z.string.people_invite_headline = 'Bring your friends' +z.string.people_tabs_details = 'Details' +z.string.people_tabs_devices = 'Devices' +z.string.people_tabs_devices_headline = 'Wire gives every device a unique fingerprint. Compare them with %@.name and verify your conversation.' +z.string.people_tabs_devices_why_verify = 'Why verify conversation?' +z.string.people_tabs_no_devices_headline = '%@.name is using an old version of Wire. No devices are shown here.' +z.string.people_tabs_device_detail_all_my_devices = 'Show all my devices' +z.string.people_tabs_device_detail_device_fingerprint = 'Device fingerprint' +z.string.people_tabs_device_detail_headline = 'Verify that this matches the fingerprint shown on %bold%@.name’s device%end.' +z.string.people_tabs_device_detail_how_to = 'How do I do that?' +z.string.people_tabs_device_detail_reset_session = 'Reset session' +z.string.people_tabs_device_detail_show_my_device = 'Show my device fingerprint' +z.string.people_tabs_device_detail_verified = 'Verified' + +# Add people to conversation share history dialogue +z.string.people_add_to_headline = 'Add people and share history?' +z.string.people_add_to_message = 'They will be able to see previous messages. Create a new conversation to avoid that.' + +# Block user +z.string.people_block_headline = 'Block?' +z.string.people_block_message = '%@.first_name won’t be able to find or contact you on Wire.' + +# Accept a pending connection dialogue +z.string.people_connect_headline = 'Accept?' +z.string.people_connect_message = 'This will connect you and open the conversation with %@.first_name.' + +# Cancel a pending request +z.string.people_cancel_request_headline = 'Cancel Request?' +z.string.people_cancel_request_message = 'Remove connection request to %@.first_name.' + +# Leave the conversation dialogue +z.string.people_leave_headline = 'Leave the conversation?' +z.string.people_leave_message = 'You won’t be able to send or receive messages in this conversation.' + +# Remove from conversation dialogue +z.string.people_remove_headline = 'Remove?' +z.string.people_remove_message = '%@.first_name won’t be able to send or receive messages in this conversation.' + +# Unblock user +z.string.people_unblock_headline = 'Unblock?' +z.string.people_unblock_message = '%@.first_name will be able to find and contact you on Wire again. You’ll also receive messages they sent while blocked.' + +# Button labels for the actions +z.string.people_button_add = 'Add people' +z.string.people_button_block = 'Block' +z.string.people_button_cancel = 'Cancel' +z.string.people_button_connect = 'Connect' +z.string.people_button_continue = 'Continue' +z.string.people_button_create = 'Create group' +z.string.people_button_ignore = 'Ignore' +z.string.people_button_leave = 'Leave' +z.string.people_button_open = 'Open Conversation' +z.string.people_button_pending = 'Pending' +z.string.people_button_profile = 'Profile' +z.string.people_button_remove = 'Remove' +z.string.people_button_unblock = 'Unblock' +z.string.people_button_no = 'No' +z.string.people_button_yes = 'Yes' + +# Preferences +z.string.preferences_headline = 'Settings' +z.string.preferences_account = 'Account' +z.string.preferences_password = 'Password' +z.string.preferences_password_reset = 'Reset Password' +z.string.preferences_delete = 'Delete Account' +z.string.preferences_email_sent = 'Email Sent' +z.string.preferences_delete_info = 'We will send an email to: %email follow the link to permanently delete your account.' + +z.string.preferences_contacts = 'Contacts' +z.string.preferences_share_contacts = 'Import from Gmail' +z.string.preferences_share_osx_contacts = 'Import from Contacts' +z.string.preferences_contacts_detail = 'We use your contact data to connect you with others. We anonymize all information and do not share it with anyone else.' +z.string.preferences_data = 'Data' +z.string.preferences_data_checkbox = 'Send anonymous usage data' +z.string.preferences_data_detail = 'Send usage data and crash reports. Make Wire better by sending anonymous information.' +z.string.preferences_sound = 'Sound alerts' +z.string.preferences_sound_all = 'All' +z.string.preferences_sound_all_hint = 'All sounds' +z.string.preferences_sound_none = 'None' +z.string.preferences_sound_none_hint = 'Sshhh!' +z.string.preferences_sound_some = 'Some' +z.string.preferences_sound_some_hint = 'Pings and calls' +z.string.preferences_devices = 'Devices' +z.string.preferences_device_remove = 'Remove Device' +z.string.preferences_device_remove_detail = 'Remove this device if you have stopped using it.' +z.string.preferences_device_button_remove = 'Remove' +z.string.preferences_device_button_cancel = 'Cancel' +z.string.preferences_device_activated = 'Activated in ' +z.string.preferences_device_id = 'ID: ' +z.string.preferences_device_reset_session_description = 'If fingerprints don’t match, reset your secure session to make sure that the correct fingerprints are shown on all devices.' +z.string.preferences_device_reset_session_button = 'Reset session' +z.string.preferences_device_fingerprint_label = 'Key fingerprint' +z.string.preferences_device_fingerprint_message = 'Wire gives every device a unique fingerprint. Compare fingerprints to verify your devices and conversations.' + +# Profile +z.string.profile_about = 'About' +z.string.profile_preferences = 'Settings' +z.string.profile_sign_out = 'Log out' +z.string.profile_support = 'Support' +z.string.profile_username_placeholder = 'Your full name' + +# Search +z.string.search_group_hint = 'Keep typing or pick more people to create a group' +z.string.search_connect = 'Connect' +z.string.search_connections = 'Connections' +z.string.search_groups = 'Groups' +z.string.search_placeholder = 'Search by name or email' +z.string.search_top_people = 'Top people' +z.string.search_try_search = 'Find people by name or\nfull email address' +z.string.search_no_contacts_on_wire = 'You have no contacts on Wire.\nTry finding people by name or\nfull email address' +z.string.search_others = 'Connect' +z.string.search_suggestion_one = 'Knows %@.first_name' +z.string.search_suggestion_two = 'Knows %@.first_name and %@.other_name' +z.string.search_suggestion_many = 'Knows %@.first_name and %no others' + +# Picture upload +z.string.upload_welcome = 'Wire is so much nicer with a picture.' +z.string.upload_welcome_keep = 'keep this one' +z.string.upload_welcome_choose = 'choose your own' + +# Google contacts upload +z.string.upload_google_headline = 'Find people\nto talk to.' +z.string.upload_google_message = 'We use your contact data to connect you with others. We anonymize all information and do not share it with anyone else.' +z.string.upload_google_headline_error = 'Something\nwent wrong.' +z.string.upload_google_message_error = 'We did not receive your information. Please try importing your contacts again.' +z.string.upload_google_button_again = 'Try again' + +# URLs +z.string.url_password_reset = 'https://wire.com/forgot/' +z.string.url_legal = 'https://wire.com/legal/' +z.string.url_privacy = 'https://wire.com/privacy/' +z.string.url_privacy_why = 'https://wire.com/privacy/why/' +z.string.url_support = 'https://support.wire.com' +z.string.url_terms_of_use = 'https://wire.com/legal/terms/' +z.string.url_wire = 'https://wire.com' +z.string.url_wire_for_web = 'https://app.wire.com' +z.string.url_support_calling = 'https://support.wire.com/hc/en-us/articles/202969412' +z.string.url_support_camera_access_denied = 'https://support.wire.com/hc/en-us/articles/202935412' +z.string.url_support_contact_bug = 'https://support.wire.com/hc/en-us/requests/new?ticket_form_id=101615' +z.string.url_support_history = 'https://support.wire.com/hc/en-us/articles/207834645' +z.string.url_support_mic_access_denied = 'https://support.wire.com/hc/en-us/articles/202590081' +z.string.url_support_mic_not_found = 'https://support.wire.com/hc/en-us/articles/202970662' +z.string.url_support_screen_access_denied = 'https://support.wire.com/hc/en-us/articles/202935412' +z.string.url_support_screen_whitelist = 'https://support.wire.com/hc/en-us/articles/202935412' +z.string.url_support_session = 'https://support.wire.com/hc/en-us/' +z.string.url_downloads = 'https://wire.com/download/' +z.string.url_app_store = 'https://wire.com/download/osx/' +z.string.url_decrypt_error_1 = 'https://wire.com/privacy/error-1' +z.string.url_decrypt_error_2 = 'https://wire.com/privacy/error-2' + +# Warnings: Permission requests & permission callbacks +z.string.warning_call_detail = 'Your browser needs access to the microphone to make calls.' +z.string.warning_call_headline = 'Can’t call without microphone' +z.string.warning_call_unsupported_incoming = '%s.first_name is calling. Your browser doesn’t support calls.' +z.string.warning_call_unsupported_outgoing = 'You cannot call because your browser doesn’t support calls.' +z.string.warning_call_issues = 'This version of Wire can not participate in the call. Please use' +z.string.warning_call_upgrade_browser = 'To call, please update Google Chrome.' +z.string.warning_learn_more = 'Learn more' +z.string.warning_not_found_camera = 'You cannot call because your computer does not have a camera.' +z.string.warning_not_found_microphone = 'You cannot call because your computer does not have a microphone.' +z.string.warning_permission_denied_camera = 'You cannot call because your browser does not have access to the camera.' +z.string.warning_permission_denied_microphone = 'You cannot call because your browser does not have access to the microphone.' +z.string.warning_permission_denied_screen = 'You cannot share your screen because the browser does not have access it.' +z.string.warning_permission_request_camera = '%icon Allow access to camera' +z.string.warning_permission_request_microphone = '%icon Allow access to microphone' +z.string.warning_permission_request_notification = '%icon Allow notifications' +z.string.warning_permission_request_screen = '%icon Allow access to screen' +z.string.warning_tell_me_how = 'Tell me how' + +# Warnings: Connectivity +z.string.warning_connectivity_connection_lost = 'Trying to connect. Wire may not be able to deliver messages.' +z.string.warning_connectivity_no_internet = 'No Internet. You won’t be able to send or receive messages.' + +# Warnings: App banner +z.string.warning_app_banner_win = 'Wire is available for Windows.' +z.string.warning_app_banner_osx = 'Wire is available for OS X.' +z.string.warning_app_banner_link = 'Get the app' + +# Browser notifications +z.string.system_notification_asset_add = 'Shared a picture' +z.string.system_notification_connection_accepted = 'Accepted your connection request' +z.string.system_notification_connection_request = 'Wants to connect' +z.string.system_notification_conversation_create = '%s.first_name started a conversation' +z.string.system_notification_conversation_rename = '%s.first_name renamed the conversation to %name' +z.string.system_notification_member_join_many = '%s.first_name added %no people to the conversation' +z.string.system_notification_member_join_one = '%s.first_name added %@.first_name to the conversation' +z.string.system_notification_member_leave_left = '%s.first_name left the conversation' +z.string.system_notification_member_leave_removed_many = '%s.first_name removed %no people from the conversation' +z.string.system_notification_member_leave_removed_one = '%s.first_name removed %@.first_name from the conversation' +z.string.system_notification_ping = 'Pinged' +z.string.system_notification_voice_channel_activate = 'Calling' +z.string.system_notification_voice_channel_deactivate = 'Called' +z.string.system_notification_shared_audio = 'Shared an audio message' +z.string.system_notification_shared_video = 'Shared a video' +z.string.system_notification_shared_file = 'Shared a file' + +# Tooltips +z.string.tooltip_call_banner_accept = 'Accept call' +z.string.tooltip_call_banner_ignore = 'Ignore call (%shortcut)' +z.string.tooltip_call_banner_mute = 'Mute microphone (%shortcut)' +z.string.tooltip_call_banner_unmute = 'Unmute microphone (%shortcut)' + +z.string.tooltip_conversation_call = 'Call' +z.string.tooltip_conversation_video_call = 'Video Call' +z.string.tooltip_conversation_file = 'Add file' +z.string.tooltip_conversation_people = 'People (%shortcut)' +z.string.tooltip_conversation_picture = 'Add picture' +z.string.tooltip_conversation_ping = 'Ping (%shortcut)' +z.string.tooltip_conversation_all_verified = 'All fingerprints are verified' +z.string.tooltip_conversation_input_placeholder = 'Type a message' + +z.string.tooltip_conversation_list_archive = 'Archive (%shortcut)' +z.string.tooltip_conversation_list_archived = 'Show archive (%no)' +z.string.tooltip_conversation_list_more = 'More' +z.string.tooltip_conversation_list_notify = 'Unmute (%shortcut)' +z.string.tooltip_conversation_list_silence = 'Mute (%shortcut)' +z.string.tooltip_conversation_list_tooltip_start = 'Start conversation (%shortcut)' + +z.string.tooltip_people_add = 'Add people to conversation (%shortcut)' +z.string.tooltip_people_back = 'Back' +z.string.tooltip_people_block = 'Block' +z.string.tooltip_people_connect = 'Connect' +z.string.tooltip_people_leave = 'Leave conversation' +z.string.tooltip_people_open = 'Open conversation' +z.string.tooltip_people_profile = 'Open your profile' +z.string.tooltip_people_rename = 'Change conversation name' +z.string.tooltip_people_remove = 'Remove from conversation' +z.string.tooltip_people_unblock = 'Unblock' + +z.string.tooltip_preferences_contacts = 'Sign in to your Gmail account to share contacts' +z.string.tooltip_preferences_password = 'Open another website to reset your password' +z.string.tooltip_preferences_sound = 'Select if events play sounds' + +z.string.tooltip_profile_picture = 'Change your picture…' +z.string.tooltip_profile_preferences = 'Preferences…' +z.string.tooltip_profile_rename = 'Change your name' + +z.string.tooltip_search_close = 'Close (Esc)' + +# App loading +z.string.init_received_access_token = 'Access granted' +z.string.init_received_self_user = 'Hello, %name.' +z.string.init_sessions_expectation = 'We need to initialize %sessions sessions' +z.string.init_sessions_expectation_long = 'We need to initialize %sessions sessions' +z.string.init_sessions_progress = 'Initializing sessions - %progress of %total' +z.string.init_initialized_storage = 'Sessions loaded' +z.string.init_initialized_cryptography = 'Cryptography has been fully set up' +z.string.init_validated_client = 'Loading your connections and your conversations' +z.string.init_received_user_data = 'Checking for new messages' +z.string.init_events_expectation = 'You have %events new messages' +z.string.init_events_expectation_long = 'Loading %events new messages' +z.string.init_events_progress = 'Loading messages - %progress of %total' +z.string.init_updated_from_notifications = 'We are almost there' +z.string.init_app_pre_loaded = 'Enjoy Wire' diff --git a/app/script/location/GeoLocation.coffee b/app/script/location/GeoLocation.coffee new file mode 100644 index 00000000000..31acee0ed57 --- /dev/null +++ b/app/script/location/GeoLocation.coffee @@ -0,0 +1,83 @@ +# +# 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.location = do -> + GOOGLE_GEOCODING_BASE_URL = 'https://maps.googleapis.com/maps/api/geocode/json' + API_KEY = 'AIzaSyCKxxKw5JBZ5zEFtoirtgnw8omvH7gWzfo' + + _parse_results = (results) -> + res = {} + result = results[0] + + res['address'] = result.formatted_address + res['lat'] = result.geometry.location.lat + res['lng'] = result.geometry.location.lng + + for component in result.address_components + name = component.long_name or component.short_name + for type in component.types + res[type] = name + if type is 'country' + res['country_code'] = component.short_name or '' + + res['place'] = res.locality or res.natural_feature or res.administrative_area_level_3 or res.administrative_area_level_2 or res.administrative_area_level_1 + delete res.political? + return res + + ### + Reverse loop up for geo location + + @param lat [Number] latitude + @param lng [Number] longitude + @param callback [Function] Function to be called on return + ### + get_location = (lat, lng, callback) -> + if not lat? or not lng? + callback? 'You need to specify latitude and longitude in order to get the location' + + $.ajax + url: "#{GOOGLE_GEOCODING_BASE_URL}?latlng=#{lat},#{lng}&key=#{API_KEY}" + .done (response) -> + if response.status is 'OK' + callback? null, _parse_results response.results + else + callback? response.status + .fail (jqXHR, textStatus, errorThrown) -> + callback? errorThrown + + ### + Return link to google maps + + @param lat [Number] latitude + @param lng [Number] longitude + @param name [String] location name + @param zoom [String] map zoom level + ### + get_maps_url = (lat, lng, name, zoom) -> + base_url = 'https://google.com/maps/' + base_url += "place/#{name}/" if name? + base_url += "@#{lat},#{lng}" + base_url += ",#{zoom}z" if zoom? + return base_url + + return { + get_location: get_location + get_maps_url: get_maps_url + } diff --git a/app/script/main/app.coffee b/app/script/main/app.coffee new file mode 100644 index 00000000000..f356d4bb22f --- /dev/null +++ b/app/script/main/app.coffee @@ -0,0 +1,483 @@ +# +# 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.main ?= {} + +# @formatter:off +class z.main.App + ### + Construct a new app. + @param auth [z.main.Auth] Authentication settings + ### + constructor: (@auth) -> + @logger = new z.util.Logger 'z.main.App', z.config.LOGGER.OPTIONS + + @telemetry = new z.telemetry.app_init.AppInitTelemetry() + @window_handler = new z.ui.WindowHandler().init() + + @service = @_setup_services() + @repository = @_setup_repositories() + @view = @_setup_view_models() + @util = @_setup_utils() + + @_subscribe_to_events() + + @init_debugging() + @init_app() + + + ############################################################################### + # Instantiation + ############################################################################### + + # Create all app services. + _setup_services: -> + service = {} + + service.asset = new z.assets.AssetService @auth.client + service.call = new z.calling.CallService @auth.client + service.connect = new z.connect.ConnectService @auth.client + service.connect_google = new z.connect.ConnectGoogleService @auth.client + service.cryptography = new z.cryptography.CryptographyService @auth.client + service.giphy = new z.extension.GiphyService @auth.client + service.search = new z.search.SearchService @auth.client + service.storage = new z.storage.StorageService() + service.user = new z.user.UserService @auth.client + service.web_socket = new z.event.WebSocketService @auth.client + + service.client = new z.client.ClientService @auth.client, service.storage + service.conversation = new z.conversation.ConversationService @auth.client, service.storage + service.notification = new z.event.NotificationService @auth.client, service.storage + service.announce = new z.announce.AnnounceService() + + return service + + # Create all app repositories. + _setup_repositories: -> + repository = {} + + repository.audio = @auth.audio + repository.storage = new z.storage.StorageRepository @service.storage + repository.cache = new z.cache.CacheRepository() + repository.cryptography = new z.cryptography.CryptographyRepository @service.cryptography, repository.storage + repository.giphy = new z.extension.GiphyRepository @service.giphy + + repository.client = new z.client.ClientRepository @service.client, repository.cryptography + repository.user = new z.user.UserRepository @service.user, @service.asset, @service.search, repository.client, repository.cryptography + repository.connect = new z.connect.ConnectRepository @service.connect, @service.connect_google, repository.user + repository.event = new z.event.EventRepository @service.web_socket, @service.notification, repository.cryptography, repository.user + repository.search = new z.search.SearchRepository @service.search, repository.user + repository.announce = new z.announce.AnnounceRepository @service.announce + repository.links = new z.links.LinkPreviewRepository @service.asset + + repository.conversation = new z.conversation.ConversationRepository( + @service.conversation, + @service.asset, + repository.user, + repository.giphy, + repository.cryptography, + repository.links + ) + + repository.call_center = new z.calling.CallCenter @service.call, repository.conversation, repository.user, repository.audio + repository.event_tracker = new z.tracking.EventTrackingRepository repository.user, repository.conversation + repository.system_notification = new z.SystemNotification.SystemNotificationRepository repository.conversation + + return repository + + # Create all app view models. + _setup_view_models: -> + view = {} + + view.main = new z.ViewModel.MainViewModel 'wire-main', @repository.user + view.content = new z.ViewModel.RightViewModel 'right', @repository.user, @repository.conversation, @repository.call_center, @repository.search, @repository.giphy, @repository.client + view.background = new z.ViewModel.BackgroundViewModel 'background', view.content, @repository.conversation, @repository.user + view.conversation_list = new z.ViewModel.ConversationListViewModel 'conversation-list', view.content, @repository.call_center, @repository.user, @repository.conversation + view.start_ui = new z.ViewModel.StartUIViewModel 'start-ui', @repository.conversation, @repository.search, @repository.user, @repository.connect + view.archive = new z.ViewModel.ArchiveViewModel 'archive', @repository.conversation + view.actions = new z.ViewModel.ActionsViewModel 'actions-bubble', @repository.conversation, @repository.user, view.conversation_list + view.title = new z.ViewModel.WindowTitleViewModel view.content, @repository.user, @repository.conversation + view.welcome = new z.ViewModel.WelcomeViewModel 'welcome', @repository.user + view.settings = new z.ViewModel.SettingsViewModel 'self-settings', @repository.user, @repository.conversation, @repository.client, @repository.cryptography + view.warnings = new z.ViewModel.WarningsViewModel 'warnings' + view.modals = new z.ViewModel.ModalsViewModel 'modals' + + view.loading = new z.ViewModel.LoadingViewModel 'loading-screen', @repository.user + + return view + + _setup_utils: -> + utils = + debug: new z.util.DebugUtil @repository.user, @repository.conversation + return utils + + # Subscribe to amplify events. + _subscribe_to_events: -> + amplify.subscribe z.event.WebApp.SIGN_OUT, @logout + + + ############################################################################### + # Initialization + ############################################################################### + + ### + Initialize the app. + @note any failure will result in a logout + @todo Check if we really need to logout the user in all these error cases or how to recover from them + ### + init_app: (is_reload = @_is_reload()) => + @_load_access_token is_reload + .then => + @view.loading.switch_message z.string.init_received_access_token, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.RECEIVED_ACCESS_TOKEN + return z.util.protobuf.load_protos "ext/proto/generic-message-proto/messages.proto?#{z.util.Environment.version false}" + .then => + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.INITIALIZED_PROTO_MESSAGES + return @_get_user_self() + .then (self_user_et) => + @view.loading.switch_message z.string.init_received_self_user, true + @repository.client.init self_user_et + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.RECEIVED_SELF_USER + return @repository.storage.init false + .then => + @view.loading.switch_message z.string.init_initialized_storage, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.INITIALIZED_STORAGE + number_of_sessions = Object.keys(@repository.storage.sessions).length + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.SESSIONS, number_of_sessions, 50 + return @repository.cryptography.init() + .then => + @view.loading.switch_message z.string.init_initialized_cryptography, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.INITIALIZED_CRYPTOGRAPHY + return @repository.client.get_valid_local_client() + .then (client_observable) => + @view.loading.switch_message z.string.init_validated_client, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.VALIDATED_CLIENT + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.CLIENT_TYPE, client_observable().type + @repository.cryptography.current_client = client_observable + @repository.event.current_client = client_observable + @repository.event.connect() + promises = [ + @repository.client.get_clients_for_self() + @repository.user.init_properties() + @repository.conversation.get_conversations() + @repository.user.get_connections() + ] + return Promise.all promises + .then (response_array) => + [client_ets, user_properties, conversation_ets, connection_ets] = response_array + @view.loading.switch_message z.string.init_received_user_data, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.RECEIVED_USER_DATA + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.CLIENTS, client_ets.length + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.CONVERSATIONS, conversation_ets.length, 50 + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.CONNECTIONS, connection_ets.length, 50 + @repository.user.self().devices client_ets + + @logger.log @logger.levels.INFO, 'Mapping user connections to conversations' + amplify.publish z.event.WebApp.CONVERSATION.MAP_CONNECTION, @repository.user.connections() + @_subscribe_to_beforeunload() + return @repository.event.update_from_notification_stream() + .then (notifications_count) => + @view.loading.switch_message z.string.init_updated_from_notifications, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.UPDATED_FROM_NOTIFICATIONS + @telemetry.add_statistic z.telemetry.app_init.AppInitStatisticsValue.NOTIFICATIONS, notifications_count, 100 + return @_watch_online_status() + .then => + @view.loading.switch_message z.string.init_app_pre_loaded, true + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.APP_PRE_LOADED + @logger.log @logger.levels.INFO, 'App pre-loading completed' + @_show_ui() + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.SHOWING_UI + @telemetry.report() + amplify.publish z.event.WebApp.LOADED + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.APP_LOADED + return @repository.conversation.update_conversations @repository.conversation.conversations_unarchived() + .then => + @repository.announce.init() + @telemetry.time_step z.telemetry.app_init.AppInitTimingsStep.UPDATED_CONVERSATIONS + @logger.log @logger.levels.INFO, 'App fully loaded' + .catch (error) => + @logger.log @logger.levels.INFO, 'Error during app initialization.' + @logger.log @logger.levels.DEBUG, + "App reload: '#{is_reload}', Document referrer: '#{document.referrer}', Location: '#{window.location.href}'" + error_message = error.message or error + invalid_client = [z.client.ClientError::TYPE.MISSING_ON_BACKEND, z.client.ClientError::TYPE.NO_LOCAL_CLIENT] + if is_reload and error.type not in invalid_client + @auth.client.execute_on_connectivity().then -> window.location.reload false + else if navigator.onLine + @logger.log @logger.levels.ERROR, + "Could not load app version '#{z.util.Environment.version false}': #{error_message}", error + @logout 'init_app' + else + @logger.log @logger.levels.WARN, 'No connectivity. Trigger reload on regained connectivity.', error + @_watch_online_status() + + ### + Get the self user from the backend. + @return [Promise] Promise that resolves with the self user entity + ### + _get_user_self: -> + return new Promise (resolve, reject) => + @repository.user.get_me() + .then (user_et) => + @logger.log @logger.levels.INFO, "Loaded self user with ID '#{user_et.id}'" + + if not user_et.email() and not user_et.phone() + reject new Error 'User does not have a verified identity' + else + @service.storage.init user_et.id + .then => + if not user_et.picture_medium().length + z.util.load_url_blob z.config.UNSPLASH_URL, (blob) => + @repository.user.change_picture blob, -> amplify.publish z.event.WebApp.WELCOME.UNSPLASH_LOADED + resolve user_et + .catch (error) => + error = new Error "Loading self user failed: #{error}" + @logger.log @logger.levels.ERROR, error.message + reject error + + ### + Check whether the page has been reloaded. + @private + @return [Boolean] True if it is a page refresh + ### + _is_reload: -> + is_reload = z.util.is_same_location document.referrer, window.location.href + @logger.log @logger.levels.DEBUG, + "App reload: '#{is_reload}', Document referrer: '#{document.referrer}', Location: '#{window.location.href}'" + return is_reload + + ### + Load the access token from cache or get one from the backend. + @return [Promise] Promise that resolves with the Access Token + ### + _load_access_token: (is_reload) -> + return new Promise (resolve) => + if z.util.Environment.frontend.is_localhost() or document.referrer.toLowerCase().includes '/auth' + token_promise = @auth.repository.get_cached_access_token().then(resolve) + else + token_promise = @auth.repository.get_access_token().then(resolve) + + token_promise.catch (error) => + if is_reload + if error.type is error.type is z.auth.AccessTokenError::TYPE.REQUEST_FORBIDDEN + @logger.log @logger.levels.ERROR, "Session expired on page reload: #{error.message}", error + Raygun.send new Error ('Session expired on page reload'), error + @_redirect_to_login true + else if error.type isnt z.auth.AccessTokenError::TYPE.NOT_FOUND_IN_CACHE + @logger.log @logger.levels.WARN, 'Connectivity issues. Trigger reload on regained connectivity.', error + @auth.client.execute_on_connectivity().then -> window.location.reload false + else if navigator.onLine + if error.type is z.auth.AccessTokenError::TYPE.NOT_FOUND_IN_CACHE + @logger.log @logger.levels.WARN, 'No access token found in cache. Redirecting to login.', error + @_redirect_to_login false + else if error.type is z.auth.AccessTokenError::TYPE.REQUEST_FORBIDDEN + @logger.log @logger.levels.WARN, 'Access token request forbidden. Redirecting to login.', error + @_redirect_to_login false + else + @logger.log @logger.levels.ERROR, "Could not get access token: #{error.message}. Logging out user.", error + @logout 'init_app' + else + @logger.log @logger.levels.WARN, 'No connectivity. Trigger reload on regained connectivity.', error + @_watch_online_status() + + # Subscribe to 'beforeunload to stop calls and disconnect the WebSocket. + _subscribe_to_beforeunload: -> + $(window).on 'beforeunload', => + @logger.log '\'window.onbeforeunload\' was triggered, so we will disconnect from the backend.' + @repository.event.disconnect z.event.WebSocketService::CHANGE_TRIGGER.PAGE_NAVIGATION + @repository.call_center.state_handler.leave_call_on_beforeunload() + @repository.storage.terminate() + return undefined + + # Hide the loading spinner and show the application UI. + _show_ui: -> + @logger.log @logger.levels.INFO, 'Showing application UI' + conversation_et = @repository.conversation.get_most_recent_conversation() + has_picture = !!@repository.user.self().picture_medium() + + connect_token = z.util.get_url_parameter z.auth.URLParameter.CONNECT + if connect_token + @logger.log @logger.levels.INFO, 'Found connect token' + @repository.user.create_connection_from_invite_token connect_token + + if conversation_et and has_picture + amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + setTimeout => + types_to_notify = [z.conversation.ConversationType.REGULAR, z.conversation.ConversationType.ONE2ONE] + if conversation_et.type() in types_to_notify + @repository.system_notification.request_permission() + , 2000 + else if @repository.user.connect_requests().length > 0 and has_picture + setTimeout -> + amplify.publish z.event.WebApp.PENDING.SHOW + , 1000 + else if has_picture and not connect_token + amplify.publish z.event.WebApp.PROFILE.SHOW + setTimeout -> + amplify.publish z.event.WebApp.SEARCH.SHOW + , 1000 + else + # Triggered when newly registered user opens Wire (first run experience) + amplify.publish z.event.WebApp.PROFILE.SHOW, false + amplify.publish z.event.WebApp.APP.HIDE + setTimeout -> + amplify.publish z.event.WebApp.WELCOME.SHOW + , 1500 + + $('#loading-screen').remove() + $('#wire-main') + .removeClass 'off' + .attr 'data-uie-value', 'is-loaded' + + # Subscribe to 'navigator.onLine' related events. + _watch_online_status: -> + @logger.log @logger.levels.INFO, 'Watching internet connectivity status' + $(window).on 'offline', @on_internet_connection_lost + $(window).on 'online', @on_internet_connection_gained + + # Behavior when internet connection is re-established. + on_internet_connection_gained: => + @logger.log @logger.levels.INFO, 'Internet connection regained. Re-establishing WebSocket connection...' + @auth.client.execute_on_connectivity() + .then => + amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.NO_INTERNET + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + @repository.event.reconnect z.event.WebSocketService::CHANGE_TRIGGER.ONLINE + + # Reflect internet connection loss in the UI. + on_internet_connection_lost: => + @logger.log @logger.levels.WARN, 'Internet connection lost' + @repository.event.disconnect z.event.WebSocketService::CHANGE_TRIGGER.OFFLINE + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.NO_INTERNET + + + ############################################################################### + # Logout + ############################################################################### + + ### + Logs the user out on the backend and deletes cached data. + @param cause [String] Cause for logout + @param clear_data [Boolean] Whether to keep data in database + @param session_expired [Boolean] Whether to redirect the user to the login page + ### + logout: (cause, clear_data = false, session_expired = false) => + _logout = => + # Disconnect from our backend, end tracking and clear cached data + @repository.event.disconnect z.event.WebSocketService::CHANGE_TRIGGER.LOGOUT + amplify.publish z.event.WebApp.ANALYTICS.SESSION.CLOSE + + # Clear Local Storage (but don't delete the cookie label if you were logged in with a permanent client) + do_not_delete = [z.storage.StorageKey.AUTH.SHOW_LOGIN] + + if @repository.client.is_current_client_permanent() and not clear_data + do_not_delete.push z.storage.StorageKey.AUTH.PERSIST + + # XXX remove on next iteration + self_user = @repository.user.self() + if self_user + cookie_label_key = @repository.client.construct_cookie_label_key self_user.email() or self_user.phone() + + amplify_objects = amplify.store() + for amplify_key, amplify_value of amplify_objects + continue if amplify_key is cookie_label_key and clear_data + do_not_delete.push amplify_key if z.util.contains amplify_key, z.storage.StorageKey.AUTH.COOKIE_LABEL + + @repository.cache.clear_cache session_expired, do_not_delete + + # Clear IndexedDB + if clear_data + @repository.storage.delete_everything() + .catch (error) => + @logger.log @logger.levels.ERROR, 'Failed to delete database before logout', error + .then => + @_redirect_to_login session_expired + else + @_redirect_to_login session_expired + + _logout_on_backend = => + @logger.log @logger.levels.INFO, "Logout triggered by '#{cause}': Disconnecting user from the backend." + @auth.repository.logout() + .then -> _logout() + .catch => @_redirect_to_login false + + if session_expired + _logout() + else if navigator.onLine + _logout_on_backend() + else + @logger.log @logger.levels.WARN, 'No internet access. Continuing when internet connectivity regained.' + $(window).on 'online', -> _logout_on_backend() + + # Redirect to the login page after internet connectivity has been verified. + _redirect_to_login: (session_expired) -> + @logger.log @logger.levels.INFO, + "Redirecting to login after connectivity verification. Session expired: #{session_expired}" + @auth.client.execute_on_connectivity() + .then -> + url = "/auth/#{location.search}" + url = z.util.append_url_parameter url, z.auth.URLParameter.EXPIRED if session_expired + window.location.replace url + + + ############################################################################### + # Debugging + ############################################################################### + + # Disable debugging on any environment. + disable_debugging: -> + amplify.publish z.event.WebApp.PROPERTIES.CHANGE.DEBUG, false + + # Enable debugging on any environment. + enable_debugging: -> + amplify.publish z.event.WebApp.PROPERTIES.CHANGE.DEBUG, true + + # Report call telemetry to Raygun for analysis. + report_call: => + @repository.call_center.report_call() + + # Reset all known sessions at once. + reset_all_sessions: => + @repository.conversation.reset_all_sessions() + + # Initialize debugging features. + init_debugging: => + return if not z.ViewModel.DebugViewModel + @_attach_live_reload() if z.util.Environment.frontend.is_localhost() + @_initialize_sidebar() if not z.util.Environment.frontend.is_production() + + # Attach live reload on localhost. + _attach_live_reload: -> + live_reload = document.createElement 'script' + live_reload.id = 'live_reload' + live_reload.src = 'http://localhost:32123/livereload.js' + document.body.appendChild live_reload + $('html').addClass 'development' + + # Initialize the view model for the debug sidebar. + _initialize_sidebar: -> + @view.debug_view = new z.ViewModel.DebugViewModel 'debug', @repository.conversation, @repository.user + + +############################################################################### +# Setting up the App +############################################################################### +$ -> + if $('#wire-main-app').length isnt 0 + wire.app = new z.main.App wire.auth diff --git a/app/script/main/auth.coffee b/app/script/main/auth.coffee new file mode 100644 index 00000000000..103ad203b2a --- /dev/null +++ b/app/script/main/auth.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.main ?= {} + +class z.main.Auth + ### + Constructs objects needed for app authentication. + + @param [Object] settings Collection of URL settings + @option settings [String] environment Handle of the backend environment (staging, edge, etc.) + @option settings [String] web_socket_url URL to the backend's WebSocket + @option settings [String] rest_url URL to the backend's REST service + @option settings [String] parameter Additional parameters for the webapp's login URL + ### + constructor: (@settings) -> + @audio = new z.audio.AudioRepository() + @client = new z.service.Client @settings + @service = new z.auth.AuthService @client + @repository = new z.auth.AuthRepository @service diff --git a/app/script/main/auth_init_dist.coffee b/app/script/main/auth_init_dist.coffee new file mode 100644 index 00000000000..cb8927c32bc --- /dev/null +++ b/app/script/main/auth_init_dist.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/. +# + +############################################################################### +# Setting up the Environment (DIST) +############################################################################### +$ -> + env = z.util.get_url_parameter z.auth.URLParameter.ENVIRONMENT + if env? and env is z.service.BackendEnvironment.EDGE + settings = + environment: z.service.BackendEnvironment.EDGE + rest_url: 'https://edge-nginz-https.zinfra.io' + web_socket_url: 'wss://edge-nginz-ssl.zinfra.io' + settings.parameter = '?env=edge' if env? + else if env? and env is z.service.BackendEnvironment.STAGING + settings = + environment: z.service.BackendEnvironment.STAGING + rest_url: 'https://staging-nginz-https.zinfra.io' + web_socket_url: 'wss://staging-nginz-ssl.zinfra.io' + settings.parameter = '?env=staging' if env? + else + settings = + environment: z.service.BackendEnvironment.PRODUCTION + rest_url: 'https://prod-nginz-https.wire.com' + web_socket_url: 'wss://prod-nginz-ssl.wire.com' + + window.wire = + auth: new z.main.Auth settings diff --git a/app/script/main/auth_init_edge.coffee b/app/script/main/auth_init_edge.coffee new file mode 100644 index 00000000000..dc624c39218 --- /dev/null +++ b/app/script/main/auth_init_edge.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/. +# + +############################################################################### +# Setting up the Environment (EDGE) +############################################################################### +$ -> + env = z.util.get_url_parameter z.auth.URLParameter.ENVIRONMENT + if env? and env is 'prod' + settings = + environment: z.service.BackendEnvironment.PRODUCTION + rest_url: 'https://prod-nginz-https.wire.com' + web_socket_url: 'wss://prod-nginz-ssl.wire.com' + settings.parameter = '?env=prod' if env? + else if env? and env is 'staging' + settings = + environment: z.service.BackendEnvironment.STAGING + rest_url: 'https://staging-nginz-https.zinfra.io' + web_socket_url: 'wss://staging-nginz-ssl.zinfra.io' + settings.parameter = '?env=staging' if env? + else + settings = + environment: z.service.BackendEnvironment.EDGE + rest_url: 'https://edge-nginz-https.zinfra.io' + web_socket_url: 'wss://edge-nginz-ssl.zinfra.io' + + window.wire = + auth: new z.main.Auth settings diff --git a/app/script/main/auth_init_prod.coffee b/app/script/main/auth_init_prod.coffee new file mode 100644 index 00000000000..983c77d7346 --- /dev/null +++ b/app/script/main/auth_init_prod.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/. +# + +############################################################################### +# Setting up the Environment (PRODUCTION) +############################################################################### +$ -> + settings = + environment: z.service.BackendEnvironment.PRODUCTION + rest_url: 'https://prod-nginz-https.wire.com' + web_socket_url: 'wss://prod-nginz-ssl.wire.com' + + window.wire = + auth: new z.main.Auth settings diff --git a/app/script/main/auth_init_staging.coffee b/app/script/main/auth_init_staging.coffee new file mode 100644 index 00000000000..14f2e5c3e30 --- /dev/null +++ b/app/script/main/auth_init_staging.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/. +# + +############################################################################### +# Setting up the Environment (STAGING) +############################################################################### +$ -> + env = z.util.get_url_parameter z.auth.URLParameter.ENVIRONMENT + if env? and env is 'prod' + settings = + environment: z.service.BackendEnvironment.PRODUCTION + rest_url: 'https://prod-nginz-https.wire.com' + web_socket_url: 'wss://prod-nginz-ssl.wire.com' + settings.parameter = '?env=prod' if env? + else if env? and env is z.service.BackendEnvironment.EDGE + settings = + environment: z.service.BackendEnvironment.EDGE + rest_url: 'https://edge-nginz-https.zinfra.io' + web_socket_url: 'wss://edge-nginz-ssl.zinfra.io' + settings.parameter = '?env=edge' if env? + else + settings = + environment: z.service.BackendEnvironment.STAGING + rest_url: 'https://staging-nginz-https.zinfra.io' + web_socket_url: 'wss://staging-nginz-ssl.zinfra.io' + + window.wire = + auth: new z.main.Auth settings diff --git a/app/script/media/MediaEmbeds.coffee b/app/script/media/MediaEmbeds.coffee new file mode 100644 index 00000000000..b8e2093c095 --- /dev/null +++ b/app/script/media/MediaEmbeds.coffee @@ -0,0 +1,236 @@ +# +# 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.media ?= {} + +# Media embeds. +z.media.MediaEmbeds = do -> + + ### + Create and iframe. + + @private + + @param options [Object] Settings to be used to create the iframe + ### + _create_iframe_container = (options) -> + + defaults = + allowfullscreen: ' allowfullscreen' + class: 'iframe-container iframe-container-video' + frameborder: '0' + height: '100%' + type: 'default' + video: true + width: '100%' + + opt = _.extend defaults, options + + iframe_container = '
    ' + + if not opt.video + opt.allowfullscreen = '' + opt.class = 'iframe-container' + + if z.util.Environment.electron + opt.allowfullscreen = '' + + return z.util.string_format iframe_container, opt.class, opt.width, opt.height, opt.src, opt.frameborder, opt.allowfullscreen + + # Enum of different regex for the supported services. + _regex = + # example: http://regexr.com/3ase5 + youtube: /.*(?:youtu.be|youtube.com).*(?:\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/g + soundcloud: /(https?:\/\/(?:www\.|m\.)?)?soundcloud\.com(\/[\w\-]+){2,3}/g + spotify: /https?:\/\/(?:play\.|open\.)*spotify.com\/([\w\-/]+)/g + vimeo: /https?:\/\/(?:vimeo\.com\/|player\.vimeo\.com\/)(?:video\/|(?:channels\/staffpicks\/|channels\/)|)((\w|-){7,9})/g + + ### + Appends and iFrame. + + @private + + @param link [HTMLAnchorElement] + @param message [String] Message containing the link + @param iframe [String] HTML of iframe + ### + _append_iframe = (link, message, iframe) -> + link_string = link.outerHTML.replace /&/g, '&' + message = message.replace link_string, "#{link_string}#{iframe}" + + ### + Generate embed url to use as src in iframes + + @private + + @param url [String] given youtube url + ### + _generate_youtube_embed_url = (url) -> + if url.match /youtu.be|youtube.com/ + + # get youtube video id + video_id = url.match /(?:embed\/|v=|v\/|be\/)([a-zA-Z0-9_-]{11})/ + + return if not video_id + + # we have to remove the v param and convert the timestamp + # into an embed friendly format (start=seconds) + query = url + .substr url.indexOf('?'), url.length + .replace /^[?]/, '&' + .replace /[&]v=[a-zA-Z0-9_-]{11}/, '' + .replace /[&#]t=([a-z0-9]+)/, (a, b) -> "&start=#{_convert_youtube_timestamp_to_seconds b}" + .replace /[&]?autoplay=1/, '' # remove autoplay param + + # append html5 parameter to youtube src to force html5 mode + # this fixes the issue that FF displays black box in some cases + return "https://www.youtube.com/embed/#{video_id[1]}?html5=1#{query}" + + ### + Converts youtube timestamp into seconds + + @private + + @param timestamp [String] youtube timestamp 1h8m55s + ### + _convert_youtube_timestamp_to_seconds = (timestamp) -> + return 0 if not timestamp + return window.parseInt(timestamp, 10) if /^[0-9]*$/.test timestamp + hours = timestamp.match(/([0-9]*)h/)?[1] or 0 + minutes = timestamp.match(/([0-9]*)m/)?[1] or 0 + seconds = timestamp.match(/([0-9]*)s/)?[1] or 0 + return window.parseInt(hours, 10) * 3600 + window.parseInt(minutes, 10) * 60 + window.parseInt(seconds, 10) + + # Make public for testability. + regex: _regex + generate_youtube_embed_url: _generate_youtube_embed_url + convert_youtube_timestamp_to_seconds: _convert_youtube_timestamp_to_seconds + + ### + Appends SoundCloud iFrame if link is a valid SoundCloud source. + + @param link [HTMLAnchorElement] + @param message [String] Message containing the link + ### + soundcloud: (link, message) -> + link_src = link.href + + if link_src.match _regex.soundcloud + link_src = link_src.replace /(m\.)/, '' + link_path_name = link.pathname + + if link_path_name.endsWith('/') + link_path_name = link_path_name.substr 0, link_path_name.length - 1 + + is_single_track = false + slashes_in_link = link_path_name.split('/').length + + if slashes_in_link is 3 + is_single_track = true + else if slashes_in_link > 3 and link_path_name.indexOf('sets') is -1 + # Fix for WEBAPP-1137 + return message + + height = if is_single_track then 164 else 465 + + iframe = _create_iframe_container + src: 'https://w.soundcloud.com/player/?url={1}&visual=false&show_comments=false&buying=false&show_playcount=false&liking=false&sharing=false&hide_related=true' + type: 'soundcloud' + video: false + height: height + + embed = z.util.string_format iframe, height, link_src + message = _append_iframe link, message, embed + + return message + + ### + Appends Spotify iFrame if link is a valid Spotify source. + + @param link [HTMLAnchorElement] + @param message [String] Message containing the link + ### + spotify: (link, message) -> + link_src = link.href + + if link_src.match _regex.spotify + + iframe = _create_iframe_container + src: 'https://embed.spotify.com/?uri=spotify$1' + type: 'spotify' + video: false + height: '80px' + + # convert spotify uri: album/23... -> album:23... -> album%3A23... + embed = '' + + link_src.replace _regex.spotify, (match, group1) -> + replace_slashes = group1.replace /\//g, ':' + encoded_params = encodeURIComponent ":#{replace_slashes}" + embed = iframe.replace '$1', encoded_params + + message = _append_iframe link, message, embed + + return message + + ### + Appends Vimeo iFrame if link is a valid Vimeo source. + + @param link [HTMLAnchorElement] + @param message [String] Message containing the link + ### + vimeo: (link, message, theme_color) -> + link_src = link.href + vimeo_color = theme_color?.replace '#', '' + + if link_src.match _regex.vimeo + return message if z.util.contains link_src, '/user' + + iframe = _create_iframe_container + src: "https://player.vimeo.com/video/$1?portrait=0&color=#{vimeo_color}&badge=0" + type: 'vimeo' + + embed = '' + + link_src.replace _regex.vimeo, (match, group1) -> + embed = iframe.replace '$1', group1 + + message = _append_iframe link, message, embed + + return message + + ### + Appends YouTube iFrame if link is a valid YouTube source. + + @param link [HTMLAnchorElement] + @param message [String] Message containing the link + ### + youtube: (link, message) -> + embed_url = _generate_youtube_embed_url link.href + + if embed_url + + iframe = _create_iframe_container + src: embed_url + type: 'youtube' + + message = _append_iframe link, message, iframe + return message + + return message diff --git a/app/script/media/MediaParser.coffee b/app/script/media/MediaParser.coffee new file mode 100644 index 00000000000..b4dd7fd17a4 --- /dev/null +++ b/app/script/media/MediaParser.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.media ?= {} + +# Media Parser to render rich media embeds. +class MediaParser + # Construct a new Media parser and select available embeds. + constructor: -> + @embeds = [ + z.media.MediaEmbeds.soundcloud + z.media.MediaEmbeds.spotify + z.media.MediaEmbeds.vimeo + z.media.MediaEmbeds.youtube + ] + + ### + Render media embeds. + + @note Checks message for valid media links and appends an iFrame right after the link + + @param message [String] Message text + @param theme_color [String] Accent color to be applied to the embed + ### + render_media_embeds: (message, theme_color) => + div = document.createElement 'div' + div.innerHTML = message + links = div.querySelectorAll 'a' + + for link in links + for embed in @embeds + message = embed link, message, theme_color + + return message + +z.media.MediaParser = new MediaParser() diff --git a/app/script/message/CallMessageType.coffee b/app/script/message/CallMessageType.coffee new file mode 100644 index 00000000000..94ce62300af --- /dev/null +++ b/app/script/message/CallMessageType.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.message ?= {} + +# Enum for different call message types. +z.message.CallMessageType = + ACTIVATED: 'activated' + DEACTIVATED: 'deactivated' diff --git a/app/script/message/PingMessageType.coffee b/app/script/message/PingMessageType.coffee new file mode 100644 index 00000000000..b9044e2ec2b --- /dev/null +++ b/app/script/message/PingMessageType.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.message ?= {} + +# Enum for different ping message types. +z.message.PingMessageType = + PING: 'ping' + HOT_PING: 'hot-ping' diff --git a/app/script/message/SuperType.coffee b/app/script/message/SuperType.coffee new file mode 100644 index 00000000000..09a1e74a37a --- /dev/null +++ b/app/script/message/SuperType.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.message ?= {} + +### +Enum for different message super types. +### +z.message.SuperType = + ALL_VERIFIED: 'all-verified' + CALL: 'call' + CONVERSATION_RENAME: 'rename' # TODO used? + CONTENT: 'normal' + DEVICE: 'device' + LOCATION: 'location' + MEMBER: 'member' + PING: 'ping' + SPECIAL: 'special' + SYSTEM: 'system' + UNABLE_TO_DECRYPT: 'unable-to-decrypt' diff --git a/app/script/message/SystemMessageType.coffee b/app/script/message/SystemMessageType.coffee new file mode 100644 index 00000000000..e937ef7ae45 --- /dev/null +++ b/app/script/message/SystemMessageType.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.message ?= {} + +### +Enum for different system message types. + +@todo Refactor to use member-join and member-leave instead of normal. It duplicates "z.message.SuperType". +### +z.message.SystemMessageType = + NORMAL: 'normal' + MEMBER_JOIN: 'join' + MEMBER_LEAVE: 'leave' + CONVERSATION_CREATE: 'created-group' + CONVERSATION_RENAME: 'rename' + CONVERSATION_RESUME: 'resume' + CONNECTION_REQUEST: 'connecting' + CONNECTION_ACCEPTED: 'created-one-to-one' diff --git a/app/script/search/SearchLevel.coffee b/app/script/search/SearchLevel.coffee new file mode 100644 index 00000000000..435d412bcb2 --- /dev/null +++ b/app/script/search/SearchLevel.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.search ?= {} + +### +Enum of different search levels. +### +z.search.SEARCH_LEVEL = + DIRECT_CONTACT: 1 + FRIEND_OF_FRIEND: 2 + INDIRECT_CONTACT: 3 diff --git a/app/script/search/SearchMode.coffee b/app/script/search/SearchMode.coffee new file mode 100644 index 00000000000..188700eed2f --- /dev/null +++ b/app/script/search/SearchMode.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.search ?= {} + +# Enum of different search modes. +z.search.SEARCH_MODE = + COMMON_CONNECTIONS: 'common_connections' + CONTACTS: 'contacts' + SUGGESTIONS: 'suggestions' + TOP_PEOPLE: 'top_people' + ONBOARDING: 'onboarding' diff --git a/app/script/search/SearchRepository.coffee b/app/script/search/SearchRepository.coffee new file mode 100644 index 00000000000..ae0f157edd0 --- /dev/null +++ b/app/script/search/SearchRepository.coffee @@ -0,0 +1,221 @@ +# +# 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.search ?= {} + +# Search repository for all interactions with the search service. +class z.search.SearchRepository + ### + Construct a new Conversation Repository. + @param search_service [z.search.SearchService] Backend REST API search service implementation + @param user_repository [z.user.UserRepository] Repository for all user and connection interactions + ### + constructor: (@search_service, @user_repository) -> + @logger = new z.util.Logger 'z.search.SearchRepository', z.config.LOGGER.OPTIONS + + @search_result_mapper = new z.search.SearchResultMapper @user_repository + + @suggested_search_ets = [] + + amplify.subscribe z.event.WebApp.SEARCH.ONBOARDING, @show_onboarding + + ### + Get common contacts for given user. + @param user_id [String] User ID + @return [Promise>] Promise that will resolve with an array containing the users common contacts + ### + get_common_contacts: (user_id) => + @search_service.get_common user_id + .then (response) => + return @search_result_mapper.map_results response.documents, z.search.SEARCH_MODE.COMMON_CONNECTIONS + .then ([search_ets, mode]) => + return @_prepare_search_result search_ets, mode + + ### + Get "People you may know" (PYMK). + @param size [Integer] Number of requested user + @return [Promise] Promise that resolves with suggestions + ### + get_suggestions: -> + return new Promise (resolve, reject) => + @suggested_search_ets = z.storage.get_value(z.storage.StorageKey.SEARCH.SUGGESTED_SEARCH_ETS) or [] + + if @suggested_search_ets.length > 0 + @_prepare_search_result @suggested_search_ets, z.search.SEARCH_MODE.SUGGESTIONS + .then (user_ets) -> resolve user_ets + .catch (error) -> reject error + else + @search_service.get_suggestions z.config.SUGGESTIONS_FETCH_LIMIT + .then (response) => + return @search_result_mapper.map_results response.documents, z.search.SEARCH_MODE.SUGGESTIONS + .then ([search_ets, mode]) => + return @_prepare_search_result search_ets, mode + .then (search_ets) => + # store suggested user ids + @suggested_search_ets = search_ets + z.storage.set_value z.storage.StorageKey.SEARCH.SUGGESTED_SEARCH_ETS, @suggested_search_ets, 15 * 60 + resolve search_ets + .catch (error) -> + reject error + + ### + Get top people. + @return [Function] Promise that resolves with the top connections + ### + get_top_people: -> + @search_service.get_top z.config.TOP_PEOPLE_FETCH_LIMIT + .then (response) => + return @search_result_mapper.map_results response.documents, z.search.SEARCH_MODE.TOP_PEOPLE + .then ([search_ets, mode]) => + return @_prepare_search_result search_ets, mode + + ### + Ignore suggested user. + @param user_id [String] User ID + @return [Promise] Promise that resolves when a suggestion has been ignored + ### + ignore_suggestion: (user_id) -> + @search_service.put_suggestions_ignore user_id + .then => + search_et = ko.utils.arrayFirst @suggested_search_ets, (search_et) -> + search_et.id is user_id + + # remove ignored user from the suggested user ids + user_id_index = @suggested_search_ets.indexOf search_et + @suggested_search_ets.splice user_id_index, 1 if user_id_index > -1 + z.storage.set_value z.storage.StorageKey.SEARCH.SUGGESTED_SEARCH_ETS, @suggested_search_ets, 30 + + ### + Search for users on the backend by name. + @param name [String] Search query + @return [Promise] Promise that resolves with the search results + ### + search_by_name: (name) -> + @search_service.get_contacts name, 30, z.search.SEARCH_LEVEL.INDIRECT_CONTACT, 1 + .then (response) => + return @search_result_mapper.map_results response?.documents, z.search.SEARCH_MODE.CONTACTS + .then ([search_ets, mode]) => + return @_prepare_search_result search_ets, mode + + ### + Show on-boarding results. + @param response [Object] Server response + @return [Promise] Promise that resolves with the connections found through on-boarding + ### + show_onboarding: (response) => + @search_result_mapper.map_results response.results, z.search.SEARCH_MODE.ONBOARDING + .then ([search_ets, mode]) => + return @_prepare_search_result search_ets, mode + + ### + Preparing the search results for display. + + @note We skip a few results as connection changes need a while to reflect on the graph. + @private + + @param search_ets [Array] An array of mapped search result entities + @param mode [z.search.SEARCH_MODE] Search mode + @return [Promise] Promise that will resolve with search results + ### + _prepare_search_result: (search_ets, mode) -> + return new Promise (resolve, reject) => + user_ids = [] + user_ids.push user_et.id for user_et in search_ets + + @user_repository.get_users_by_id user_ids, (user_ets) => + result_user_ets = [] + for user_et in user_ets + ### + Skipping some results to adjust for slow graph updates. + + Only show connected people among your top people. + Do not show already connected people when uploading address book. + Only show unknown or cancelled people in suggestions. + ### + switch mode + when z.search.SEARCH_MODE.COMMON_CONNECTIONS + result_user_ets.push user_et + when z.search.SEARCH_MODE.CONTACTS + if not user_et.connected() + user_et.connection_level z.user.ConnectionLevel.NO_CONNECTION + result_user_ets.push user_et + when z.search.SEARCH_MODE.ONBOARDING + result_user_ets.push user_et if not user_et.connected() + when z.search.SEARCH_MODE.SUGGESTIONS + states_to_suggest = [z.user.ConnectionStatus.UNKNOWN, z.user.ConnectionStatus.CANCELLED] + result_user_ets.push user_et if user_et.connection().status() in states_to_suggest + when z.search.SEARCH_MODE.TOP_PEOPLE + result_user_ets.push user_et if user_et.connected() + else + result_user_ets.push user_et + + if mode in [z.search.SEARCH_MODE.CONTACTS, z.search.SEARCH_MODE.SUGGESTIONS] + @_add_mutual_friends(result_user_ets, search_ets).then(resolve).catch(reject) + else + resolve result_user_ets + + ### + Adding mutual friend entities to the search results. + + @private + + @param user_ets [Array] User entities + @param search_ets [Array] Search entities returned from the server + @return [Promise] Promise that resolves with search results that where mutual friends are added + ### + _add_mutual_friends: (user_ets, search_ets) -> + return new Promise (resolve) => + mutual_friend_ids = _.flatten(search_et.mutual_friend_ids for search_et in search_ets when search_et.mutual_friend_ids?) + + @user_repository.get_users_by_id mutual_friend_ids, (all_mutual_friend_ets) -> + for user_et in user_ets + search_et = ko.utils.arrayFirst search_ets, (search_et) -> + search_et.id is user_et.id + user_et.mutual_friends_total search_et.mutual_friends_total + if user_et.mutual_friends_total() > 0 + user_et.mutual_friend_ids search_et.mutual_friend_ids + mutual_friend_ets = [] + for mutual_friend_id in user_et.mutual_friend_ids() + mutual_friend_et = ko.utils.arrayFirst all_mutual_friend_ets, (user_et) -> + user_et.id is mutual_friend_id + mutual_friend_ets.push mutual_friend_et if mutual_friend_et? + user_et.mutual_friend_ets mutual_friend_ets + + if user_et.mutual_friend_ets().length is 1 + user_et.relation_info = z.localization.Localizer.get_text { + id: z.string.search_suggestion_one + replace: {placeholder: '%@.first_name', content: user_et.mutual_friend_ets()[0].first_name()} + } + else if user_et.mutual_friend_ets().length is 2 + user_et.relation_info = z.localization.Localizer.get_text { + id: z.string.search_suggestion_two + replace: [ + {placeholder: '%@.first_name', content: user_et.mutual_friend_ets()[0].first_name()} + {placeholder: '%@.other_name', content: user_et.mutual_friend_ets()[1].first_name()} + ] + } + else if user_et.mutual_friend_ets().length > 2 + user_et.relation_info = z.localization.Localizer.get_text { + id: z.string.search_suggestion_many + replace: [ + {placeholder: '%@.first_name', content: user_et.mutual_friend_ets()[0].first_name()} + {placeholder: '%no', content: user_et.mutual_friends_total() - 1} + ] + } + resolve user_ets diff --git a/app/script/search/SearchResultMapper.coffee b/app/script/search/SearchResultMapper.coffee new file mode 100644 index 00000000000..08fe2aed9c0 --- /dev/null +++ b/app/script/search/SearchResultMapper.coffee @@ -0,0 +1,46 @@ +# +# 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.search ?= {} + +# Search mapper to convert all server side JSON search results into usable entities. +class z.search.SearchResultMapper + # Construct a new Search Result Mapper. + constructor: -> + @logger = new z.util.Logger 'z.search.SearchResultMapper', z.config.LOGGER.OPTIONS + + ### + Converts JSON search results from other search modes into core entities. + + @param search_results [JSON] Search result data + @param mode [z.search.SEARCH_MODE] Search mode to be mapped + @return [Promise] Promise that will resolve with the mapped search results + ### + map_results: (search_results = [], mode) -> + return new Promise (resolve) -> + search_ets = [] + + for search_result in search_results + search_et = {} + search_et.id = search_result.id + if mode in [z.search.SEARCH_MODE.CONTACTS, z.search.SEARCH_MODE.SUGGESTIONS] + search_et.mutual_friends_total = search_result.total_mutual_friends + search_et.mutual_friend_ids = search_result.mutual_friends + search_ets.push search_et + resolve [search_ets, mode] diff --git a/app/script/search/SearchService.coffee b/app/script/search/SearchService.coffee new file mode 100644 index 00000000000..1b8f229bf21 --- /dev/null +++ b/app/script/search/SearchService.coffee @@ -0,0 +1,83 @@ +# +# 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.search ?= {} + +# Search service for all search calls to the backend REST API. +class z.search.SearchService + ### + Construct a new Search Service. + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client) -> + @logger = new z.util.Logger 'z.search.SearchService', z.config.LOGGER.OPTIONS + + ### + Get common contacts for given user. + @param user_id [String] User ID + @return [Promise] Promise that resolves with the common contacts + ### + get_common: (user_id) -> + @client.send_request + type: 'GET' + url: @client.create_url "/search/common/#{user_id}" + + ### + Search for a user. + + @param query [String] Query string (case insensitive) + @param size [Integer] Number of requested user + @param level [Integer] How deep to search (friends, friends of friends, friends of friends or friends)... + @param directory [Integer] Fall back to directory if graph search does not find the size of people (0 or 1) + @return [Promise] Promise that resolves with the search results + ### + get_contacts: (query, size, level, directory) -> + @client.send_request + type: 'GET' + url: @client.create_url "/search/contacts?q=#{encodeURIComponent(query)}&size=#{size}&l=#{level}&d=#{directory}" + + ### + Get "People you may know" (PYMK). + @param size [Integer] Number of requested user + @return [Promise] Promise that resolves with the contact suggestions + ### + get_suggestions: (size) -> + @client.send_request + type: 'GET' + url: @client.create_url "/search/suggestions?size=#{size}" + + ### + Get top people. + @param [Integer] size number of requested user + @return [Promise] Promise that resolves with the top connections + ### + get_top: (size) -> + @client.send_request + type: 'GET' + url: @client.create_url "/search/top?size=#{size}" + + ### + Ignore suggested user. + @param user_id [String] User ID + @return [Promise] Promise that resolves when a suggestion has been ignored + ### + put_suggestions_ignore: (user_id) -> + @client.send_request + type: 'PUT' + url: @client.create_url "/search/suggestions/#{user_id}/ignore" diff --git a/app/script/service/BackendClientError.coffee b/app/script/service/BackendClientError.coffee new file mode 100644 index 00000000000..0b8cd8cb845 --- /dev/null +++ b/app/script/service/BackendClientError.coffee @@ -0,0 +1,78 @@ +# +# 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.service ?= {} + +class z.service.BackendClientError + constructor: (params) -> + @name = @constructor.name + @stack = (new Error()).stack + + if _.isObject params + @code = params.code + @label = params.label + @message = params.message + else if _.isNumber params + @code = params + @message = "#{params}" + + @:: = new Error() + @::constructor = @ + @::STATUS_CODE = + ACCEPTED: 202 + BAD_GATEWAY: 502 + BAD_REQUEST: 400 + CONFLICT: 409 + CONNECTIVITY_PROBLEM: 0 + CREATED: 201 + FORBIDDEN: 403 + INTERNAL_SERVER_ERROR: 500 + NO_CONTENT: 204 + NOT_FOUND: 404 + OK: 200 + REQUEST_TIMEOUT: 408 + PRECONDITION_FAILED: 412 + TOO_MANY_REQUESTS: 429 + UNAUTHORIZED: 401 + REQUEST_TOO_LARGE: 413 + + @::LABEL = + BAD_REQUEST: 'bad-request' + BLACKLISTED_EMAIL: 'blacklisted-email' + BLACKLISTED_PHONE: 'blacklisted-phone' + CONNECTIVITY_PROBLEM: 'connectivity-problem' + CONVERSATION_TOO_BIG: 'conv-too-big' + IN_USE: 'in-use' + INVALID_CREDENTIALS: 'invalid-credentials' + INVALID_EMAIL: 'invalid-email' + INVALID_INVITATION_CODE: 'invalid-invitation-code' + INVALID_OPERATION: 'invalid-op' + INVALID_PHONE: 'invalid-phone' + KEY_EXISTS: 'key-exists' + MISSING_AUTH: 'missing-auth' + MISSING_IDENTITY: 'missing-identity' + NOT_FOUND: 'not-found' + PASSWORD_EXISTS: 'password-exists' + PENDING_ACTIVATION: 'pending-activation' + PENDING_LOGIN: 'pending-login' + TOO_MANY_CLIENTS: 'too-many-clients' + TOO_MANY_MEMBERS: 'too-many-members' + UNAUTHORIZED: 'unauthorized' + UNKNOWN_CLIENT: 'unknown-client' + VOICE_CHANNEL_FULL: 'voice-channel-full' diff --git a/app/script/service/BackendEnvironment.coffee b/app/script/service/BackendEnvironment.coffee new file mode 100644 index 00000000000..f5b7437233f --- /dev/null +++ b/app/script/service/BackendEnvironment.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.service ?= {} + +z.service.BackendEnvironment = + EDGE: 'edge' + PRODUCTION: 'production' + STAGING: 'staging' diff --git a/app/script/service/Client.coffee b/app/script/service/Client.coffee new file mode 100644 index 00000000000..ef7045d270c --- /dev/null +++ b/app/script/service/Client.coffee @@ -0,0 +1,242 @@ +# +# 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.service ?= {} + +IGNORED_BACKEND_ERRORS = [ + z.service.BackendClientError::STATUS_CODE.BAD_GATEWAY + z.service.BackendClientError::STATUS_CODE.BAD_REQUEST + z.service.BackendClientError::STATUS_CODE.CONFLICT + z.service.BackendClientError::STATUS_CODE.CONNECTIVITY_PROBLEM + z.service.BackendClientError::STATUS_CODE.INTERNAL_SERVER_ERROR + z.service.BackendClientError::STATUS_CODE.NOT_FOUND + z.service.BackendClientError::STATUS_CODE.PRECONDITION_FAILED + z.service.BackendClientError::STATUS_CODE.REQUEST_TIMEOUT + z.service.BackendClientError::STATUS_CODE.TOO_MANY_REQUESTS +] + +# Client for all backend REST API calls. +class z.service.Client + ### + Construct a new client. + + @param settings [Object] Settings for different backend environments + @option settings [String] environment + @option settings [String] rest_url + @option settings [String] web_socket_url + @option settings [String] parameter + ### + constructor: (settings) -> + @logger = new z.util.Logger 'z.service.Client', z.config.LOGGER.OPTIONS + + z.util.Environment.backend.current = settings.environment + @rest_url = settings.rest_url + @web_socket_url = settings.web_socket_url + + @request_queue = [] + @is_requesting_access_token = ko.observable false + @is_requesting_access_token.subscribe (is_requesting) => + @execute_request_queue() if @access_token isnt '' and @request_queue.length > 0 and not is_requesting + + @access_token = '' + @access_token_type = '' + + @number_of_requests = ko.observable 0 + @number_of_requests.subscribe (new_value) -> + amplify.publish z.event.WebApp.TELEMETRY.BACKEND_REQUESTS, new_value + + # http://stackoverflow.com/a/18996758/451634 + pre_filters = $.Callbacks() + pre_filters.before_each_request = (options, originalOptions, jqXHR) => + jqXHR.wire = + original_request_options: originalOptions + request_id: @number_of_requests() + requested: new Date() + + $.ajaxPrefilter pre_filters.before_each_request + + ### + Create a request URL. + @param url [String] API endpoint to be prefixed with REST API environment + @return [String] REST API endpoint + ### + create_url: (url) -> + return "#{@rest_url}#{url}" + + ### + Request backend status. + @return [$.Promise] jquery ajax promise + ### + status: => + $.ajax + type: 'HEAD' + timeout: 500 + url: @create_url '/self' + + ### + Delay a function call until backend connectivity is guaranteed. + @return [Promise] Promise that is resolved when connectivity is verified + ### + execute_on_connectivity: => + return new Promise (resolve) => + check_status = => + @logger.log @logger.levels.INFO, 'Checking connectivity status' + @status() + .done => + @logger.log @logger.levels.INFO, 'Connectivity verified.' + resolve() + .fail (jqXHR) => + if jqXHR.readyState is 4 + @logger.log @logger.levels.INFO, "Connectivity verified by server error: #{jqXHR.statusText}" + resolve() + else + window.setTimeout check_status, 2000 + + check_status() + + # Execute queued requests. + execute_request_queue: => + @logger.log @logger.levels.INFO, "Executing '#{@request_queue.length}' queued requests" + for request in @request_queue + [config, request_resolve, request_reject] = @request_queue.shift() + @send_request config + .then (response) => + @logger.log @logger.levels.INFO, "Queued '#{config.type}' request to '#{config.url}' executed", response + request_resolve response + .catch (error) => + @logger.log @logger.levels.INFO, + "Failed to execute queued '#{config.type}' request to '#{config.url}'", error + request_reject error + + ### + Send jQuery AJAX request. + @see http://api.jquery.com/jquery.ajax/#jQuery-ajax-settings + @param config [jQuery.ajax SettingsObject] + ### + send_request: (config) -> + return new Promise (resolve, reject) => + if @is_requesting_access_token() + @logger.log @logger.levels.INFO, 'Request queued while access token is refreshed', config + @request_queue.push [config, resolve, reject] + else + headers = config.headers or {} + headers['Authorization'] = "#{@access_token_type} #{@access_token}" if @access_token + + xhrFields = {} + xhrFields['withCredentials'] = true if config.withCredentials + + @number_of_requests @number_of_requests() + 1 + + if _.isArray config.callback + $.ajax + contentType: config.contentType + data: config.data + headers: headers + processData: config.processData + timeout: config.timeout + type: config.type + url: config.url + xhrFields: xhrFields + .always (data_or_jqXHR, textStatus, jqXHR_or_data) => + if textStatus not in ['error', 'timeout'] + if jqXHR_or_data.wire + jqXHR_or_data.wire.original_request_options.api_endpoint = config.api_endpoint + jqXHR_or_data.wire.responded = new Date() + resolve [data_or_jqXHR, jqXHR_or_data] + else + switch data_or_jqXHR.status + when z.service.BackendClientError::STATUS_CODE.CONNECTIVITY_PROBLEM + @logger.log @logger.levels.WARN, 'Request failed due to connectivity problem.', config + @request_queue.push [config, resolve, reject] + @execute_on_connectivity().then => @execute_request_queue() + when z.service.BackendClientError::STATUS_CODE.UNAUTHORIZED + @logger.log @logger.levels.WARN, 'Request failed as access token is invalid.', config + @request_queue.push [config, resolve, reject] + amplify.publish z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEW + else + reject data_or_jqXHR.responseJSON or new z.service.BackendClientError data_or_jqXHR.status + else + $.ajax + contentType: config.contentType + data: config.data + headers: headers + processData: config.processData + timeout: config.timeout + type: config.type + url: config.url + .done (data, textStatus, jqXHR) => + @logger.log @logger.levels.OFF, "Server Response ##{jqXHR.wire.request_id} from '#{config.url}':", data + config.callback? data + resolve data + .fail (jqXHR, textStatus, errorThrown) => + switch jqXHR.status + when z.service.BackendClientError::STATUS_CODE.CONNECTIVITY_PROBLEM + @logger.log @logger.levels.WARN, 'Request failed due to connectivity problem.' + @request_queue.push [config, resolve, reject] + @execute_on_connectivity().then => @execute_request_queue() + return + when z.service.BackendClientError::STATUS_CODE.UNAUTHORIZED + @request_queue.push [config, resolve, reject] + @logger.log @logger.levels.WARN, 'Request failed as access token is invalid.' + amplify.publish z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEW + return + when z.service.BackendClientError::STATUS_CODE.FORBIDDEN + switch jqXHR.responseJSON?.label + when z.service.BackendClientError::LABEL.INVALID_CREDENTIALS + Raygun.send new Error 'Server request failed: Invalid credentials' + when z.service.BackendClientError::LABEL.TOO_MANY_CLIENTS, z.service.BackendClientError::LABEL.TOO_MANY_MEMBERS + @logger.log @logger.levels.WARN, "Server request failed: '#{jqXHR.responseJSON.label}'" + else + Raygun.send new Error 'Server request failed' + else + if jqXHR.status not in IGNORED_BACKEND_ERRORS + Raygun.send new Error "Server request failed: #{jqXHR.status}" + + if _.isFunction config.callback + config.callback null, jqXHR.responseJSON or new z.service.BackendClientError errorThrown + else + if navigator.onLine + reject jqXHR.responseJSON or new z.service.BackendClientError jqXHR.status + else + error_data = + code: z.service.BackendClientError::STATUS_CODE.CONNECTIVITY_PROBLEM + label: z.service.BackendClientError::LABEL.CONNECTIVITY_PROBLEM + message: 'Problem with the network connectivity' + reject new z.service.BackendClientError error_data + + ### + Send AJAX request with compressed JSON body. + + @param config [Object] + @option config [Function] callback + @option config [JSON] data + @option config [String] type + @option config [String] url + ### + send_json: (config) -> + @send_request + api_endpoint: config.api_endpoint + callback: config.callback + contentType: 'application/json; charset=utf-8' + data: pako.gzip JSON.stringify config.data if config.data + headers: + 'Content-Encoding': 'gzip' + processData: false + type: config.type + url: config.url diff --git a/app/script/storage/SkipError.coffee b/app/script/storage/SkipError.coffee new file mode 100644 index 00000000000..ce16d39ab67 --- /dev/null +++ b/app/script/storage/SkipError.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.storage ?= {} + +class z.storage.SkipError + constructor: (message) -> + @name = @constructor.name + @message = message + @stack = (new Error()).stack + + @:: = new Error() + @::constructor = @ diff --git a/app/script/storage/StorageKey.coffee b/app/script/storage/StorageKey.coffee new file mode 100644 index 00000000000..2618ba2af2e --- /dev/null +++ b/app/script/storage/StorageKey.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.storage ?= {} + +z.storage.StorageKey = + AUTH: + ACCESS_TOKEN: + VALUE: 'z.storage.StorageKey.AUTH.ACCESS_TOKEN.VALUE' + EXPIRATION: 'z.storage.StorageKey.AUTH.ACCESS_TOKEN.EXPIRATION' + TTL: 'z.storage.StorageKey.AUTH.ACCESS_TOKEN.TTL' + TYPE: 'z.storage.StorageKey.AUTH.ACCESS_TOKEN.TYPE' + COOKIE_LABEL: 'z.storage.StorageKey.AUTH.COOKIE_LABEL' + PERSIST: 'z.storage.StorageKey.AUTH.PERSIST' + SHOW_LOGIN: 'z.storage.StorageKey.AUTH.SHOW_LOGIN' + CONVERSATION: + INPUT: 'z.storage.StorageKey.CONVERSATION.INPUT' + LOCALIZATION: + LOCALE: 'z.storage.StorageKey.LOCALIZATION.LOCALE' + SEARCH: + SUGGESTED_SEARCH_ETS: 'z.storage.StorageKey.SEARCH.SUGGESTED_SEARCH_ETS' + ANNOUNCE: + ANNOUNCE_KEY: 'z.storage.StorageKey.ANNOUNCE.ANNOUNCE_KEY' diff --git a/app/script/storage/StorageRepository.coffee b/app/script/storage/StorageRepository.coffee new file mode 100644 index 00000000000..4e694c39cb1 --- /dev/null +++ b/app/script/storage/StorageRepository.coffee @@ -0,0 +1,427 @@ +# +# 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.storage ?= {} + +class z.storage.StorageRepository extends cryptobox.CryptoboxStore + constructor: (@storage_service) -> + @logger = new z.util.Logger 'z.storage.StorageRepository', z.config.LOGGER.OPTIONS + + @identity = undefined + @prekeys = {} + @sessions = {} + + @sessions_handled = 0 + @sessions_promises = [] + @sessions_total = 0 + @sessions_queue = ko.observableArray [] + + @sessions_queue.subscribe (sessions) => + if sessions.length > 0 + @_deserialize_session @sessions_queue()[0] + .then (session) => + if session + @logger.log @logger.levels.INFO, "De-serialized session '#{@sessions_queue()[0].id}'", session + @sessions_handled++ + if @sessions_handled % 5 is 0 + replace = [@sessions_handled, @sessions_total] + amplify.publish z.event.WebApp.APP.UPDATE_INIT, z.string.init_sessions_progress, false, replace + window.setTimeout => + @sessions_queue.shift() + , 0 + else + @logger.log @logger.levels.INFO, "De-serialized '#{Object.keys(@sessions).length}' sessions", @sessions + @sessions_promises[0] @sessions + + + ############################################################################### + # Initialization + ############################################################################### + + ### + Initialize the repository to setup Cryptobox with the Local Identity Key Pair, Sessions and Pre-Keys. + @return [Promise] Promise that will resolve with the repository after initialization + ### + init: (skip_sessions) => + return new Promise (resolve, reject) => + @_load_identity() + .then (@identity) => + if @identity + @logger.log @logger.levels.INFO, 'Loaded local identity key pair from database', @identity + else + @logger.log @logger.levels.INFO, 'We did not find a local identity. This is a new client.' + return @identity + .then (local_identity) => + return {} if not local_identity + if skip_sessions + throw new z.storage.SkipError 'Skipped loading of sessions and pre-keys' + else + return @_load_sessions() + .then => + return @_load_pre_keys() + .then => + @logger.log @logger.levels.INFO, 'Initialized repository' + resolve @ + .catch (error) => + if error instanceof z.storage.SkipError + @logger.log "Initialized repository with the following exception: #{error.message}" + resolve @ + else + @logger.log @logger.levels.ERROR, "Storage Repository initialization failed: #{error?.message}", error + reject error + + _deserialize_session: (session) -> + return Promise.resolve() + .then => + try + bytes = sodium.from_base64 session.serialised + @sessions[session.id] = Proteus.session.Session.deserialise @identity, bytes.buffer + return @sessions[session.id] + catch error + @logger.log @logger.levels.ERROR, "Session '#{session.id}' is corrupt.", error + # TODO: Consider deleting or repairing corrupt data (like broken sessions) + return undefined + + ### + Loads the user's identity key pair. + @private + @return [Promise] Promise that will resolve with the key pair loaded from storage + ### + _load_identity: => + return new Promise (resolve, reject) => + @storage_service.load @storage_service.OBJECT_STORE_KEYS, 'local_identity' + .then (identity_key_pair) -> + if identity_key_pair + bytes = sodium.from_base64 identity_key_pair.serialised + resolve Proteus.keys.IdentityKeyPair.deserialise bytes.buffer + else + resolve undefined + .catch (error) => + @logger.log @logger.levels.ERROR, "Something failed: #{error?.message}", error + reject error + + ### + Returns a dictionary of all de-serialized records in a given object store. + + @note: To be only used with Cryptobox! + @private + @return [Promise] Promise that will resolve with the de-serialized pre-keys + ### + _load_pre_keys: => + return new Promise (resolve, reject) => + @storage_service.get_all @storage_service.OBJECT_STORE_PREKEYS + .then (pre_keys) => + @logger.log @logger.levels.INFO, "Loaded '#{pre_keys.length}' pre-keys from database" + for pre_key in pre_keys + try + bytes = sodium.from_base64 pre_key.serialised + @prekeys[pre_key.id] = Proteus.keys.PreKey.deserialise bytes.buffer + catch error + @logger.log @logger.levels.ERROR, "Pre-key with primary key '#{pre_key.id}' is corrupt.", error + # TODO: Consider deleting or repairing corrupt data (like broken sessions) + @logger.log @logger.levels.INFO, + "Initialized '#{Object.keys(@prekeys).length}' pre-keys from database", @prekeys + resolve @prekeys + .catch (error) => + @logger.log @logger.levels.WARN, "Failed to load pre-keys from storage: #{error.message}", error + reject error + + _load_sessions: => + return new Promise (resolve, reject) => + @storage_service.get_all @storage_service.OBJECT_STORE_SESSIONS + .then (sessions) => + @sessions_total = sessions.length + amplify.publish z.event.WebApp.APP.UPDATE_INIT, z.string.init_sessions_expectation, true, [@sessions_total] + if @sessions_total > 0 + @logger.log @logger.levels.INFO, "Loaded '#{@sessions_total}' sessions from storage" + @sessions_queue sessions + @sessions_promises = [resolve, reject] + else + @logger.log @logger.levels.INFO, 'No sessions found in storage' + resolve @sessions + .catch (error) => + @logger.log @logger.levels.WARN, "Failed to load sessions from storage: #{error.message}", error + reject error + + ############################################################################### + # Amplify + ############################################################################### + + ### + Get a value for a given primary key from the amplify value store. + + @param primary_key [String] Primary key to retrieve the object for + @return [Promise] Promise that will resolve with the retrieved value + ### + get_value: (primary_key) => + return new Promise (resolve, reject) => + @storage_service.load @storage_service.OBJECT_STORE_AMPLIFY, primary_key + .then (record) -> + if record?.value + resolve record.value + else + reject new Error "Value for primary key '#{primary_key}' not found" + .catch (error) => + @logger.log @logger.levels.ERROR, "Something failed: #{error?.message}", error + reject error + + ### + Save a value in the amplify value store. + + @param primary_key [String] Primary key to save the object with + @param value [value] Object to be stored + @return [Promise] Promise that will resolve with the saved record + ### + save_value: (primary_key, value) => + return @storage_service.save @storage_service.OBJECT_STORE_AMPLIFY, primary_key, value: value + + ### + Closes the database connection. + ### + terminate: -> + @storage_service.terminate() + + ############################################################################### + # Conversation Events + ############################################################################### + + ### + Construct a unique primary key. + + @param conversation_id [String] ID of conversation + @param sender_id [String, undefined] ID of message sender + @param time [String] Time in ISO format to create timestamp from + @return [String] Generated primary key + ### + construct_primary_key: (conversation_id, sender_id = @storage_service.user_id, time) -> + timestamp = new Date(time).getTime() + return "#{conversation_id}@#{sender_id}@#{timestamp}" + + ### + Save an unencrypted conversation event. + + @param event [Object] JSON event to be stored + @return [Promise] Promise that resolves with the stored record + ### + save_unencrypted_conversation_event: (event) -> + return new Promise (resolve, reject) => + primary_key = @construct_primary_key event.conversation, event.from, event.time + + event_object = + raw: event + meta: + timestamp: new Date(event.time).getTime() + version: 1 + + store_name = @storage_service.OBJECT_STORE_CONVERSATION_EVENTS + @storage_service.save store_name, primary_key, event_object + .then (primary_key) -> resolve primary_key + .catch (error) -> reject error + + ### + Save a decrypted conversation event. + + @param primary_key [String] Primary key to save the object with + @param otr_message_event [Object] JSON event to be stored + @param mapped_json [Object] OTR event mapped to its unencrypted counterpart + @return [Promise] Promise that resolves with the stored record + ### + save_decrypted_conversation_event: (primary_key, otr_message_event, mapped_json) -> + return new Promise (resolve, reject) => + event_object = + raw: otr_message_event + meta: + timestamp: new Date(otr_message_event.time).getTime() + version: 1 + mapped: mapped_json + + # We don't need to keep ciphertext once it has been successfully decrypted + event_object.raw.data = undefined + + store_name = @storage_service.OBJECT_STORE_CONVERSATION_EVENTS + @storage_service.save store_name, primary_key, event_object + .then (primary_key) -> resolve primary_key + .catch (error) -> reject error + + ### + Load a conversation event for a given primary key. + + @param primary_key [String] Primary key to save the object with + @return [Promise] Promise that resolves with the retrieved record + ### + load_event_for_conversation: (primary_key) -> + return @storage_service.load @storage_service.OBJECT_STORE_CONVERSATION_EVENTS, primary_key + + ### + Load conversation events by event type. + + @param event_types [Array] Array of event types to match + @return [Promise] Promise that resolves with the retrieved records + ### + load_events_by_types: (event_types) -> + return new Promise (resolve, reject) => + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'raw.type' + .anyOf event_types + .sortBy 'raw.time' + .then (records) -> + resolve records + .catch (error) => + @logger.log @logger.levels.ERROR, "Something failed: #{error?.message}", error + reject error + + ############################################################################### + # Identity + ############################################################################### + + ### + Load the identity key pair. + @override + @return [String] Serialized identity key pair + ### + load_identity: -> + return @identity + + ### + Save the identity key pair. + + @override + @param identity [Proteus.keys.IdentityKeyPair] + @return [Promise] Promise that resolves with the saved record + ### + save_identity: (identity) -> + return new Promise (resolve, reject) => + @identity = identity + payload = serialised: sodium.to_base64 new Uint8Array identity.serialise() + + @storage_service.save @storage_service.OBJECT_STORE_KEYS, 'local_identity', payload + .then (primary_key) => + message = "Saved local identity '#{identity.public_key.fingerprint()}' to db '#{@storage_service.db_name}'" + @logger.log @logger.levels.INFO, message, identity + resolve primary_key + .catch (error) -> reject error + + + ############################################################################### + # Pre-keys + ############################################################################### + + ### + Store a pre-key. + + @override + @param prekey [String] Pre-key to be stored + @return [Promise] Promise that resolves with the saved record + ### + add_prekey: (prekey) -> + return new Promise (resolve, reject) => + @prekeys[prekey.key_id] = prekey + payload = + id: prekey.key_id + serialised: sodium.to_base64 new Uint8Array prekey.serialise() + + @storage_service.save @storage_service.OBJECT_STORE_PREKEYS, "#{prekey.key_id}", payload + .then (primary_key) -> resolve primary_key + .catch (error) -> reject error + + ### + Delete a pre-key + + @override + @param prekey_id [String] Primary key to delete pre-key from store + @return [Promise] Promise that resolves with the deleted record + ### + delete_prekey: (prekey_id) -> + return new Promise (resolve, reject) => + delete @prekeys[prekey_id] + + @storage_service.delete @storage_service.OBJECT_STORE_PREKEYS, "#{prekey_id}" + .then (record) -> resolve record + .catch (error) -> reject error + + ### + Load a pre-key. + + @override + @param prekey_id [String] Primary key to retrieve pre-key for + @return [String] Pre-key + ### + load_prekey: (prekey_id) -> + return @prekeys[prekey_id] + + clear_all_stores: => + @storage_service.clear_all_stores() + .then => @logger.log "Cleared database '#{@storage_service.db_name}'" + + ############################################################################### + # Sessions + ############################################################################### + + ### + Delete a session. + + @override + @param session_id [String] ID of session to be deleted from store + @return [Promise] Promise that resolves with the deleted record + ### + delete_session: (session_id) -> + return new Promise (resolve, reject) => + delete @sessions[session_id] + + @storage_service.delete @storage_service.OBJECT_STORE_SESSIONS, session_id + .then (record) -> resolve record + .catch (error) -> reject error + + ### + Load a session. + + @override + @param identity [String] Unused identity + @param session_id [String] ID of session to be retrieved + @return [Object] Session + ### + load_session: (identity, session_id) -> + return @sessions[session_id] + + ### + Save a session. + + @override + @param session_id [String] ID of session to be stored + @param session [Object] Session to be stored + @return [Promise] Promise that resolves with the saved record + ### + save_session: (session_id, session) -> + return new Promise (resolve, reject) => + @sessions[session_id] = session + payload = + id: session_id + serialised: sodium.to_base64 new Uint8Array session.serialise() + + @storage_service.save @storage_service.OBJECT_STORE_SESSIONS, session_id, payload + .then (primary_key) -> + resolve primary_key + .catch (error) -> reject error + + ### + Nuke the database. + ### + delete_everything: => + @logger.log @logger.levels.WARN, "Deleting database '#{@storage_service.db_name}'" + return @storage_service.delete_everything() diff --git a/app/script/storage/StorageService.coffee b/app/script/storage/StorageService.coffee new file mode 100644 index 00000000000..2a870196bcb --- /dev/null +++ b/app/script/storage/StorageService.coffee @@ -0,0 +1,339 @@ +# +# 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.storage ?= {} + +### +Base class for persistent storage. +### +class z.storage.StorageService + OBJECT_STORE_AMPLIFY: 'amplify' + OBJECT_STORE_CLIENTS: 'clients' + OBJECT_STORE_CONVERSATION_EVENTS: 'conversation_events' + OBJECT_STORE_CONVERSATIONS: 'conversations' + OBJECT_STORE_KEYS: 'keys' + OBJECT_STORE_PREKEYS: 'prekeys' + OBJECT_STORE_SESSIONS: 'sessions' + + constructor: -> + @logger = new z.util.Logger 'z.storage.StorageService', z.config.LOGGER.OPTIONS + + @db = undefined + @db_name = undefined + @user_id = undefined + + return @ + + ############################################################################### + # Initialization + ############################################################################### + + ### + Initialize the IndexedDB for a user. + + @param user_id [String] User ID + @return [Promise] Promise that will resolve with the database name + ### + init: (user_id = @user_id) => + return new Promise (resolve, reject) => + is_permanent = z.storage.get_value z.storage.StorageKey.AUTH.PERSIST + client_type = if is_permanent then z.client.ClientType.PERMANENT else z.client.ClientType.TEMPORARY + + @user_id = user_id + @db_name = "wire@#{z.util.Environment.backend.current}@#{user_id}@#{client_type}" + + # https://github.com/dfahlander/Dexie.js/wiki/Version.stores() + version_1 = + "#{@OBJECT_STORE_AMPLIFY}": '' + "#{@OBJECT_STORE_CLIENTS}": '' + "#{@OBJECT_STORE_CONVERSATION_EVENTS}": ', raw.conversation, raw.time, meta.timestamp' + "#{@OBJECT_STORE_KEYS}": '' + "#{@OBJECT_STORE_PREKEYS}": '' + "#{@OBJECT_STORE_SESSIONS}": '' + + version_2 = + "#{@OBJECT_STORE_AMPLIFY}": '' + "#{@OBJECT_STORE_CLIENTS}": '' + "#{@OBJECT_STORE_CONVERSATION_EVENTS}": ', raw.conversation, raw.time, raw.type, meta.timestamp' + "#{@OBJECT_STORE_KEYS}": '' + "#{@OBJECT_STORE_PREKEYS}": '' + "#{@OBJECT_STORE_SESSIONS}": '' + + version_3 = + "#{@OBJECT_STORE_AMPLIFY}": '' + "#{@OBJECT_STORE_CLIENTS}": '' + "#{@OBJECT_STORE_CONVERSATION_EVENTS}": ', raw.conversation, raw.time, raw.type, meta.timestamp' + "#{@OBJECT_STORE_CONVERSATIONS}": ', id, last_event_timestamp' + "#{@OBJECT_STORE_KEYS}": '' + "#{@OBJECT_STORE_PREKEYS}": '' + "#{@OBJECT_STORE_SESSIONS}": '' + + version_4 = + "#{@OBJECT_STORE_AMPLIFY}": '' + "#{@OBJECT_STORE_CLIENTS}": ', meta.primary_key' + "#{@OBJECT_STORE_CONVERSATION_EVENTS}": ', raw.conversation, raw.time, raw.type, meta.timestamp' + "#{@OBJECT_STORE_CONVERSATIONS}": ', id, last_event_timestamp' + "#{@OBJECT_STORE_KEYS}": '' + "#{@OBJECT_STORE_PREKEYS}": '' + "#{@OBJECT_STORE_SESSIONS}": '' + + @db = new Dexie @db_name + + @db.on 'blocked', => + @logger.log @logger.levels.ERROR, 'Database is blocked' + + # @see https://github.com/dfahlander/Dexie.js/issues/189#issuecomment-196304545 + # @see https://github.com/dfahlander/Dexie.js/wiki/Dexie.UpgradeError + Dexie.Promise.on 'error', (error) => + switch error.name + when Dexie.errnames.Upgrade + @logger.log @logger.levels.ERROR, "Failed to open database '#{@db_name}': #{error.message}", error + else + @logger.log @logger.levels.ERROR, "Database error (#{error.name}): #{error.message}", error + + # @see https://github.com/dfahlander/Dexie.js/wiki/Version.upgrade() + # @see https://github.com/dfahlander/Dexie.js/wiki/WriteableCollection.modify() + @db.version(1).stores version_1 + @db.version(2).stores version_2 + @db.version(3).stores version_3 + @db.version(4).stores version_4 + .upgrade (transaction) => + @logger.log @logger.levels.WARN, 'Database upgrade to version 4 needed', transaction + transaction[@OBJECT_STORE_CLIENTS].toCollection().modify (client) => + client.meta = + is_verified: true + primary_key: 'local_identity' + @logger.log 'Updated client', client + @db.version(5).stores version_4 + @db.version(6).stores version_4 + .upgrade (transaction) => + @logger.log @logger.levels.WARN, 'Database upgrade to version 6 needed', transaction + transaction[@OBJECT_STORE_CONVERSATIONS].toCollection().eachKey (key) => + @db[@OBJECT_STORE_CONVERSATIONS].update key, {id: key} + transaction[@OBJECT_STORE_SESSIONS].toCollection().eachKey (key) => + @db[@OBJECT_STORE_SESSIONS].update key, {id: key} + transaction[@OBJECT_STORE_PREKEYS].toCollection().eachKey (key) => + @db[@OBJECT_STORE_PREKEYS].update key, {id: key} + + @db.open() + .then => + @logger.log @logger.levels.LEVEL_1, "Storage Service initialized with database '#{@db_name}'" + resolve @db_name + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to initialize database '#{@db_name}' for Storage Service" + reject error + + + ############################################################################### + # Interactions + ############################################################################### + + ### + Removes persisted data. + + @param store_name [String] Name of the object store + @param primary_key [String] Primary key + @return [Promise] Promise that will resolve when the object is deleted + ### + delete: (store_name, primary_key) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name].delete primary_key + .then => + @logger.log "Deleted '#{primary_key}' from object store '#{store_name}'" + resolve primary_key + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to delete '#{primary_key}' from store '#{store_name}'", error + reject error + else + reject new Error "Data store '#{store_name}' not found" + + clear_all_stores: => + promises = (@delete_store store_name for store_name of @db._dbSchema) + return Promise.all promises + + ### + @return [Promise] + ### + delete_store: (store_name) => + @logger.log "Clearing object store '#{store_name}' in database '#{@db_name}'" + return @db[store_name].clear() + + ### + Delete the IndexedDB with all its stores. + @return [Promise] Promise that will resolve if a database is found and cleared + ### + delete_everything: => + return new Dexie.Promise (resolve, reject) => + if @db? + @db.delete() + .then => + @logger.log @logger.levels.INFO, "Clearing IndexedDB '#{@db_name}' successful" + resolve true + .catch (error) => + @logger.log @logger.levels.ERROR, "Clearing IndexedDB '#{@db_name}' failed" + reject error + else + @logger.log @logger.levels.ERROR, "IndexedDB '#{@db_name}' not found" + resolve true + + ### + Returns an array of all records for a given object store. + + @param store_name [String] Name of object store + @return [Promise] Promise that will resolve with the records from the object store + ### + get_all: (store_name) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name] + .toArray() + .then (records) -> resolve records + .catch (error) => + @logger.log @logger.levels.ERROR, "Could not load objects from store '#{store_name}'", error + reject error + else + reject new Error "Data store '#{store_name}' not found" + + ### + Returns an array of all keys in a given object store. + + @param store_name [String] Name of object store + @return [Promise] Promise that will resolve with the keys of the object store + ### + get_keys: (store_name, regex_string) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name] + .toCollection() + .keys() + .then (keys) -> + if regex_string is undefined + resolve keys + else + regex = new RegExp regex_string, 'igm' + accepted_keys = keys.filter (key) -> key.match regex + resolve accepted_keys + .catch (error) -> reject error + else + reject new Error "Data store '#{store_name}' not found" + + ### + Loads persisted data via a promise. + + @note If a key cannot be found, it resolves and returns "undefined". + + @param store_name [String] Name of object store + @param primary_key [String] Primary key of object to be retrieved + @return [Promise] Promise that will resolve with the record matching the primary key + ### + load: (store_name, primary_key) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name].get primary_key + .then (record) -> resolve record + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to load '#{primary_key}' from store '#{store_name}'", error + reject error + else + reject new Error "Data store '#{store_name}' not found" + + ### + Loads all objects from an object store and returns them with their keys and values. + + @example Return value: + { + "key_1": { + "property_1": value_1, + "property_2": value_2 + }, + "key_2": { + "property_1": value_1, + "property_2": value_2 + }, + ... + } + + @param store_name [String] Name of object store + @return [Promise] Promise which resolves with an object containing all keys and values + ### + load_all: (store_name, regex_string) => + return new Dexie.Promise (resolve, reject) => + data = {} + + @get_keys store_name, regex_string + .then (keys) => + promises = keys.map (key) => + return new Promise (resolve, reject) => + @load store_name, key + .then (value) -> + data[key] = value + resolve undefined + .catch (error) -> reject error + return Promise.all promises + .then -> resolve data + .catch (error) -> reject error + + ### + Saves objects in the local database. + + @param store_name [String] Name of object store where to save the object + @param primary_key [String] Primary key which should be used to store the object + @return [Promise] Promise that will resolve with the primary key of the persisted object + ### + save: (store_name, primary_key, entity) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name].put entity, primary_key + .then (key) -> resolve key + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to put '#{primary_key}' into store '#{store_name}'", error + reject error + else + reject new Error "Data store '#{store_name}' not found" + + ### + Closes the database. This operation completes immediately and there is no returned Promise. + @see https://github.com/dfahlander/Dexie.js/wiki/Dexie.close() + ### + terminate: -> + @logger.log "Closing database connection with '#{@db.name}'" + @db.close() + + ### + Update previously persisted data via a promise. + + @param store_name [String] Name of object store + @param primary_key [String] Primary key of object to be updated + @param changes [Object] Object containing the key paths to each property you want to change + @return [Promise] Promise with the number of updated records (1 if an object was updated, otherwise 0). + ### + update: (store_name, primary_key, changes) => + return new Dexie.Promise (resolve, reject) => + if @db[store_name]? + @db[store_name].update primary_key, changes + .then (number_of_updates) => + @logger.log @logger.levels.INFO, + "Updated #{number_of_updates} record(s) with key '#{primary_key}' in store '#{store_name}'", changes + resolve number_of_updates + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to update '#{primary_key}' in store '#{store_name}'", error + reject error + else + reject new Error "Data store '#{store_name}' not found" diff --git a/app/script/system_notification/SystemNotificationRepository.coffee b/app/script/system_notification/SystemNotificationRepository.coffee new file mode 100644 index 00000000000..77a3a4b656c --- /dev/null +++ b/app/script/system_notification/SystemNotificationRepository.coffee @@ -0,0 +1,476 @@ +# +# 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.SystemNotification ?= {} + +### +System notification repository to trigger browser and audio notifications. + +@see https://developer.mozilla.org/en/docs/Web/API/notification +@see http://www.w3.org/TR/notifications +### +class z.SystemNotification.SystemNotificationRepository + @::EVENTS_TO_NOTIFY = [ + z.message.SuperType.CALL + z.message.SuperType.CONTENT + z.message.SuperType.MEMBER + z.message.SuperType.PING + z.message.SuperType.SYSTEM + ] + + ### + Construct a new System Notification Repository. + @param conversation_repository [z.conversation.ConversationService] Repository for all conversation interactions + ### + constructor: (@conversation_repository) -> + @logger = new z.util.Logger 'z.SystemNotification.SystemNotificationRepository', z.config.LOGGER.OPTIONS + + @ask_for_permission = true + @notifications = [] + + @muted = true + @subscribe_to_events() + + subscribe_to_events: => + amplify.subscribe z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, @set_muted_state + amplify.subscribe z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, @, @notify + amplify.subscribe z.event.WebApp.SYSTEM_NOTIFICATION.REMOVE_READ, @, @remove_read_notifications + amplify.subscribe z.event.WebApp.SYSTEM_NOTIFICATION.REQUEST_PERMISSION, @, @should_request + + ### + Display browser notification and play sound notification. + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + ### + notify: (conversation_et, message_et) => + return if @muted or message_et.super_type not in @EVENTS_TO_NOTIFY + @_notify_sound conversation_et, message_et + @_notify_banner conversation_et, message_et + + ### + Remove notifications from the queue that are no longer unread + ### + remove_read_notifications: => + for notification in @notifications + return if not notification.data? + conversation_id = notification.data.conversation_id + message_id = notification.data.message_id + if @conversation_repository.is_message_read conversation_id, message_id + notification.close() + @logger.log @logger.levels.INFO, "Removed read notification for '#{message_id}' in '#{conversation_id}'." + + ### + Request browser permission for notifications. + @param on_permission_granted [Function] Function to be called when permission is granted + @param on_permission_denied [Function] Function to be called when permission is denied + ### + request_permission: (on_permission_granted, on_permission_denied) -> + return if not z.util.Environment.browser.supports.notifications + if window.Notification.permission is z.util.BrowserPermissionType.DEFAULT + amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.REQUEST_NOTIFICATION + # Note: The callback will be only triggered in Chrome. + # If you ignore a permission request on Firefox, then the callback will not be triggered. + window.Notification.requestPermission? (permission) -> + amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.REQUEST_NOTIFICATION + if permission is z.util.BrowserPermissionType.GRANTED + amplify.publish z.event.WebApp.ANALYTICS.EVENT, + z.tracking.EventName.PERMISSION.ALLOW_NOTIFICATIONS, value: 'allow' + on_permission_granted?() + else + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.REQUEST_PERMISSION, false + amplify.publish z.event.WebApp.ANALYTICS.EVENT, + z.tracking.EventName.PERMISSION.ALLOW_NOTIFICATIONS, value: 'block' + on_permission_denied?() + + ### + Set the muted state. + @note Temporarily mute notifications on recovery from Notification Stream + @param muted [Boolean] Are sounds and notifications muted + ### + set_muted_state: (handling_notifications) => + @muted = handling_notifications + @logger.log @logger.levels.INFO, "Setting muted state to: #{handling_notifications}" + + ### + @param should_request [Boolean] True, when permission should be requested + ### + should_request: (should_request) -> + @ask_for_permission = should_request + + ### + Sending the browser notification. + + @param notification_content [Object] + @option notification_content [String] title + @option notification_content [Object] options + @option notification_content [Function] trigger + @option notification_content [Integer] timeout + ### + show: (notification_content) -> + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.SHOW, notification_content + @_show_notification notification_content + + ### + Check for browser permission if we have not yet asked. + + @param success_callback [Function] Function to be called if permission is granted + ### + _check_permission: (success_callback) => + if @ask_for_permission is true + switch window.Notification.permission + when z.util.BrowserPermissionType.DEFAULT + @request_permission success_callback + when z.util.BrowserPermissionType.GRANTED + success_callback() + + ### + Creates the notification body for calls. + @private + @return [String] Notification message body + ### + _create_body_call: (message_et) -> + if message_et.is_call_activation() + return z.localization.Localizer.get_text z.string.system_notification_voice_channel_activate + else if message_et.is_call_deactivation() + return if message_et.finished_reason isnt z.calling.enum.CallFinishedReason.MISSED + return z.localization.Localizer.get_text z.string.system_notification_voice_channel_deactivate + + ### + Creates the notification body for text messages and pictures. + + @private + @param message_et [z.entity.NormalMessage] Normal message entity + @return [String] Notification message body + #### + _create_body_content: (message_et) -> + if message_et.has_asset_text() + for asset_et in message_et.assets() when asset_et.is_text() + return z.util.trunc_text asset_et.text, z.config.BROWSER_NOTIFICATION.BODY_LENGTH + else if message_et.has_asset_medium_image() + return z.localization.Localizer.get_text z.string.system_notification_asset_add + else if message_et.has_asset() + asset_et = message_et.assets()[0] + return z.localization.Localizer.get_text z.string.system_notification_shared_audio if asset_et.is_audio() + return z.localization.Localizer.get_text z.string.system_notification_shared_video if asset_et.is_video() + return z.localization.Localizer.get_text z.string.system_notification_shared_file if asset_et.is_file() + + ### + Creates the notification body for a renamed conversation. + + @private + @param message_et [z.entity.RenameMessage] Rename message entity + @return [String] Notification message body + ### + _create_body_conversation_rename: (message_et) -> + return z.localization.Localizer.get_text { + id: z.string.system_notification_conversation_rename + replace: [ + {placeholder: '%s.first_name', content: message_et.user().first_name()} + {placeholder: '%name', content: message_et.name} + ] + } + + ### + Creates the notification body for people being added to a group conversation. + + @private + @param message_et [z.entity.Message] Member message entity + @return [String] Notification message body + ### + _create_body_member_join: (message_et) -> + if message_et.user_ets().length is 1 + return z.localization.Localizer.get_text { + id: z.string.system_notification_member_join_one + replace: [ + {placeholder: '%s.first_name', content: message_et.user().first_name()} + { + placeholder: '%@.first_name' + content: z.util.get_first_name message_et.user_ets()[0], z.string.Declension.ACCUSATIVE + } + ] + } + return z.localization.Localizer.get_text { + id: z.string.system_notification_member_join_many + replace: [ + {placeholder: '%s.first_name', content: message_et.user().first_name()} + {placeholder: '%no', content: message_et.user_ids().length} + ] + } + + + ### + Creates the notification body for people being removed from or leaving a group conversation. + + @private + @param message_et [z.entity.MemberMessage] Member message entity + @return [String] Notification message body + ### + _create_body_member_leave: (message_et) -> + if message_et.user_ets().length is 1 + if message_et.user_ets()[0] is message_et.user() + return z.localization.Localizer.get_text { + id: z.string.system_notification_member_leave_left + replace: {placeholder: '%s.first_name', content: message_et.user().first_name()} + } + return z.localization.Localizer.get_text { + id: z.string.system_notification_member_leave_removed_one + replace: [ + {placeholder: '%s.first_name', content: message_et.user().first_name()} + { + placeholder: '%@.first_name' + content: z.util.get_first_name message_et.user_ets()[0], z.string.Declension.ACCUSATIVE + } + ] + } + return z.localization.Localizer.get_text { + id: z.string.system_notification_member_leave_removed_many + replace: [ + {placeholder: '%s.first_name', content: message_et.user().first_name()} + {placeholder: '%no', content: message_et.user_ets().length} + ] + } + + ### + Selects the type of system message that the notification body needs to be created for. + + @private + @param message_et [z.entity.MemberMessage] Member message entity + @param is_group_conversation [Boolean] Is a group conversation + @return [String] Notification message body + ### + _create_body_member_update: (message_et, is_group_conversation) -> + switch message_et.member_message_type + when z.message.SystemMessageType.NORMAL + return if not is_group_conversation + if message_et.type is z.event.Backend.CONVERSATION.MEMBER_JOIN + return @_create_body_member_join message_et + else if message_et.type is z.event.Backend.CONVERSATION.MEMBER_LEAVE + return @_create_body_member_leave message_et + when z.message.SystemMessageType.CONNECTION_ACCEPTED + return z.localization.Localizer.get_text z.string.system_notification_connection_accepted + when z.message.SystemMessageType.CONNECTION_REQUEST + return z.localization.Localizer.get_text z.string.system_notification_connection_request + when z.message.SystemMessageType.CONVERSATION_CREATE + return z.localization.Localizer.get_text { + id: z.string.system_notification_conversation_create + replace: {placeholder: '%s.first_name', content: message_et.user().first_name()} + } + + ### + Creates the notification body for ping and hot-ping. + + @private + @param message_et [z.entity.PingMessage] Ping message entity + @return [String] Notification message body + ### + _create_body_ping: (message_et) -> + return z.localization.Localizer.get_text z.string.system_notification_ping + + ### + Selects the type of system message that the notification body needs to be created for. + + @private + @param message_et [z.entity.MemberMessage] Member message entity + @param conversation_type [z.conversation.ConversationType] Type of the conversation + @return [String] Notification message body + ### + _create_body_system: (message_et) => + switch message_et.system_message_type + when z.message.SystemMessageType.CONVERSATION_RENAME + return @_create_body_conversation_rename + ### + Selects the type of message that the notification body needs to be created for. + + @private + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + @return [String] Notification message body + ### + _create_options_body: (conversation_et, message_et) => + switch message_et.super_type + when z.message.SuperType.CALL + return @_create_body_call message_et + when z.message.SuperType.CONTENT + return @_create_body_content message_et + when z.message.SuperType.MEMBER + return @_create_body_member_update message_et, conversation_et.is_group?() + when z.message.SuperType.PING + return @_create_body_ping message_et + when z.message.SuperType.SYSTEM + return @_create_body_conversation_rename message_et + + ### + Creates the notification data to help check its content. + + @private + @param input [z.entity.Conversation, z.entity.Connection] Information to grab the conversation ID from + @param message_et [z.entity.Message] Message entity + @return [String] Notification message data + ### + _create_options_data: (input, message_et) -> + return {} = + conversation_id: input.id or input.conversation_id + message_id: message_et.id + + ### + Creates the notification tag. + + @private + @param input [z.entity.Conversation, z.entity.Connection] Information to create the tag from + @return [String] Notification message tag + ### + _create_options_tag: (input) -> + return input.id or input.conversation_id + + ### + Creates the notification title. + + @private + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + @return [String] Notification message title + ### + _create_title: (conversation_et, message_et) -> + if conversation_et.display_name?() + if conversation_et.is_group() + return z.util.trunc_text "#{message_et.user().first_name()} in #{conversation_et.display_name()}", z.config.BROWSER_NOTIFICATION.TITLE_LENGTH, false + return z.util.trunc_text conversation_et.display_name(), z.config.BROWSER_NOTIFICATION.TITLE_LENGTH, false + if not message_et.user() + Raygun.send new Error 'Message does not contain user info' + else + return z.util.trunc_text message_et.user().name(), z.config.BROWSER_NOTIFICATION.TITLE_LENGTH, false + + ### + Creates the notification trigger. + + @private + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + @return [Function] Function to be called when notification is clicked + ### + _create_trigger: (conversation_et, message_et) -> + if message_et.is_member() + switch message_et.member_message_type + when z.message.SystemMessageType.CONNECTION_ACCEPTED + return -> amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et.conversation_id + when z.message.SystemMessageType.CONNECTION_REQUEST + return -> amplify.publish z.event.WebApp.PENDING.SHOW + return -> amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + + ### + Creates the browser notification and sends it. + + @private + @see https://developer.mozilla.org/en/docs/Web/API/notification#Parameters + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + ### + _notify_banner: (conversation_et, message_et) -> + return if z.util.Environment.browser.supports.notifications is false + return if window.Notification.permission is z.util.BrowserPermissionType.DENIED + return if document.hasFocus() + return if message_et.user()?.is_me + return if conversation_et.is_muted?() + + notification_content = + title: @_create_title conversation_et, message_et + options: + body: @_create_options_body conversation_et, message_et + data: @_create_options_data conversation_et, message_et + icon: if z.util.Environment.electron and z.util.Environment.os.mac then '' else window.notification_icon or '/image/logo/notification.png' + tag: @_create_options_tag conversation_et + silent: true #@note When Firefox supports this we can remove the fix for WEBAPP-731 + timeout: z.config.BROWSER_NOTIFICATION.TIMEOUT + trigger: @_create_trigger conversation_et, message_et + + return if not notification_content.options.body? + + @_check_permission => @show notification_content + + ### + Plays the sound from the audio repository. + + @private + @param conversation_et [z.entity.Conversation] Conversation entity + @param message_et [z.entity.Message] Message entity + ### + _notify_sound: (conversation_et, message_et) -> + return if conversation_et.is_muted?() + return if not document.hasFocus() and z.util.Environment.browser.firefox and z.util.Environment.os.mac + switch message_et.super_type + when z.message.SuperType.CONTENT + return if message_et.user().is_me + return if message_et.has_asset_preview_image() + unless document.hasFocus() and conversation_et.id is @conversation_repository.active_conversation()?.id + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.NEW_MESSAGE + when z.message.SuperType.PING + if message_et.user().is_me + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.OUTGOING_PING + else + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.INCOMING_PING + + ### + Sending the browser notification. + + @private + @param notification_content [Object] + @option notification_content [String] title + @option notification_content [Object] options + @option notification_content [Function] trigger + @option notification_content [Integer] timeout + ### + _show_notification: (notification_content) -> + ### + @note Notification.data is only supported on Chrome + @see https://developer.mozilla.org/en-US/docs/Web/API/Notification/data + ### + @remove_read_notifications() + notification = new window.Notification notification_content.title, notification_content.options + conversation_id = notification_content.options.data.conversation_id + message_id = notification_content.options.data.message_id + timeout_trigger_id = undefined + notification.onclick = => + notification_content.trigger() + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.CLICK + @logger.log @logger.levels.INFO, "Notification for '#{message_id} in '#{conversation_id}' closed by click." + window.focus() + notification.close() + notification.onclose = => + clearTimeout timeout_trigger_id + @notifications.splice @notifications.indexOf(notification), 1 + @logger.log @logger.levels.INFO, "Removed notification for '#{message_id}' in '#{conversation_id}' locally." + notification.onerror = => + @logger.log @logger.levels.ERROR, "Notification for '#{message_id}' in '#{conversation_id}' closed by error." + notification.close() + notification.onshow = => + timeout_trigger_id = setTimeout => + @logger.log @logger.levels.INFO, "Notification for '#{message_id}' in '#{conversation_id}' closed by timeout." + notification.close() + , notification_content.timeout + + @notifications.push notification + @logger.log @logger.levels.INFO, "Added notification for '#{message_id}' in '#{conversation_id}' to queue." + window.onunload = => + for notification in @notifications + notification.close() + + return if not notification.data? + conversation_id = notification.data.conversation_id + message_id = notification.data.message_id + @logger.log @logger.levels.INFO, "Notification for '#{message_id}' in '#{conversation_id}' closed by redirect." diff --git a/app/script/telemetry/app_init/AppInitStatistics.coffee b/app/script/telemetry/app_init/AppInitStatistics.coffee new file mode 100644 index 00000000000..39c7346be99 --- /dev/null +++ b/app/script/telemetry/app_init/AppInitStatistics.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.telemetry ?= {} +z.telemetry.app_init ?= {} + +class z.telemetry.app_init.AppInitStatistics + constructor: -> + @logger = new z.util.Logger 'z.telemetry.app_init.AppInitStatistics', z.config.LOGGER.OPTIONS + + amplify.subscribe z.event.WebApp.TELEMETRY.BACKEND_REQUESTS, @update_backend_requests + + add: (statistic, value, bucket_size) => + if bucket_size and _.isNumber value + buckets = Math.floor(value / bucket_size) + if value % bucket_size then 1 else 0 + @[statistic] = if value is 0 then 0 else bucket_size * buckets + else + @[statistic] = value + + get: => + statistics = {} + statistics[key] = value for key, value of @ when _.isNumber(value) or _.isString value + return statistics + + log: => + @logger.log @logger.levels.DEBUG, 'App initialization statistics' + for key, value of @ when _.isNumber(value) or _.isString value + placeholder_key = Array(Math.max 17 - key.length, 1).join ' ' + placeholder_value = Array(Math.max 11 - value.toString().length, 1).join ' ' + @logger.log @logger.levels.INFO, "#{placeholder_key}'#{key}':#{placeholder_value}#{value}" + + update_backend_requests: (number_of_requests) => + @[z.telemetry.app_init.AppInitStatisticsValue.BACKEND_REQUESTS] = number_of_requests diff --git a/app/script/telemetry/app_init/AppInitStatisticsValue.coffee b/app/script/telemetry/app_init/AppInitStatisticsValue.coffee new file mode 100644 index 00000000000..2d4d469d527 --- /dev/null +++ b/app/script/telemetry/app_init/AppInitStatisticsValue.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.telemetry ?= {} +z.telemetry.app_init ?= {} + +z.telemetry.app_init.AppInitStatisticsValue = + BACKEND_REQUESTS: 'backend_requests' + CLIENT_TYPE: 'client_type' + CLIENTS: 'clients' + CONNECTIONS: 'connections' + CONVERSATIONS: 'conversations' + NOTIFICATIONS: 'notifications' + SESSIONS: 'sessions' diff --git a/app/script/telemetry/app_init/AppInitTelemetry.coffee b/app/script/telemetry/app_init/AppInitTelemetry.coffee new file mode 100644 index 00000000000..81db703a85a --- /dev/null +++ b/app/script/telemetry/app_init/AppInitTelemetry.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.telemetry ?= {} +z.telemetry.app_init ?= {} + +class z.telemetry.app_init.AppInitTelemetry + constructor: -> + @logger = new z.util.Logger 'z.telemetry.app_init.AppInitTelemetry', z.config.LOGGER.OPTIONS + @timings = new z.telemetry.app_init.AppInitTimings() + @statistics = new z.telemetry.app_init.AppInitStatistics() + + add_statistic: (statistic, value, bucket_size) => + @statistics.add statistic, value, bucket_size + + get_statistics: => + @statistics.get() + + get_timings: => + @timings.get() + + log_statistics: => + @statistics.log() + + log_timings: => + @timings.log() + + report: => + statistics = @get_statistics() + statistics.loading_time = @timings.get_app_load() + statistics.app_version = z.util.Environment.version false + @logger.log @logger.levels.DEBUG, 'App initialization telemetry' + @logger.log @logger.levels.INFO, "App version '#{statistics.app_version}' initialized within #{statistics.loading_time}s" + @log_statistics() + @log_timings() + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.TELEMETRY.APP_INITIALIZATION, statistics + + time_step: (step) => + @timings.time_step step diff --git a/app/script/telemetry/app_init/AppInitTimings.coffee b/app/script/telemetry/app_init/AppInitTimings.coffee new file mode 100644 index 00000000000..f59143985ee --- /dev/null +++ b/app/script/telemetry/app_init/AppInitTimings.coffee @@ -0,0 +1,46 @@ +# +# 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.telemetry ?= {} +z.telemetry.app_init ?= {} + +class z.telemetry.app_init.AppInitTimings + constructor: -> + @logger = new z.util.Logger 'z.telemetry.AppInitTimings', z.config.LOGGER.OPTIONS + @init = window.performance.now() + + get: => + timings = {} + timings[key] = value for key, value of @ when key.toString() isnt 'init' and _.isNumber value + return timings + + get_app_load: => + bucket_size = 10 + return bucket_size * (Math.floor(@[z.telemetry.app_init.AppInitTimingsStep.SHOWING_UI] / bucket_size / 1000) + 1) + + log: => + @logger.log @logger.levels.DEBUG, 'App initialization step durations' + for key, value of @ when key.toString() isnt 'init' and _.isNumber value + placeholder_key = Array(Math.max 27 - key.length, 1).join ' ' + placeholder_value = Array(Math.max 6 - value.toString().length, 1).join ' ' + @logger.log @logger.levels.INFO, "#{placeholder_key}'#{key}':#{placeholder_value}#{value}ms" + + time_step: (step) => + if not @[step] + @[step] = window.parseInt window.performance.now() - @init diff --git a/app/script/telemetry/app_init/AppInitTimingsStep.coffee b/app/script/telemetry/app_init/AppInitTimingsStep.coffee new file mode 100644 index 00000000000..0098bd64ba9 --- /dev/null +++ b/app/script/telemetry/app_init/AppInitTimingsStep.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.telemetry ?= {} +z.telemetry.app_init ?= {} + +z.telemetry.app_init.AppInitTimingsStep = + RECEIVED_ACCESS_TOKEN: 'received_access_token' + INITIALIZED_PROTO_MESSAGES: 'initialized_proto_messages' + RECEIVED_SELF_USER: 'received_self_user' + INITIALIZED_STORAGE: 'initialized_storage' + INITIALIZED_CRYPTOGRAPHY: 'initialized_cryptography' + VALIDATED_CLIENT: 'validated_client' + RECEIVED_USER_DATA: 'received_user_data' + UPDATED_FROM_NOTIFICATIONS: 'updated_from_notifications' + APP_PRE_LOADED: 'app_pre_loaded' + SHOWING_UI: 'showing_ui' + APP_LOADED: 'app_loaded' + UPDATED_CONVERSATIONS: 'updated_conversations' diff --git a/app/script/telemetry/calling/AudioStreamStats.coffee b/app/script/telemetry/calling/AudioStreamStats.coffee new file mode 100644 index 00000000000..f056bbaa09f --- /dev/null +++ b/app/script/telemetry/calling/AudioStreamStats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +class z.telemetry.calling.AudioStreamStats extends z.telemetry.calling.MediaStreamStats + constructor: (timestamp) -> + super timestamp + @media_type = z.calling.enum.MediaType.AUDIO + @volume_received = 0 + @volume_sent = 0 diff --git a/app/script/telemetry/calling/CallSetupSteps.coffee b/app/script/telemetry/calling/CallSetupSteps.coffee new file mode 100644 index 00000000000..a93f4370139 --- /dev/null +++ b/app/script/telemetry/calling/CallSetupSteps.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.telemetry ?= {} +z.telemetry.calling ?= {} + +z.telemetry.calling.CallSetupSteps = + ICE_CONNECTION_COMPLETED: 'ice_connection_completed' + ICE_CONNECTION_CONNECTED: 'ice_connection_connected' + ICE_CONNECTION_CHECKING: 'ice_connection_checking' + ICE_GATHERING_COMPLETED: 'ice_gathering_completed' + ICE_GATHERING_STARTED: 'ice_gathering_started' + FLOW_RECEIVED: 'flow_received' + LOCAL_SDP_CREATED: 'local_sdp_created' + LOCAL_SDP_SEND: 'local_sdp_send' + LOCAL_SDP_SET: 'local_sdp_set' + PEER_CONNECTION_CREATED: 'peer_connection_created' + REMOTE_SDP_RECEIVED: 'remote_sdp_received' + REMOTE_SDP_SET: 'remote_sdp_set' + STARTED: 'started' + STATE_PUT: 'state_put' + STREAM_RECEIVED: 'stream_received' + STREAM_REQUESTED: 'stream_requested' diff --git a/app/script/telemetry/calling/CallSetupStepsOrder.coffee b/app/script/telemetry/calling/CallSetupStepsOrder.coffee new file mode 100644 index 00000000000..33e827ba57e --- /dev/null +++ b/app/script/telemetry/calling/CallSetupStepsOrder.coffee @@ -0,0 +1,57 @@ +# +# 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.telemetry ?= {} +z.telemetry.calling ?= {} + +z.telemetry.calling.CallSetupStepsOrder = + ANSWER: [ + z.telemetry.calling.CallSetupSteps.STREAM_REQUESTED + z.telemetry.calling.CallSetupSteps.STREAM_RECEIVED + z.telemetry.calling.CallSetupSteps.STATE_PUT + z.telemetry.calling.CallSetupSteps.FLOW_RECEIVED + z.telemetry.calling.CallSetupSteps.PEER_CONNECTION_CREATED + z.telemetry.calling.CallSetupSteps.REMOTE_SDP_RECEIVED + z.telemetry.calling.CallSetupSteps.REMOTE_SDP_SET + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_CREATED + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SET + z.telemetry.calling.CallSetupSteps.ICE_GATHERING_STARTED + z.telemetry.calling.CallSetupSteps.ICE_GATHERING_COMPLETED + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SEND + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CHECKING + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CONNECTED + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_COMPLETED + ] + OFFER: [ + z.telemetry.calling.CallSetupSteps.STREAM_REQUESTED + z.telemetry.calling.CallSetupSteps.STREAM_RECEIVED + z.telemetry.calling.CallSetupSteps.STATE_PUT + z.telemetry.calling.CallSetupSteps.FLOW_RECEIVED + z.telemetry.calling.CallSetupSteps.PEER_CONNECTION_CREATED + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_CREATED + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SET + z.telemetry.calling.CallSetupSteps.ICE_GATHERING_STARTED + z.telemetry.calling.CallSetupSteps.ICE_GATHERING_COMPLETED + z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SEND + z.telemetry.calling.CallSetupSteps.REMOTE_SDP_RECEIVED + z.telemetry.calling.CallSetupSteps.REMOTE_SDP_SET + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CHECKING + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CONNECTED + z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_COMPLETED + ] diff --git a/app/script/telemetry/calling/CallSetupTimings.coffee b/app/script/telemetry/calling/CallSetupTimings.coffee new file mode 100644 index 00000000000..b0765f4dbde --- /dev/null +++ b/app/script/telemetry/calling/CallSetupTimings.coffee @@ -0,0 +1,68 @@ +# +# 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.telemetry ?= {} +z.telemetry.calling ?= {} + +class z.telemetry.calling.CallSetupTimings + constructor: (@call_id) -> + @logger = new z.util.Logger 'z.telemetry.calling.CallSetupTimings', z.config.LOGGER.OPTIONS + @is_answer = false + @flow_id = undefined + + @started = window.performance.now() + @stream_requested = 0 + @stream_received = 0 + @state_put = 0 + @flow_received = 0 + @peer_connection_created = 0 + @remote_sdp_received = 0 + @remote_sdp_set = 0 + @local_sdp_created = 0 + @local_sdp_send = 0 + @local_sdp_set = 0 + @ice_gathering_started = 0 + @ice_gathering_completed = 0 + @ice_connection_checking = 0 + @ice_connection_connected = 0 + @ice_connection_completed = 0 + + get: => + timings = {} + for step in @_steps_order() + timings[step] = @[step] + return timings + + time_step: (step) -> + if @[step] is 0 + @[step] = window.parseInt window.performance.now() - @started + + log: => + @logger.log @logger.levels.INFO, "Call setup duration for flow ID '#{@flow_id}' of call ID '#{@call_id}'" + for step in @_steps_order() + placeholder_key = Array(Math.max 26 - step.length, 1).join ' ' + placeholder_value = Array(Math.max 6 - @[step].toString().length, 1).join ' ' + @logger.log @logger.levels.INFO, "Step#{placeholder_key}'#{step}':#{placeholder_value}#{@[step]}ms" + + _steps_order: -> + if @is_answer + order = z.telemetry.calling.CallSetupStepsOrder.ANSWER + else + order = z.telemetry.calling.CallSetupStepsOrder.OFFER + return order diff --git a/app/script/telemetry/calling/CallTelemetry.coffee b/app/script/telemetry/calling/CallTelemetry.coffee new file mode 100644 index 00000000000..c661dd1ebb3 --- /dev/null +++ b/app/script/telemetry/calling/CallTelemetry.coffee @@ -0,0 +1,198 @@ +# +# 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.telemetry ?= {} +z.telemetry.calling ?= {} + +# Call traces entity. +class z.telemetry.calling.CallTelemetry + constructor: -> + @logger = new z.util.Logger 'z.telemetry.calling.CallTelemetry', z.config.LOGGER.OPTIONS + + @sessions = {} + @requests = {} + @traces = {} + + + ############################################################################### + # Call traces + ############################################################################### + + ### + Add an event to the call trace. + @param event [JSON] Backend event + ### + trace_event: (event) => + @traces[event.conversation] ?= [] + + timing_incoming = Date.now() + + @traces[event.conversation].push { + from: 'backend' + to: 'us' + transport: 'WebSocket' + response: + payload: event + timestamp: timing_incoming + timestamp_iso_8601: new Date(timing_incoming).toISOString() + } + + ### + Add a backend response to the debug trace. + @param conversation_id [String] Conversation ID of call + @param jqXHR [jQuery XMLHttpRequest] jQuery object of backend response + ### + trace_request: (conversation_id, jqXHR) -> + timestamp_incoming = jqXHR.wire.responded.getTime() + timestamp_outgoing = jqXHR.wire.requested.getTime() + + request_duration = timestamp_incoming - timestamp_outgoing + request_type = "#{jqXHR.wire.original_request_options.type} #{jqXHR.wire.original_request_options.api_endpoint}" + @requests[request_type] ?= [] + @requests[request_type].push request_duration + hits = @requests[request_type].length + average = z.util.Statistics.average @requests[request_type] + standard_deviation = z.util.Statistics.standard_deviation @requests[request_type], average + @logger.log @logger.levels.INFO, "Request #{request_type} took #{request_duration}ms" + @logger.log @logger.levels.INFO, "# of requests #{hits} - Avg: #{average}ms | SD: #{standard_deviation}" + + trace_payload = {} + if jqXHR.wire.original_request_options.data + trace_payload = JSON.parse z.util.types.convert_array_buffer_to_string jqXHR.wire.original_request_options.data + + @traces[conversation_id] ?= [] + @traces[conversation_id].push { + from: 'us' + to: 'backend' + transport: 'REST' + request: + method: jqXHR.wire.original_request_options.type + url: jqXHR.wire.original_request_options.url + payload: trace_payload + timestamp: timestamp_outgoing + timestamp_iso_8601: new Date(timestamp_outgoing).toISOString() + response: + status: + code: jqXHR.status + text: jqXHR.statusText + payload: jqXHR.responseJSON + timestamp: timestamp_incoming + timestamp_iso_8601: new Date(timestamp_incoming).toISOString() + } + + + ############################################################################### + # Sessions + ############################################################################### + + # Force log last call session IDs. + log_sessions: => + @logger.force_log 'Your last session IDs:' + sorted_sessions = z.util.sort_object_by_keys @sessions, true + @logger.force_log tracking_info.to_string() for session_id, tracking_info of sorted_sessions + return sorted_sessions + + ### + Track session ID. + @param conversation_id [String] ID of conversation for call session + @param event [JSON] Call event from backend + ### + track_session: (conversation_id, event) => + @sessions[event.session] = new z.calling.CallTrackingInfo { + conversation_id: conversation_id + session_id: event.session + } + + + ############################################################################### + # Error reporting + ############################################################################### + + ### + Report an error to Raygun. + @param description [String] Error description + @param custom_date [Object] Custom data passed into the report + ### + report_error: (description, passed_error) -> + raygun_error = new Error description + + if passed_error + custom_data = + error: passed_error + raygun_error.stack = passed_error.stack + + Raygun.send raygun_error, custom_data + + + ############################################################################### + # Analytics + ############################################################################### + + ### + Reports call events for call tracking to Localytics. + @param event_name [z.tracking.EventName] String for call event + @param call_et [z.calling.Call] Call entity + @param attributes [Object] Attributes for the event + ### + track_event: (event_name, call_et, attributes) -> + if call_et + attributes = + conversation_participants: call_et.conversation_et.number_of_participants() + conversation_participants_in_call: call_et.max_number_of_participants + conversation_type: if call_et.is_group() then 'group' else 'one_to_one' + + if call_et.is_remote_videod() + event_name = event_name.replace '_call', '_video_call' + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event_name, attributes + + # Track the call duration. + track_duration: (call_et) => + duration = Math.floor (Date.now() - call_et.timer_start) / 1000 + if not window.isNaN duration + @logger.log @logger.levels.INFO, "Call duration: #{duration} seconds.", call_et.duration_time() + + if duration <= 15 + duration_bucket = '0s-15s' + else if duration <= 30 + duration_bucket = '16s-30s' + else if duration <= 60 + duration_bucket = '31s-60s' + else if duration <= 3 * 60 + duration_bucket = '61s-3min' + else if duration <= 10 * 60 + duration_bucket = '3min-10min' + else if duration <= 60 * 60 + duration_bucket = '10min-1h' + else + duration_bucket = '1h-infinite' + + attributes = + conversation_participants: call_et.conversation_et.number_of_participants() + conversation_participants_in_call: call_et.max_number_of_participants + conversation_type: if call_et.is_group() then 'group' else 'one_to_one' + duration: duration_bucket + duration_sec: duration + reason: call_et.finished_reason + + event_name = z.tracking.EventName.CALLING.ENDED_CALL + if call_et.is_remote_videod() + event_name = event_name.replace '_call', '_video_call' + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event_name, attributes diff --git a/app/script/telemetry/calling/ConnectionStats.coffee b/app/script/telemetry/calling/ConnectionStats.coffee new file mode 100644 index 00000000000..30e7ff9cebd --- /dev/null +++ b/app/script/telemetry/calling/ConnectionStats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +# Connection stats entity. +class z.telemetry.calling.ConnectionStats + # Construct a new connection stats report. + constructor: -> + @timestamp = Date.now() + @connected = undefined + + @audio = new z.telemetry.calling.AudioStreamStats @timestamp + @peer_connection = new z.telemetry.calling.StreamStats @timestamp + @video = new z.telemetry.calling.VideoStreamStats @timestamp diff --git a/app/script/telemetry/calling/FlowTelemetry.coffee b/app/script/telemetry/calling/FlowTelemetry.coffee new file mode 100644 index 00000000000..004f1d56aa3 --- /dev/null +++ b/app/script/telemetry/calling/FlowTelemetry.coffee @@ -0,0 +1,458 @@ +# +# 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.telemetry ?= {} +z.telemetry.calling ?= {} + +# Flow telemetry entity. +class z.telemetry.calling.FlowTelemetry + ### + Construct new flow telemetry entity. + + @param id [String] Flow ID + @param remote_user_id [String] Remote user ID + @param call_et [z.calling.Call] Call entity + @param timings [z.telemetry.calling.CallSetupTimings] Timings of call setup steps + ### + constructor: (id, @remote_user_id, @call_et, timings) -> + @logger = new z.util.Logger "z.telemetry.calling.FlowTelemetry (#{id})", z.config.LOGGER.OPTIONS + + @id = id + @is_answer = false + @peer_connection = undefined + + @timings = timings + @statistics = new z.telemetry.calling.ConnectionStats() + + @stats_poller = undefined + + + ############################################################################### + # External misc + ############################################################################### + + ### + Create flow status report. + @param passed_error [Error] Optional error to be added to report + @return [Object] Report + ### + create_report: (passed_error) => + report = + meta: + browser_name: z.util.Environment.browser.name + browser_version: z.util.Environment.browser.version + flow_id: @id + id: @call_et.id + is_answer: @is_answer + session_id: @call_et.session_id() + telemetry: + statistics: @get_statistics() + timings: @get_timings() + + if @peer_connection + report.rtc_peer_connection = + ice_connection_state: @peer_connection.iceConnectionState + ice_gathering_state: @peer_connection.iceGatheringState + signaling_state: @peer_connection.signalingState + + if @peer_connection.localDescription? + rtc_peer_connection = + local_SDP: @peer_connection.localDescription.sdp + local_SDP_type: @peer_connection.localDescription.type + $.extend report.rtc_peer_connection, rtc_peer_connection + + if @peer_connection.remoteDescription? + rtc_peer_connection = + remote_SDP: @peer_connection.remoteDescription.sdp + remote_SDP_type: @peer_connection.remoteDescription.type + $.extend report.rtc_peer_connection, rtc_peer_connection + + if passed_error + report.error = passed_error + + return report + + ### + Check stream for flowing bytes. + + @param media_type [z.calling.enum.MediaType] Media type of stream + @param timeout [Number] Time in milliseconds since the check was scheduled + @param attempt [Number] Attempt of stream check + ### + check_stream: (media_type, timeout, attempt = 1) => + stats = @statistics[media_type] + if stats + seconds = attempt * timeout / 1000 + if stats.bytes_received is 0 and stats.bytes_sent is 0 + @logger.log @logger.levels.ERROR, "No '#{media_type}' flowing in either direction after #{seconds} seconds" + else if stats.bytes_received is 0 + @logger.log @logger.levels.ERROR, "No incoming '#{media_type}' received after #{seconds} seconds" + else if stats.bytes_sent is 0 + @logger.log @logger.levels.ERROR, "No outgoing '#{media_type}' sent after #{seconds} seconds" + else + @logger.log @logger.levels.DEBUG, "Stream has '#{media_type}' flowing properly both ways" + else + if @is_answer + @logger.log @logger.levels.INFO, "Check stream statistics of type '#{media_type}' delayed as we created this flow" + else + window.setTimeout => + @check_stream media_type, timeout, attempt++ + , timeout + + ### + Schedule check of stream activity. + @param timeout [Number] Milliseconds from now to execute the check + ### + schedule_check: (timeout) -> + window.setTimeout => + @check_stream z.calling.enum.MediaType.AUDIO, timeout + @check_stream z.calling.enum.MediaType.VIDEO, timeout if @call_et.is_remote_videod() + , timeout + + ### + Set the PeerConnection on the telemetry. + @param [RTCPeerConnection] PeerConnection to be used for telemetry + ### + set_peer_connection: (peer_connection) => + @peer_connection = peer_connection + + ### + Update 'is_answer' status of flow. + @param is_answer [Boolean] Is the flow an answer + ### + update_is_answer: (is_answer) => + @is_answer = is_answer + @timings.is_answer = is_answer + + + ############################################################################### + # Statistics + ############################################################################### + + # Flow connected. + connected: => + @statistics.connected = Date.now() + + ### + Return the statistics object. + @return [z.telemetry.calling.stats.ConnectionStats] Flow statistics + ### + get_statistics: => + return @statistics + + # Update statics for the last time and then reset them and the polling interval. + reset_statistics: => + return new Promise (resolve, reject) => + @_update_statistics() + .then => + resolve @statistics + @statistics = {} + .catch (error) => + @logger.log @logger.levels.WARN, "Failed to update flow networks stats: #{error.message}" + reject error + window.clearInterval @stats_poller + @stats_poller = undefined + + ### + Start statistics polling. + @param ice_connection_state [RTCIceConnectionState] Current state of ICE connection + ### + start_statistics: (ice_connection_state) => + if not @stats_poller + # Track call stats + @time_step z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CONNECTED + $.extend @statistics, new z.telemetry.calling.ConnectionStats() + @connected() + + # Report calling stats within specified interval + window.setTimeout => + @_update_statistics() + .then => @logger.log @logger.levels.INFO, 'Flow network stats updated for the first time', @statistics + .catch (error) => @logger.log @logger.levels.WARN, "Failed to update flow networks stats: #{error.message}" + , 50 + @stats_poller = window.setInterval => + @_update_statistics() + .then => @logger.log @logger.levels.OFF, 'Flow network stats updated', @statistics + .catch (error) => @logger.log @logger.levels.WARN, "Flow networks stats not updated: #{error.message}" + , 2000 + + if ice_connection_state is z.calling.rtc.ICEConnectionState.COMPLETED + @time_step z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_COMPLETED + + ### + Get current statistics from PeerConnection. + @private + @return [Promise] Promise to be resolved when stats are returned + ### + _update_statistics: => + @peer_connection.getStats null + .then (rtc_statistics) => + @logger.log @logger.levels.OFF, 'Received new statistics report for flow', rtc_statistics + + updated_statistics = new z.telemetry.calling.ConnectionStats() + + for key, report of rtc_statistics + switch report.type + when z.calling.rtc.StatsType.CANDIDATE_PAIR + updated_statistics = @_update_from_candidate_pair report, rtc_statistics, updated_statistics + when z.calling.rtc.StatsType.GOOGLE_CANDIDATE_PAIR + updated_statistics = @_update_peer_connection_bytes report, updated_statistics + updated_statistics = @_update_from_google_candidate_pair report, rtc_statistics, updated_statistics + when z.calling.rtc.StatsType.INBOUND_RTP + updated_statistics = @_update_peer_connection_bytes report, updated_statistics + updated_statistics = @_update_from_inbound_rtp report, updated_statistics + when z.calling.rtc.StatsType.OUTBOUND_RTP + updated_statistics = @_update_peer_connection_bytes report, updated_statistics + updated_statistics = @_update_from_outbound_rtp report, updated_statistics + when z.calling.rtc.StatsType.SSRC + updated_statistics = @_update_from_ssrc report, updated_statistics + + _calc_rate = (key, timestamp, type) => + bytes = (updated_statistics[key][type] - @statistics[key][type]) + time_span = (updated_statistics.timestamp - timestamp) + return window.parseInt 1000.0 * bytes / time_span, 10 + + # Calculate bit rate since last update + for key, value of updated_statistics + if _.isObject value + updated_statistics[key].bit_rate_mean_received = _calc_rate key, @statistics.connected, 'bytes_received' + updated_statistics[key].bit_rate_mean_sent = _calc_rate key, @statistics.connected, 'bytes_sent' + updated_statistics[key].bit_rate_current_received = _calc_rate key, @statistics.timestamp, 'bytes_received' + updated_statistics[key].bit_rate_current_sent = _calc_rate key, @statistics.timestamp, 'bytes_sent' + + $.extend @statistics, updated_statistics + @logger.log @logger.levels.OFF, 'Update of network stats for flow successful', @statistics + .catch (error) => + @logger.log @logger.levels.WARN, 'Update of network stats for flow failed', error + + ### + Update from z.calling.rtc.StatsType.CANDIDATE_PAIR report. + + @param report [Object] z.calling.rtc.StatsType.CANDIDATE_PAIR report + @param stats_reports [RTCStatsReport] Statistics report from PeerConnection + @param updated_stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_from_candidate_pair: (report, stat_reports, updated_stats) -> + if report.selected + updated_stats.peer_connection.local_candidate_type = stat_reports[report.localCandidateId].candidateType + updated_stats.peer_connection.remote_candidate_type = stat_reports[report.remoteCandidateId].candidateType + return updated_stats + + ### + Update from z.calling.rtc.StatsType.GOOGLE_CANDIDATE_PAIR report. + + @param report [Object] z.calling.rtc.StatsType.GOOGLE_CANDIDATE_PAIR report + @param stats_reports [RTCStatsReport] Statistics report from PeerConnection + @param updated_stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_from_google_candidate_pair: (report, stat_reports, updated_stats) -> + if report.googActiveConnection is 'true' + updated_stats.peer_connection.round_trip_time = window.parseInt report.googRtt, 10 + updated_stats.peer_connection.local_candidate_type = stat_reports[report.localCandidateId].candidateType + updated_stats.peer_connection.remote_candidate_type = stat_reports[report.remoteCandidateId].candidateType + return updated_stats + + ### + Update from z.calling.rtc.StatsType.INBOUND_RTP report. + + @param report [Object] z.calling.rtc.StatsType.INBOUND_RTP report + @param stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_from_inbound_rtp: (report, stats) -> + if report.mediaType in [z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaType.VIDEO] + stats[report.mediaType].bytes_received += report.bytesReceived if report.bytesReceived + stats[report.mediaType].frame_rate_received = window.parseInt report.framerateMean, 10 if report.framerateMean + return stats + + ### + Update from z.calling.rtc.StatsType.OUTBOUND_RTP report. + + @param report [Object] z.calling.rtc.StatsType.OUTBOUND_RTP report + @param stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_from_outbound_rtp: (report, stats) -> + if report.mediaType in [z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaType.VIDEO] + stats[report.mediaType].bytes_sent += report.bytesSent if report.bytesSent + stats[report.mediaType].frame_rate_sent = window.parseInt report.framerateMean, 10 if report.framerateMean + return stats + + ### + Update from statistics report. + + @param report [Object] Statistics report + @param stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_peer_connection_bytes: (report, stats) -> + stats.peer_connection.bytes_received += window.parseInt report.bytesReceived, 10 if report.bytesReceived + stats.peer_connection.bytes_sent += window.parseInt report.bytesSent, 10 if report.bytesSent + return stats + + ### + Update from z.calling.rtc.StatsType.SSRC report. + + @param report [Object] z.calling.rtc.StatsType.SSRC report + @param stats [z.telemetry.calling.ConnectionStats] Parsed flow statistics + @return [z.telemetry.calling.ConnectionStats] updated_stats + ### + _update_from_ssrc: (report, stats) => + if report.codecImplementationName + codec = "#{report.googCodecName} #{report.codecImplementationName}" + else + codec = report.googCodecName + + if report.audioOutputLevel + stream_stats = stats.audio + stream_stats.volume_received = window.parseInt report.audioOutputLevel, 10 + stream_stats.codec_received = codec + else if report.audioInputLevel + stream_stats = stats.audio + stream_stats.volume_sent = window.parseInt report.audioInputLevel, 10 + stream_stats.codec_sent = codec + else if @call_et.is_remote_videod() + stream_stats = stats.video + if report.googFrameHeightReceived + stream_stats.frame_height_received = window.parseInt report.googFrameHeightReceived, 10 + stream_stats.frame_rate_received = window.parseInt report.googFrameRateReceived, 10 + stream_stats.frame_width_received = window.parseInt report.googFrameWidthReceived, 10 + stream_stats.codec_received = codec + else if report.googFrameHeightSent + stream_stats.frame_height_sent = window.parseInt report.googFrameHeightSent, 10 + stream_stats.frame_rate_sent = window.parseInt report.googFrameRateSent, 10 if report.googFrameRateSent + stream_stats.frame_width_sent = window.parseInt report.googFrameWidthSent, 10 if report.googFrameWidthSent + stream_stats.codec_sent = codec + + if stream_stats + stream_stats.bytes_received += window.parseInt report.bytesReceived, 10 if report.bytesReceived + stream_stats.bytes_received = stats.peer_connection.bytes_received if stream_stats.bytes_received is 0 + stream_stats.bytes_sent += window.parseInt report.bytesSent, 10 if report.bytesSent + stream_stats.bytes_sent = stats.peer_connection.bytes_sent if stream_stats.bytes_sent is 0 + stream_stats.delay = window.parseInt report.googCurrentDelayMs, 10 if report.googCurrentDelayMs + stream_stats.round_trip_time = window.parseInt report.googRtt, 10 if report.googRtt + + return stats + + + ############################################################################### + # Timings + ############################################################################### + + ### + Return the step timings object. + @return [z.telemetry.calling.CallSetupTimings] Flow statistics + ### + get_timings: => + return @timings.get() + + time_step: (step) => + @timings.time_step step + + + ############################################################################### + # Reporting & Logging + ############################################################################### + + # Get full report. + get_report: => + return {} = + report: @create_report() + + # Log the flow to the browser console. + log_status: (participant_et) => + @logger.force_log "-- ID: #{@id}" + + if @remote_user isnt undefined + @logger.force_log "-- Remote user: #{participant_et.user.name()} (#{participant_et.user.id})" + + @logger.force_log "-- User is connected: #{participant_et.is_connected()}" + @logger.force_log "-- Flow is answer: #{@is_answer}" + + if @peer_connection + @logger.force_log "-- ICE connection: #{@peer_connection.iceConnectionState}" + @logger.force_log "-- ICE gathering: #{@peer_connection.iceGatheringState}" + + statistics = @get_statistics() + if statistics + # @note Types are 'none' if we cannot connect to the user (0 bytes flow) + @logger.force_log 'PeerConnection network statistics', statistics + @logger.force_log "-- Remote ICE candidate type: #{statistics.peer_connection.remote_candidate_type}" + @logger.force_log "-- Local ICE candidate type: #{statistics.peer_connection.local_candidate_type}" + # PeerConnection Stats + for key, value of statistics + if _.isObject value + @logger.force_log "Statistics for '#{key}':" + @logger.force_log "-- Bit rate received: #{value.bit_rate_received}" + @logger.force_log "-- Bit rate sent: #{value.bit_rate_sent}" + @logger.force_log "-- Bytes sent: #{value.bytes_sent}" + @logger.force_log "-- Bytes received: #{value.bytes_received}" + @logger.force_log "-- Rtt: #{value.rtt}" + media_types = [z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaType.VIDEO] + if z.util.Environment.browser.chrome and key in media_types + @logger.force_log "-- Codec received: #{value.codec_received}" + @logger.force_log "-- Codec sent: #{value.codec_sent}" + @logger.force_log "-- Delay in ms: #{value.delay}" + if key is z.calling.enum.MediaType.VIDEO + @logger.force_log "-- Frame rate received: #{value.frame_rate_received}" + @logger.force_log "-- Frame rate sent: #{value.frame_rate_sent}" + continue if not z.util.Environment.browser.chrome + received_resolution = "#{value.frame_width_received}x#{value.frame_height_received}" + sent_resolution = "#{value.frame_width_sent}x#{value.frame_height_sent}" + @logger.force_log "-- Frame resolution received: #{received_resolution}" + @logger.force_log "-- Frame resolution sent: #{sent_resolution}" + else if key is z.calling.enum.MediaType.AUDIO + @logger.force_log "-- Volume received: #{value.volume_received}" + @logger.force_log "-- Volume sent: #{value.volume_sent}" + + log_timings: => + @timings.log() + + ### + Report an error to Raygun. + @param description [String] Error description + @param passed_error [Object] Error passed into the report + @param payload [Object] Additional payload for the custom data + ### + report_error: (description, passed_error, payload) => + custom_data = @create_report() + raygun_error = new Error description + + if passed_error + custom_data.error = passed_error + raygun_error.stack = passed_error.stack + + if payload + custom_data.payload = payload + + @logger.log @logger.levels.ERROR, description, custom_data + Raygun.send raygun_error, custom_data + + report_status: => + custom_data = @create_report() + @logger.log @logger.levels.INFO, 'Created flow status for call failure report', custom_data + return custom_data + + report_timings: => + custom_data = @timings.log() + Raygun.send new Error('Call setup step timings'), custom_data + @logger.log @logger.levels.INFO, + "Reported setup step timings of flow id '#{@id}' for call analysis", custom_data diff --git a/app/script/telemetry/calling/MediaStreamStats.coffee b/app/script/telemetry/calling/MediaStreamStats.coffee new file mode 100644 index 00000000000..ed4e70556c5 --- /dev/null +++ b/app/script/telemetry/calling/MediaStreamStats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +class z.telemetry.calling.MediaStreamStats extends z.telemetry.calling.Stats + constructor: (timestamp) -> + super timestamp + @bit_rate_current_received = 0 + @bit_rate_current_sent = 0 + @bit_rate_mean_received = 0 + @bit_rate_mean_sent = 0 + @codec_received = '' + @codec_sent = '' + @delay = 0 diff --git a/app/script/telemetry/calling/Stats.coffee b/app/script/telemetry/calling/Stats.coffee new file mode 100644 index 00000000000..abd09dffbab --- /dev/null +++ b/app/script/telemetry/calling/Stats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +# http://w3c.github.io/webrtc-stats/ +class z.telemetry.calling.Stats + constructor: (@timestamp) -> + @bytes_received = 0 + @bytes_sent = 0 + @packets_lost = 0 + @round_trip_time = 0 diff --git a/app/script/telemetry/calling/StreamStats.coffee b/app/script/telemetry/calling/StreamStats.coffee new file mode 100644 index 00000000000..d930072fb70 --- /dev/null +++ b/app/script/telemetry/calling/StreamStats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +class z.telemetry.calling.StreamStats extends z.telemetry.calling.Stats + constructor: (timestamp) -> + super timestamp + @local_candidate_type = '' + @remote_candidate_type = '' diff --git a/app/script/telemetry/calling/VideoStreamStats.coffee b/app/script/telemetry/calling/VideoStreamStats.coffee new file mode 100644 index 00000000000..fdfba6dfe66 --- /dev/null +++ b/app/script/telemetry/calling/VideoStreamStats.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.telemetry ?= {} +z.telemetry.calling ?= {} + +class z.telemetry.calling.VideoStreamStats extends z.telemetry.calling.MediaStreamStats + constructor: (timestamp) -> + super timestamp + @media_type = z.calling.enum.MediaType.VIDEO + @frame_height_received = 0 + @frame_height_sent = 0 + @frame_rate_received = 0 + @frame_rate_sent = 0 + @frame_width_received = 0 + @frame_width_sent = 0 diff --git a/app/script/tracking/EventName.coffee b/app/script/tracking/EventName.coffee new file mode 100644 index 00000000000..33a47773fa5 --- /dev/null +++ b/app/script/tracking/EventName.coffee @@ -0,0 +1,95 @@ +# +# 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.tracking ?= {} + +### +Definition of events used for user-tracking with Localytics. +@note Event names should be descriptive (!) like "Item Purchased" instead of being programmatic like "itemPurchased". +### +z.tracking.EventName = + ACCOUNT: + LOGGED_IN: 'account.logged_in' + OPENED_LOGIN: 'account.opened_login' + APP_LAUNCH: 'appLaunch' + CALLING: + ENDED_CALL: 'calling.ended_call' + ESTABLISHED_CALL: 'calling.established_successful_call' + FAILED_REQUEST: 'calling.failed_request' + FAILED_REQUESTING_MEDIA: 'calling.failed_requesting_media' + FAILED_RTC: 'calling.failed_rtc' + INITIATED_CALL: 'calling.initiated_call' + MINIMIZED_FROM_FULLSCREEN: 'calling.minimized_from_fullscreen' + JOINED_CALL: 'calling.joined_call' + RECEIVED_CALL: 'calling.received_call' + SHARED_SCREEN: 'calling.shared_screen' + CONVERSATION: + ADD_TO_GROUP_CONVERSATION: 'addContactToGroupConversation' + CHARACTER_LIMIT_REACHED: 'conversation.character_limit_reached' + CREATE_GROUP_CONVERSATION: 'createGroupConversation' + DELETED_MESSAGE: 'conversation.deleted_message' + SELECTED_MESSAGE: 'conversation.selected_message' + MEDIA: + COMPLETED_MEDIA_ACTION: 'media.completed_media_action' + PLAYED_AUDIO_MESSAGE: 'media.played_audio_message' + PLAYED_VIDEO_MESSAGE: 'media.played_video_message' + E2EE: + CANNOT_DECRYPT_MESSAGE: 'e2ee.cannot_decrypt_message' + FILE: + UPLOAD_CANCELLED: 'file.cancelled_file_upload' + UPLOAD_INITIATED: 'file.initiated_file_upload' + UPLOAD_FAILED: 'file.failed_file_upload' + UPLOAD_SUCCESSFUL: 'file.successfully_uploaded_file' + UPLOAD_TOO_BIG: 'file.attempted_too_big_file_upload' + DOWNLOAD_INITIATED: 'file.initiated_file_download' + DOWNLOAD_FAILED: 'file.failed_file_download' + DOWNLOAD_SUCCESSFUL: 'file.successfully_downloaded_file' + IMAGE_SENT_ERROR: 'Image Sent Error' + NAVIGATION: + OPENED_WIRE_WEBSITE: 'navigation.opened_wire_website' + OPENED_TERMS: 'navigation.opened_terms' + ONBOARDING: + ADDED_PHOTO: 'onboarding.added_photo' + IMPORTED_CONTACTS: 'onboarding.imported_contacts' + PASSWORD_RESET: 'resetPassword' + PERMISSION: + ALLOW_NOTIFICATIONS: 'allowNotifications' + PROFILE_PICTURE_CHANGED: 'changedProfilePicture' + REGISTRATION: + ENTERED_CREDENTIALS: 'registration.entered_credentials' + OPENED_EMAIL_SIGN_UP: 'registration.opened_email_signup' + RESENT_EMAIL_VERIFICATION: 'registration.resent_email_verification' + SUCCEEDED: 'registration.succeeded' + SETTINGS: + IMPORTED_CONTACTS: 'settings.imported_contacts' + VIEWED_DEVICE: 'settings.viewed_device' + REMOVED_DEVICE: 'settings.removed_device' + SETTINGS_MENU: + SHOW_ABOUT_SCREEN: 'about' + SHOW_SUPPORT_PAGE: 'help' + SOUND_SETTINGS_CHANGED: 'soundIntensityPreference' + TELEMETRY: + APP_INITIALIZATION: 'telemetry.app_initialization' + TRACKING: + OPT_IN: 'Opt-in' + OPT_OUT: 'Opt-out' + UPLOADED_CONTACTS: 'uploadedContacts' # "source": "Gmail" + ANNOUNCE: + SENT: 'announce.sent' + CLICKED: 'announce.clicked' diff --git a/app/script/tracking/EventTrackingRepository.coffee b/app/script/tracking/EventTrackingRepository.coffee new file mode 100644 index 00000000000..566b7d01a9d --- /dev/null +++ b/app/script/tracking/EventTrackingRepository.coffee @@ -0,0 +1,266 @@ +# +# 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.tracking ?= {} + +z.tracking.config = + SESSION_TIMEOUT: 180000 # milliseconds + +LOCALYTICS = + APP_KEY: '905792736c9f17c3464fd4e-60d90c82-d14a-11e4-af66-009c5fda0a25' + TRACKING_INTERVAL: 60000 # milliseconds + DISABLED_DOMAINS: [ + 'localhost' + 'wire-webapp' + ] + +RAYGUN = + API_KEY: '5hvAMmz8wTXaHBYqu2TFUQ==' + +if z.util.Environment.frontend.is_production() + RAYGUN.API_KEY = 'lAkLCPLx3ysnsXktajeHmw==' + LOCALYTICS.APP_KEY = 'b929419faf17d843c16649c-f5cc4c44-ccb3-11e4-2efd-004a77f8b47f' + if z.util.Environment.electron + if z.util.Environment.os.mac + LOCALYTICS.APP_KEY = 'ad0b57c3c46d92daea395e0-146bc33e-6100-11e5-09e1-00deb82fd81f' + else if z.util.Environment.os.win + LOCALYTICS.APP_KEY = 'dfb424ad373e18163f25bc6-aa01a13c-6100-11e5-fb56-008b20abc1fa' + +### +Tracker for user actions which uses Localytics as a reference implementation but can be easily used with other services. + +@see https://support.localytics.com/Javascript +@see http://docs.localytics.com/#Dev/Instrument/js-tag-events.html +### +class z.tracking.EventTrackingRepository + constructor: (@user_repository, @conversation_repository) -> + @logger = new z.util.Logger 'z.tracking.EventTrackingRepository', z.config.LOGGER.OPTIONS + + @localytics = undefined # Localytics + @interval = undefined # Interval to track the Localytics session + @tracking_id = undefined # Tracking ID of the self user + @user_properties = undefined # Reference to the current user properties / settings + + @reported_errors = ko.observableArray() + @reported_errors.subscribe => @reported_errors [] if @reported_errors().length > 999 + @session_values = {} + + if @user_repository is undefined and @conversation_repository is undefined + @_start_session_without_user_tracking() + @_enable_error_reporting() + else + @session_started = Date.now() + @_reset_session_values() + + @_subscribe() + + _subscribe: -> + amplify.subscribe z.event.WebApp.ANALYTICS.INIT, @init + + ### + @param user_properties [z.user.UserProperties] + @param user_et [z.entity.User] + ### + init: (user_properties, user_et) => + @logger.log @logger.levels.INFO, 'Initialize tracking and error reporting' + @user_properties = user_properties + @tracking_id = user_et.tracking_id + @_start_session() + @_enable_error_reporting() if @_has_permission() + @_subscribe_to_events() + + ### + @see http://docs.localytics.com/#Dev/Integrate/web-options.html + ### + _init_localytics: (window, document, node_type, @localytics, c, script_node) => + options = + appVersion: z.util.Environment.version() + sessionTimeout: z.tracking.config.SESSION_TIMEOUT / 1000 + + @localytics = -> + (@localytics.q ?= []).push arguments + @localytics.t = new Date() + window.ll = @localytics + window['LocalyticsGlobal'] = 'll' + script_node = document.createElement node_type + script_node.src = 'https://web.localytics.com/v3/localytics.min.js' + (c = document.getElementsByTagName(node_type)[0]).parentNode.insertBefore script_node, c + @localytics 'init', LOCALYTICS.APP_KEY, options + @logger.log @logger.levels.INFO, 'Enabling Localytics reporting' + + _localytics_disabled: -> + if not z.util.get_url_parameter z.auth.URLParameter.LOCALYTICS + for domain in LOCALYTICS.DISABLED_DOMAINS when z.util.contains window.location.hostname, domain + @logger.log @logger.levels.WARN, 'Localytics reporting is disabled' + return true + return false + + _subscribe_to_events: -> + amplify.subscribe z.event.WebApp.ANALYTICS.EVENT, @_track_event + amplify.subscribe z.event.WebApp.ANALYTICS.SESSION.START, @_start_session + amplify.subscribe z.event.WebApp.ANALYTICS.SESSION.CLOSE, @_close_session + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATE.SEND_DATA, @_send_data + + _start_session_without_user_tracking: => + return if @_localytics_disabled() + @_init_localytics window, document, 'script', @localytics + @localytics 'open' + @localytics 'upload' + amplify.subscribe z.event.WebApp.ANALYTICS.EVENT, @_track_event + + _start_session: => + return if @_localytics_disabled() + if @localytics is undefined and @_has_permission() + @_init_localytics window, document, 'script', @localytics + @logger.log @logger.levels.INFO, 'Starting new Localytics session' + @localytics 'setCustomerId', @tracking_id + @localytics 'open' + @localytics 'upload' + @interval = window.setInterval @_tag_and_upload_session, LOCALYTICS.TRACKING_INTERVAL + + ### + @return [Boolean] true when "improve_wire" is set to "true". + ### + _has_permission: -> + return false if @user_properties is undefined + return false if @tracking_id is undefined + return @user_properties.settings.privacy.improve_wire + + _track_event: (event_name, attributes) => + if @session_values[event_name] isnt undefined + if attributes is undefined + # Increment session event value + @session_values[event_name] += 1 + else + if window.Number.isInteger attributes + @session_values[event_name] += attributes + else + @session_values[event_name] = attributes + else + # Logging events which are not bound to a session + @_tag_and_upload_event event_name, attributes + + _send_data: (send_data) => + if send_data is false + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.TRACKING.OPT_OUT + @_close_session() + @_disable_error_reporting() + else if @session is undefined + @_enable_error_reporting() + @_start_session() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.TRACKING.OPT_IN + + # @note We need a fat arrow here, because this function is executed from "window.setInterval" + # + _tag_and_upload_session: => + if @_has_permission() + number_of_connections = @user_repository.get_number_of_connections() + + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_CONTACTS] = @user_repository.connections().length + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_GROUP_CONVERSATIONS] = @conversation_repository.get_number_of_group_conversations() + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_INCOMING_CONNECTION_REQUESTS] = number_of_connections.incoming + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_OUTGOING_CONNECTION_REQUESTS] = number_of_connections.outgoing + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_ARCHIVED_CONVERSATIONS] = @conversation_repository.conversations_archived().length + @session_values[z.tracking.SessionEventName.INTEGER.TOTAL_SILENCED_CONVERSATIONS] = @conversation_repository.get_number_of_silenced_conversations() + + # Sanitize logging data + for key of @session_values + if key is 'undefined' + delete @session_values[key] + + # Log data + @logger.log @logger.levels.INFO, 'Uploading data...', @session_values + @_tag_and_upload_event 'session', @session_values + + _tag_and_upload_event: (event_name, attributes) => + return if @localytics is undefined + + @localytics 'tagEvent', event_name, attributes + @localytics 'upload' + + _close_session: => + return if @localytics is undefined + @logger.log @logger.levels.INFO, 'Closing Localytics session' + window.clearInterval @interval + + session_ended = Date.now() + session_duration = session_ended - @session_started + @session_values['sessionDuration'] = session_duration / 1000 + + @localytics 'upload' + @localytics 'close' + window.ll = undefined + @localytics = undefined + @_reset_session_values() + + _disable_error_reporting: -> + @logger.log @logger.levels.INFO, 'Disabling Raygun error reporting' + Raygun.detach() + + _enable_error_reporting: -> + @logger.log @logger.levels.INFO, 'Enabling Raygun error reporting' + options = + ignoreAjaxAbort: true + ignoreAjaxError: true + excludedHostnames: [ + 'localhost' + 'wire.ms' + ] + ignore3rdPartyErrors: true + + options.debugMode = not z.util.Environment.frontend.is_production() + + Raygun.init(RAYGUN.API_KEY, options).attach() + ### + Adding a version to the Raygun reports to identify which version of the Wire ran into the issue. + + @note We cannot use our own version string as it has to be in a certain format + @see https://github.com/MindscapeHQ/raygun4js#version-filtering + ### + Raygun.setUser @tracking_id + Raygun.setVersion z.util.Environment.version false if not z.util.Environment.frontend.is_localhost() + Raygun.withCustomData {electron_version: z.util.Environment.version true} if z.util.Environment.electron + Raygun.onBeforeSend @_check_error_payload + + ### + Checks if a Raygun payload has been already reported. + + @see https://github.com/MindscapeHQ/raygun4js#onbeforesend + @param [JSON] raygun_payload + @return [JSON|Boolean] Returns the original payload if it is an unreported error, otherwise "false". + ### + _check_error_payload: (raygun_payload) => + error_hash = objectHash.sha1 raygun_payload.Details.Error + if @reported_errors().includes error_hash + return false + else + @reported_errors.push error_hash + return raygun_payload + + _reset_session_values: => + for index, event_name of z.tracking.SessionEventName.INTEGER + @session_values[event_name] = 0 + + for index, event_name of z.tracking.SessionEventName.BOOLEAN + @session_values[event_name] = false + + print_session_values: -> + for key, value of @session_values + @logger.force_log "¬ #{key}: #{value}" + return undefined diff --git a/app/script/tracking/SessionEventName.coffee b/app/script/tracking/SessionEventName.coffee new file mode 100644 index 00000000000..58a336e05ca --- /dev/null +++ b/app/script/tracking/SessionEventName.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.tracking ?= {} + +z.tracking.SessionEventName = + BOOLEAN: + SEARCHED_FOR_PEOPLE: 'searchedForPeople' + INTEGER: + CONNECT_REQUEST_ACCEPTED: 'connectRequestsAcceptedActual' + CONNECT_REQUEST_SENT: 'connectRequestsSentActual' + CONVERSATION_RENAMED: 'conversationRenamesActual' + EVENT_HIDDEN_DUE_TO_DUPLICATE_ID: 'eventHiddenDueToDuplicateIDActual' + EVENT_HIDDEN_DUE_TO_DUPLICATE_NONCE: 'eventHiddenDueToDuplicateNonceActual' + IMAGE_DETAIL_VIEW_OPENED: 'imageContentsClicksActual' + IMAGE_SENT: 'imagesSentActual' + INCOMING_CALL_ACCEPTED: 'incomingCallsAcceptedActual' + INCOMING_CALL_MUTED: 'incomingCallsMutedActual' + MESSAGE_SENT: 'textMessagesSentActual' # No differentiation between message with text or image + PING_SENT: 'pingsSentActual' # No differentiation between ping or hot ping + SEARCH_OPENED: 'openedSearchActual' + SOUNDCLOUD_CONTENT_CLICKED: 'soundcloudContentClicksActual' + SOUNDCLOUD_LINKS_SENT: 'soundcloudLinksSentActual' + TOTAL_ARCHIVED_CONVERSATIONS: 'totalArchivedConversationsActual' + TOTAL_CONTACTS: 'totalContactsActual' + TOTAL_GROUP_CONVERSATIONS: 'totalGroupConversationsActual' + TOTAL_INCOMING_CONNECTION_REQUESTS: 'totalIncomingConnectionRequestsActual' + TOTAL_OUTGOING_CONNECTION_REQUESTS: 'totalOutgoingConnectionRequestsActual' + TOTAL_SILENCED_CONVERSATIONS: 'totalSilencedConversationsActual' + USERS_ADDED_TO_CONVERSATIONS: 'usersAddedToConversationsActual' + VOICE_CALL_INITIATED: 'voiceCallsInitiatedActual' + YOUTUBE_CONTENT_CLICKED: 'youtubeContentClicksActual' + YOUTUBE_LINKS_SENT: 'youtubeLinksSentActual' diff --git a/app/script/tracking/event/PhoneVerification.coffee b/app/script/tracking/event/PhoneVerification.coffee new file mode 100644 index 00000000000..76450fc5071 --- /dev/null +++ b/app/script/tracking/event/PhoneVerification.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.tracking ?= {} +z.tracking.event ?= {} + +class z.tracking.event.PhoneVerification + ### + Construct a phone verification event. + + @param context [String] <"registration"|"postLogin"|"signIn"> + @param state [String] <"succeeded"|"error"|"resent"> + @param description [String] <"codeRequestError"|undefined> + ### + constructor: (@context, @state, @description) -> + @name = 'PhoneVerification' + @attributes = + context: @context + state: @state + description: @description diff --git a/app/script/tracking/event/PictureTakenEvent.coffee b/app/script/tracking/event/PictureTakenEvent.coffee new file mode 100644 index 00000000000..33186468fbf --- /dev/null +++ b/app/script/tracking/event/PictureTakenEvent.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.tracking ?= {} +z.tracking.event ?= {} + +class z.tracking.event.PictureTakenEvent + # Construct a phone verification event. + # + # @param [String] context + # @param [String] source + # @param [String] trigger + # + constructor: (@context, @source, @trigger) -> + @name = 'PictureTaken' + @attributes = + context: @context + source: @source + trigger: @trigger diff --git a/app/script/ui/Shortcut.coffee b/app/script/ui/Shortcut.coffee new file mode 100644 index 00000000000..88c3d6f9df6 --- /dev/null +++ b/app/script/ui/Shortcut.coffee @@ -0,0 +1,218 @@ +# +# 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.ui ?= {} + +z.ui.ShortcutType = + ADD_PEOPLE: 'add_people' + ARCHIVE: 'archive' + CALL_IGNORE: 'ignore' + CALL_MUTE: 'mute_call' + DEBUG: 'debug' + NEXT: 'next' + PEOPLE: 'people' + PING: 'ping' + PREV: 'prev' + SILENCE: 'silence' + START: 'start' + +z.ui.Shortcut = do -> + + shortcut_map = {} + + shortcut_map[z.ui.ShortcutType.ADD_PEOPLE] = + shortcut: + webapp: + osx: 'command + shift + k' + pc: 'ctrl + shift + k' + electron: + osx: 'command + shift + k' + pc: 'ctrl + shift + k' + menu: true + event: z.event.WebApp.SHORTCUT.ADD_PEOPLE + + shortcut_map[z.ui.ShortcutType.ARCHIVE] = + shortcut: + webapp: + osx: 'command + alt + shift + d' + pc: 'ctrl + alt + d' + electron: + osx: 'command + d' + pc: 'ctrl + d' + menu: true + event: z.event.WebApp.SHORTCUT.ARCHIVE + + shortcut_map[z.ui.ShortcutType.CALL_IGNORE] = + shortcut: + webapp: + osx: 'command + alt + .' + pc: 'ctrl + alt + .' + electron: + osx: 'command + .' + pc: 'ctrl + .' + event: z.event.WebApp.SHORTCUT.CALL_IGNORE + + shortcut_map[z.ui.ShortcutType.CALL_MUTE] = + shortcut: + webapp: + osx: 'command + alt + m' + pc: 'ctrl + alt + m' + electron: + osx: 'command + alt + m' + pc: 'ctrl + alt + m' + event: z.event.WebApp.SHORTCUT.CALL_MUTE + + shortcut_map[z.ui.ShortcutType.PREV] = + shortcut: + webapp: + osx: 'command + alt + down' + pc: 'alt + shift + down' + electron: + osx: 'command + alt + down' + pc: 'alt + shift + down' + menu: true + event: z.event.WebApp.SHORTCUT.PREV + + shortcut_map[z.ui.ShortcutType.NEXT] = + shortcut: + webapp: + osx: 'command + alt + up' + pc: 'alt + shift + up' + electron: + osx: 'command + alt + up' + pc: 'alt + shift + up' + menu: true + event: z.event.WebApp.SHORTCUT.NEXT + + shortcut_map[z.ui.ShortcutType.PING] = + shortcut: + webapp: + osx: 'command + alt + k' + pc: 'ctrl + alt + k' + electron: + osx: 'command + k' + pc: 'ctrl + k' + menu: true + event: z.event.WebApp.SHORTCUT.PING + + shortcut_map[z.ui.ShortcutType.PEOPLE] = + shortcut: + webapp: + osx: 'command + alt + shift + i' + pc: 'ctrl + alt + i' + electron: + osx: 'command + i' + pc: 'ctrl + i' + menu: true + event: z.event.WebApp.SHORTCUT.PEOPLE + + shortcut_map[z.ui.ShortcutType.SILENCE] = + shortcut: + webapp: + osx: 'command + alt + s' + pc: 'ctrl + alt + s' + electron: + osx: 'command + alt + s' + pc: 'ctrl + alt + s' + menu: true + event: z.event.WebApp.SHORTCUT.SILENCE + + shortcut_map[z.ui.ShortcutType.START] = + shortcut: + webapp: + osx: 'command + alt + graveaccent' # KeyboardJS fires this when using cmd + alt + n + pc: 'ctrl + alt + graveaccent' + electron: + osx: 'command + n' + pc: 'ctrl + n' + menu: true + + event: z.event.WebApp.SHORTCUT.START + + if $('#debug').length isnt 0 + shortcut_map[z.ui.ShortcutType.DEBUG] = + shortcut: + webapp: + osx: 'command + alt + g' + pc: 'ctrl + alt + g' + electron: + osx: 'command + alt + g' + pc: 'ctrl + alt + g' + event: z.event.WebApp.SHORTCUT.DEBUG + + _register_event = (platform_specific_shortcut, event) -> + + # bind also 'command + alt + n' for start shortcut + if z.util.contains platform_specific_shortcut, 'graveaccent' + replaced_shortcut = platform_specific_shortcut.replace 'graveaccent', 'n' + _register_event replaced_shortcut, event + + keyboardJS.on platform_specific_shortcut, (e) -> + keyboardJS.releaseKey e.keyCode + + # hotfix WEBAPP-1916 + return if (z.util.contains(platform_specific_shortcut, 'command') and not e.metaKey) + + e.preventDefault() + amplify.publish event + + get_beautified_shortcut_mac = (shortcut) -> + return shortcut + .replace /\+/g, '' + .replace /\s+/g, '' + .replace 'alt', '⌥' + .replace 'command', '⌘' + .replace 'shift', '⇧' + .replace 'up', '↑' + .replace 'down', '↓' + .replace 'graveaccent', 'n' + .toUpperCase() + + get_beautified_shortcut_win = (shortcut) -> + return shortcut + .replace 'up', '↑' + .replace 'down', '↓' + .replace 'graveaccent', 'n' + .replace /\w+/g, (string) -> z.util.capitalize_first_char string + + get_shortcut = (shortcut_name) -> + platform = if z.util.Environment.electron then 'electron' else 'webapp' + platform_shortcuts = shortcut_map[shortcut_name].shortcut[platform] + os_shortcut = if z.util.Environment.os.mac then platform_shortcuts.osx else platform_shortcuts.pc + return os_shortcut + + get_shortcut_tooltip = (shortcut_name) -> + shortcut = get_shortcut shortcut_name + if shortcut + return get_beautified_shortcut_mac shortcut if z.util.Environment.os.mac + return get_beautified_shortcut_win shortcut + + _init = -> + for shortcut, data of shortcut_map + continue if z.util.Environment.electron and shortcut_map[shortcut].shortcut.electron.menu + _register_event get_shortcut(shortcut), data['event'] + + _init() + + return {} = + shortcut_map: shortcut_map + get_shortcut: get_shortcut + get_shortcut_tooltip: get_shortcut_tooltip + get_beautified_shortcut_mac: get_beautified_shortcut_mac + get_beautified_shortcut_win: get_beautified_shortcut_win diff --git a/app/script/ui/WindowHandler.coffee b/app/script/ui/WindowHandler.coffee new file mode 100644 index 00000000000..d74f83307fe --- /dev/null +++ b/app/script/ui/WindowHandler.coffee @@ -0,0 +1,92 @@ +# +# 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.ui ?= {} + + +class z.ui.WindowHandler + constructor: -> + @logger = new z.util.Logger 'z.ui.WindowHandler', z.config.LOGGER.OPTIONS + + @width = 0 + @height = 0 + + @is_visible = true + @lost_focus_interval_time = (z.tracking.config.SESSION_TIMEOUT / 3) + @lost_focus_interval = undefined + @lost_focus_on = undefined + + return @ + + init: => + @width = $(window).width() + @height = $(window).height() + @_listen_to_window_resize() + @_listen_to_visibility_change => + if document.visibilityState is 'visible' + @logger.log 'Webapp is visible' + @is_visible = true + window.clearInterval @lost_focus_interval + @lost_focus_interval = undefined + @lost_focus_on = undefined + amplify.publish z.event.WebApp.ANALYTICS.SESSION.START + else + @logger.log 'Webapp is hidden' + @is_visible = false + if @lost_focus_interval is undefined + @lost_focus_on = Date.now() + @lost_focus_interval = window.setInterval (=> @_check_for_timeout()), @lost_focus_interval_time + return @ + + _listen_to_window_resize: => + $(window).on 'resize', => + current_height = $(window).height() + current_width = $(window).width() + + change_in_width = @width - current_width + change_in_height = @height - current_height + + amplify.publish z.event.WebApp.WINDOW.RESIZE.WIDTH, change_in_width + amplify.publish z.event.WebApp.WINDOW.RESIZE.HEIGHT, change_in_height + + @width = current_width + @height = current_height + + _listen_to_visibility_change: (callback) -> + property_hidden = undefined + property_visibility_change = undefined + + if typeof document.hidden isnt 'undefined' + property_hidden = 'hidden' + property_visibility_change = 'visibilitychange' + else if typeof document.msHidden isnt 'undefined' + property_hidden = 'msHidden' + property_visibility_change = 'msvisibilitychange' + else if typeof document.webkitHidden isnt 'undefined' + property_hidden = 'webkitHidden' + property_visibility_change = 'webkitvisibilitychange' + + if property_hidden + $(document).on property_visibility_change, -> + callback() + + _check_for_timeout: -> + in_background_since = Date.now() - @lost_focus_on + if in_background_since >= z.tracking.config.SESSION_TIMEOUT + amplify.publish z.event.WebApp.ANALYTICS.SESSION.CLOSE diff --git a/app/script/user/ConnectionLevel.coffee b/app/script/user/ConnectionLevel.coffee new file mode 100644 index 00000000000..45669721c1d --- /dev/null +++ b/app/script/user/ConnectionLevel.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.user ?= {} + +# Enum of different connection levels. +z.user.ConnectionLevel = + UNKNOWN: -1 + NO_CONNECTION: 0 + KNOWN_CONTACT: 1 + FRIEND_OF_FRIEND: 2 + FRIEND_OF_FRIEND_OF_FRIEND: 3 diff --git a/app/script/user/ConnectionStatus.coffee b/app/script/user/ConnectionStatus.coffee new file mode 100644 index 00000000000..548d22d4d50 --- /dev/null +++ b/app/script/user/ConnectionStatus.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.user ?= {} + +# Enum of different connection status. +z.user.ConnectionStatus = + ACCEPTED: 'accepted' + BLOCKED: 'blocked' + CANCELLED: 'cancelled' + IGNORED: 'ignored' + PENDING: 'pending' + SENT: 'sent' + UNKNOWN: '' diff --git a/app/script/user/UserConnectionMapper.coffee b/app/script/user/UserConnectionMapper.coffee new file mode 100644 index 00000000000..16d7db7bc1f --- /dev/null +++ b/app/script/user/UserConnectionMapper.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.user ?= {} + +# Connection mapper to convert all server side JSON connections into core connection entities. +class z.user.UserConnectionMapper + # Construct a new user mapper. + constructor: -> + @logger = new z.util.Logger 'z.user.UserConnectionMapper', z.config.LOGGER.OPTIONS + + ### + Converts JSON connection into connection entity. + + @param data [JSON] Connection data + + @return [z.entity.Connection] Mapped connection entity + ### + @map_user_connection_from_json = (data) -> + connection_et = new z.entity.Connection() + return @update_user_connection_from_json connection_et, data + + ### + Convert multiple JSON connections into connection entities. + + @param json [Object] Connection data + + @return [Array] Mapped connection entities + ### + @map_user_connections_from_json = (data) -> + return (@map_user_connection_from_json connection for connection in data when connection isnt undefined) + + ### + Maps JSON connection into a blank connection entity or updates an existing one. + + @param connection_et [z.entity.Connection] Connection entity that the info shall be mapped to + @param data [JSON] Connection data + + @return [z.entity.Connection] Mapped connection entity + ### + @update_user_connection_from_json = (connection_et, data) -> + connection_et.status data.status + connection_et.conversation_id = data.conversation + connection_et.to = data.to + connection_et.from = data.from + connection_et.last_update = data.last_update + connection_et.message = data.message + return connection_et diff --git a/app/script/user/UserError.coffee b/app/script/user/UserError.coffee new file mode 100644 index 00000000000..1b7454aeb2c --- /dev/null +++ b/app/script/user/UserError.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.user ?= {} + +class z.user.UserError + constructor: (message, type) -> + @name = @constructor.name + @message = message + @type = type + @stack = (new Error()).stack + + @:: = new Error() + @::constructor = @ + @::TYPE = { + NO_CLIENTS: 'z.user.UserError::TYPE.NO_CLIENTS' + PRE_KEY_NOT_FOUND: 'z.user.UserError::TYPE.PRE_KEY_NOT_FOUND' + REQUEST_FAILURE: 'z.user.UserError::TYPE.REQUEST_FAILURE' + } diff --git a/app/script/user/UserMapper.coffee b/app/script/user/UserMapper.coffee new file mode 100644 index 00000000000..fda9d3ca72c --- /dev/null +++ b/app/script/user/UserMapper.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.user ?= {} + +# User mapper to convert all server side JSON users into core user entities. +class z.user.UserMapper + ### + Construct a new User Mapper. + + @param asset_service [z.assets.AssetService] Backend REST API asset service implementation + ### + constructor: (@asset_service) -> + @logger = new z.util.Logger 'z.user.UserMapper', z.config.LOGGER.OPTIONS + + ### + Converts JSON user into user entity. + + @param data [Object] User data + + @return [z.entity.User] Mapped user entity + ### + map_user_from_object: (data) -> + user = new z.entity.User() + return @update_user_from_object user, data + + ### + Convert multiple JSON users into user entities. + + @note Return an empty array in any case to prevent crashes. + + @param json [Object] User data + + @return [Array] Mapped user entities + ### + map_users_from_object: (data) -> + if data? + return (@map_user_from_object user for user in data when user isnt undefined) + else + @logger.log @logger.levels.WARN, 'We got no user data from the backend' + return [] + + ### + Maps JSON user into a blank user entity or updates an existing one. + + @note Mapping of single properties to an existing user happens when the user changes his name or accent color. + + @param user_et [z.entity.User] User entity that the info shall be mapped to + @param data [Object] User data + + @return [z.entity.User] Mapped user entity + ### + update_user_from_object: (user_et, data) -> + return if not data? + # It's a new user + if data.id? and user_et.id is '' + user_et.id = data.id + user_et.joaat_hash = z.util.Crypto.Hashing.joaat_hash data.id + # We are trying to update non-matching users + else if user_et.id isnt '' and data.id isnt user_et.id + throw new Error('updating wrong user_et') + + if data.email? + user_et.email data.email + + if data.phone? + user_et.phone data.phone + + if data.name? + user_et.name data.name.trim() + + if data.accent_id? and data.accent_id isnt 0 + user_et.accent_id data.accent_id + + if data.picture? + user_et.raw_pictures data.picture + + if data.picture?[0]? + preview_picture = data.picture[0] + + if preview_picture.info.public isnt true + @logger.log @logger.levels.WARN, "User ID \"#{user_et.id}\" has a private profile picture." + + url = @asset_service.generate_asset_url preview_picture.id, user_et.id + user_et.picture_preview "url('#{url}')" + + if data.picture?[1]? + medium_picture = data.picture[1] + + if medium_picture.info.public isnt true + @logger.log @logger.levels.WARN, "User ID \"#{user_et.id}\" has a private medium picture." + + url = @asset_service.generate_asset_url medium_picture.id, user_et.id + user_et.picture_medium "url('#{url}')" + + return user_et diff --git a/app/script/user/UserProperties.coffee b/app/script/user/UserProperties.coffee new file mode 100644 index 00000000000..a0d8d9f66e9 --- /dev/null +++ b/app/script/user/UserProperties.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.user ?= {} + +class z.user.UserProperties + constructor: -> + @version = 1 + @settings = + call: + mute: false + permissions: + notifications: z.util.BrowserPermissionType.DEFAULT + privacy: + report_errors: true + improve_wire: true + sound: + alerts: z.audio.AudioSetting.ALL + @contact_import = + google: undefined + osx: undefined + @has_created_conversation = false + @enable_debugging = false diff --git a/app/script/user/UserRepository.coffee b/app/script/user/UserRepository.coffee new file mode 100644 index 00000000000..ae85980b053 --- /dev/null +++ b/app/script/user/UserRepository.coffee @@ -0,0 +1,694 @@ +# +# 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.user ?= {} + +# User repository for all user and connection interactions with the user service. +class z.user.UserRepository + ### + Construct a new User repository. + + @param user_service [z.user.UserService] Backend REST API user service implementation + @param asset_service [z.assets.AssetService] Backend REST API asset service implementation + @param search_service [z.search.SearchService] Backend REST API search service implementation + @param client_repository [z.client.ClientRepository] Repository for all client interactions + @param cryptography_repository [z.cryptography.CryptographyRepository] Repository for all cryptography interactions + ### + constructor: (@user_service, @asset_service, @search_service, @client_repository, @cryptography_repository) -> + @logger = new z.util.Logger 'z.user.UserRepository', z.config.LOGGER.OPTIONS + + @connection_mapper = new z.user.UserConnectionMapper() + @user_mapper = new z.user.UserMapper @asset_service + + @self = ko.observable() + @users = ko.observableArray [] + @connections = ko.observableArray [] + @properties = new z.user.UserProperties() + + @connect_requests = ko.computed => + user_ets = [] + for user_et in @users() + user_ets.push user_et if user_et.connection().status() is z.user.ConnectionStatus.PENDING + return user_ets + .extend rateLimit: 50 + + amplify.subscribe z.event.WebApp.PROPERTIES.CHANGE.DEBUG, @save_property_enable_debugging + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATED, @properties_updated + amplify.subscribe z.event.Backend.USER.CONNECTION, @user_connection + amplify.subscribe z.event.Backend.USER.UPDATE, @user_update + + + ############################################################################### + # Connections + ############################################################################### + + ### + Accept a connection request. + + @param user_et [z.entity.User] User to update connection with + @param show_conversation [Boolean] Show new conversation on success + @return [Promise] Promise that resolves when the connection request was accepted + ### + accept_connection_request: (user_et, show_conversation = false) => + @_update_connection_status user_et, z.user.ConnectionStatus.ACCEPTED, show_conversation + .then -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.CONNECT_REQUEST_ACCEPTED + + add_client_to_user: (user_id, client_et) => + return Promise.resolve() + .then => + user_et = @find_user user_id + @client_repository._save_client user_id, client_et.to_json() + .then -> + user_et.add_client client_et + return user_et + + ### + Block a user. + @param user_et [z.entity.User] User to block + @return [Promise] Promise that resolves when the user was blocked + ### + block_user: (user_et) => + @_update_connection_status user_et, z.user.ConnectionStatus.BLOCKED + + ### + Cancel a connection request. + @param user_et [z.entity.User] User to cancel the sent connection request + @param next_conversation_et [z.entity.Conversation] Optional conversation to be switched to + @return [Promise] Promise that resolves when an outgoing connection request was cancelled + ### + cancel_connection_request: (user_et, next_conversation_et) => + @_update_connection_status user_et, z.user.ConnectionStatus.CANCELLED + .then -> + amplify.publish z.event.WebApp.CONVERSATION.SHOW, next_conversation_et if next_conversation_et + + + ### + Create a connection request. + + @param user_et [z.entity.User] User to connect to + @param message [String] Connection message + @param show_conversation [Boolean] Should we open the new conversation + @return [Promise] Promise that resolves when the connection request was successfully created + ### + create_connection: (user_et, show_conversation = false) => + return Promise.resolve() + .then => + connect_message = z.localization.Localizer.get_text + id: z.string.connection_request_message + replace: [ + {placeholder: '%@.first_name', content: user_et.name()} + {placeholder: '%s.first_name', content: @self().name()} + ] + + @user_service.create_connection user_et.id, user_et.name(), connect_message + .then (response) => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.CONNECT_REQUEST_SENT + @user_connection response, show_conversation + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to send connection request to user '#{user_et.id}': #{error.message}", error + + ### + Create a connection request from generic invite token + @param token [String] + ### + create_connection_from_invite_token: (token) => + user_id = z.util.Invite.get_user_from_invitation_token token + + return if not user_id or @self().id is user_id + + @get_user_by_id user_id, (user_et) => + connection_et = user_et.connection() + return if connection_et.status() in [z.user.ConnectionStatus.BLOCKED, z.user.ConnectionStatus.ACCEPTED, + z.user.ConnectionStatus.SENT] + + if connection_et.status() is z.user.ConnectionStatus.PENDING + @accept_connection_request user_et + else + @create_connection user_et + + ### + Get a connection for a user ID. + @param user_id [String] User ID + @return [z.entity.Connection] User connection entity + ### + get_connection_by_user_id: (user_id) -> + for connection_et in @connections() + return connection_et if connection_et.to is user_id + + ### + Get a connection for a conversation ID. + @param conversation_id [String] Conversation ID + @return [z.entity.Connection] User connection entity + ### + get_connection_by_conversation_id: (conversation_id) -> + for connection_et in @connections() + return connection_et if connection_et.conversation_id is conversation_id + + ### + Get all user connections from backend and store users of that connection. + + @note Initially called by Wire for Web's app start to retrieve user entities and their connections. + + @param limit [Integer] Query limit for user connections + @param user_id [String] User ID of the latest connection + @param connection_ets [Array + return new Promise (resolve, reject) => + @user_service.get_own_connections limit, user_id + .then (response) => + if response.connections.length > 0 + new_connection_ets = @connection_mapper.map_user_connections_from_json response.connections + connection_ets = connection_ets.concat new_connection_ets + + if response.has_more + last_connection_et = connection_ets[connection_ets.length - 1] + @get_connections limit, last_connection_et.to, connection_ets + .then => resolve @connections() + else if connection_ets.length > 0 + @update_user_connections connection_ets + .then => resolve @connections() + else + resolve @connections() + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to retrieve connections from backend: #{error.message}", error + reject error + + ### + Ignore connection request. + @param user_et [z.entity.User] User to ignore the connection request + @return [Promise] Promise that resolves when an incoming connection request was ignored + ### + ignore_connection_request: (user_et) => + @_update_connection_status user_et, z.user.ConnectionStatus.IGNORED + + ### + Unblock a user. + + @param user_et [z.entity.User] User to unblock + @param show_conversation [Boolean] Show new conversation on success + @return [Promise] Promise that resolves when a user was unblocked + ### + unblock_user: (user_et, show_conversation = true) => + @_update_connection_status user_et, z.user.ConnectionStatus.ACCEPTED, show_conversation + + ### + Update the user connections and get the matching users. + @param connection_ets [Array] Connection entities + @return [Promise] Promise that resolves when all user connections have been updated + ### + update_user_connections: (connection_ets) => + return new Promise (resolve) => + z.util.ko_array_push_all @connections, connection_ets + + # Apply connection to other user entities (which are not us) + user_ids = (connection_et.to for connection_et in connection_ets) + + if user_ids.length > 0 + @get_users_by_id user_ids, (user_ets) => + @_assign_connection user_et for user_et in user_ets + @_assign_all_clients() + .then -> resolve() + + # Assign all locally stored clients to the users. + _assign_all_clients: => + @client_repository.get_all_clients_from_db() + .then (user_client_map) => + @logger.log "Found locally stored clients for '#{Object.keys(user_client_map).length}' users", user_client_map + user_ids = (user_id for user_id, client_ets of user_client_map) + @get_users_by_id user_ids, (user_ets) => + for user_et in user_ets + @logger.log "Found '#{user_client_map[user_et.id].length}' clients for '#{user_et.name()}'", user_client_map[user_et.id] + user_et.devices user_client_map[user_et.id] + + # Assign connections to the users. + _assign_connection: (user_et) => + connection_et = @get_connection_by_user_id user_et.id + user_et.connection connection_et if connection_et + + ### + Update the status of a connection. + + @private + @param user_et [z.entity.User] User to update connection with + @param status [String] Connection status + @param show_conversation [Boolean] Show conversation on success + @return [Promise] Promise that resolves when the connection status was updated + ### + _update_connection_status: (user_et, status, show_conversation = false) => + return Promise.resolve() + .then => + # check if the status has changed + return if user_et.connection().status() is status + return @user_service.update_connection_status user_et.id, status + .then (response) => + @user_connection response, show_conversation + .catch (error) => + @logger.log @logger.levels.ERROR, + "Connection status change to '#{status}' for user '#{user_et.id}' failed: #{error.message}", error + custom_data = + failed_action: status + server_error: error + Raygun.send new Error('Connection Status change failed'), custom_data + + ############################################################################### + # Events + ############################################################################### + + ### + Convert a JSON event into an entity and get the matching conversation. + @param event_json [Object] JSON data of 'user.connection' event + @param show_conversation [Boolean] Should the new conversation be opened? + ### + user_connection: (event_json, show_conversation) => + return if not event_json? + event_json = event_json.connection or event_json + + connection_et = @get_connection_by_user_id event_json.to + previous_status = null + + if connection_et? + previous_status = connection_et.status() + @connection_mapper.update_user_connection_from_json connection_et, event_json + else + connection_et = @connection_mapper.map_user_connection_from_json event_json + @update_user_connections [connection_et] + + if connection_et + if previous_status is z.user.ConnectionStatus.SENT and connection_et.status() is z.user.ConnectionStatus.ACCEPTED + @update_user_by_id connection_et.to + @_send_user_connection_notification connection_et, previous_status + amplify.publish z.event.WebApp.CONVERSATION.MAP_CONNECTION, [connection_et], show_conversation + + ### + Use a JSON event to update the matching user. + @param event_json [Object] JSON data + ### + user_update: (event_json) => + if event_json.user.id is @self().id + @user_mapper.update_user_from_object @self(), event_json.user + else + @get_user_by_id event_json.user.id, (user_et) => + @user_mapper.update_user_from_object user_et, event_json.user if user_et? + + ### + Send the user connection notification. + @param connection_et [z.entity.Connection] Connection entity + @param previous_status [z.user.ConnectionStatus] Previous connection status + ### + _send_user_connection_notification: (connection_et, previous_status) => + # We accepted the connection request or unblocked the user + no_notification = [z.user.ConnectionStatus.BLOCKED, z.user.ConnectionStatus.PENDING] + return if previous_status in no_notification and connection_et.status() is z.user.ConnectionStatus.ACCEPTED + + @get_user_by_id connection_et.to, (user_et) -> + message_et = new z.entity.MemberMessage() + message_et.user user_et + switch connection_et.status() + when z.user.ConnectionStatus.PENDING + message_et.member_message_type = z.message.SystemMessageType.CONNECTION_REQUEST + when z.user.ConnectionStatus.ACCEPTED + message_et.member_message_type = z.message.SystemMessageType.CONNECTION_ACCEPTED + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, connection_et, message_et + + + ############################################################################### + # Users + ############################################################################### + + ### + Request account deletion. + @return [Promise] Promise that resolves when account deletion process has been initiated + ### + delete_me: => + @user_service.delete_self() + .then => + @logger.log @logger.levels.INFO, 'Account deletion initiated' + .catch (error) => + @logger.log @logger.levels.ERROR, "Unable to delete self: #{error}" + + ### + Get a user from the backend. + @param user_id [String] User ID + @param callback [Function] Function to be called on server return + ### + fetch_user_by_id: (user_id, callback) => + return callback?() if not user_id + + @fetch_users_by_id [user_id], (user_ets) -> + callback? user_ets?[0] + + ### + Get users from the backend. + @param user_ids [Array] User IDs + @param callback [Function] Function to be called on server return + ### + fetch_users_by_id: (user_ids, callback) => + user_ids = user_ids?.filter (user_id) -> + return user_id isnt undefined + + return callback? [] if not user_ids or user_ids.length is 0 + + # create chunks + fetched_user_ets = [] + chunks = z.util.array_chunks user_ids, z.config.MAXIMUM_USERS_PER_REQUEST + number_of_loaded_chunks = 0 + + for chunk in chunks + @user_service.get_users chunk, (response, error) => + number_of_loaded_chunks += 1 + if response + user_ets = @user_mapper.map_users_from_object response + fetched_user_ets = fetched_user_ets.concat user_ets + + if number_of_loaded_chunks is chunks.length + # If the difference is 1 then we most likely have a case with a suspended user + if user_ids.length isnt fetched_user_ets.length + fetched_user_ets = @_add_suspended_users user_ids, fetched_user_ets + @save_users fetched_user_ets + callback? fetched_user_ets + + ### + Find a local user. + @param user_id [String] User ID + @return [z.entity.User] Matching user entity + ### + find_user: (user_id) -> + return user_et for user_et in @users() when user_et.id is user_id + + ### + Get self user from backend. + @return [Promise] Promise that will resolve with the self user entity + ### + get_me: => + return new Promise (resolve, reject) => + @user_service.get_own_user() + .then (response) => + user_et = @user_mapper.map_user_from_object response + # TODO: This needs to be represented by a SelfUser class! + # Only the "self / own" user has a tracking ID & locale + user_et.tracking_id = response.tracking_id + user_et.locale = response.locale + @save_user user_et, true + resolve user_et + .catch (error) => + @logger.log @logger.levels.ERROR, "Unable to load self user: #{error}" + reject error + + ### + Check for user locally and fetch it from the server otherwise. + @param user_id [String] User ID + @param callback [Function] Function to be called on server return + ### + get_user_by_id: (user_id, callback) -> + if user_id is undefined + callback? null + + user_et = @find_user user_id + if not user_et + @fetch_user_by_id user_id, callback + else + callback? user_et + + return user_et + + ### + Check for users locally and fetch them from the server otherwise. + + @param user_ids [Array] User IDs + @param callback [Function] Function to be called on server return + @param offline [Boolean] Should we only look for cached contacts + ### + get_users_by_id: (user_ids = [], callback, offline = false) => + return callback? [] if user_ids.length is 0 + + known_user_ets = [] + unknown_user_ids = [] + + user_ids.forEach (user_id) => + user_et = @find_user user_id + + if user_et? + known_user_ets.push user_et + else + unknown_user_ids.push user_id + + if unknown_user_ids.length is 0 or offline + callback? known_user_ets + else + @fetch_users_by_id unknown_user_ids, (user_ets) -> + callback? known_user_ets.concat user_ets + + ### + Get user by name. + @param name [String] Name to search user for + @return [Array] Matching users + ### + get_user_by_name: (name) => + user_ets = [] + + for user_et in @users() + if user_et.connected() and (user_et.email() is name or z.util.compare_names user_et.name(), name) + user_ets.push user_et + + return user_ets.sort z.util.sort_user_by_name + + ### + Is the user the logged in user. + @param user_id [z.entity.User or String] User entity or user ID + @return [Boolean] Is the user the logged in user + ### + is_me: (user_id) -> + user_id = user_id.id if not _.isString user_id + return @self().id is user_id + + ### + Save a user. + @param user_et [z.entity.User] User entity to be stored + @param is_me [Boolean] Is the user entity the self user + ### + save_user: (user_et, is_me = false) -> + @users.push user_et if not @user_exists(user_et.id) + + if is_me + user_et.is_me = true + @self user_et + + return user_et + + ### + Save multiple users at once. + @param user_ets [Array] Array of user entities to be stored + ### + save_users: (user_ets) -> + new_user_ets = (user_et for user_et in user_ets when not @user_exists user_et.id) + z.util.ko_array_push_all @users, new_user_ets + return new_user_ets + + ### + Update a local user from the backend by ID. + @param user_id [String] User ID + ### + update_user_by_id: (user_id) => + @get_user_by_id user_id, (old_user_et) => + @user_service.get_user_by_id user_id, (new_user_et) => + @user_mapper.update_user_from_object old_user_et, new_user_et + + ### + Check if the user is already stored. + @param user_id [String] User ID + @return [Boolean] Is the user already stored + ### + user_exists: (user_id) -> + user_et = @find_user user_id + return !!user_et + + ### + Add user entities for suspended users. + + @param user_ids [Array] Requested user IDs + @param user_ets [Array] User entities returned by backend + @return [Array] User entities to be returned + ### + _add_suspended_users: (user_ids, user_ets) -> + for user_id in user_ids + if not (user_ets.find (element) -> return true if element.id is user_id) + user_et = new z.entity.User(user_id) + user_et.name z.localization.Localizer.get_text z.string.nonexistent_user + user_ets.push user_et + return user_ets + + ############################################################################### + # Profile + ############################################################################### + + ### + Change the accent color. + @param accent_id [Integer] New accent color + ### + change_accent_color: (accent_id) -> + @user_service.update_own_user_profile {accent_id: accent_id}, (response, error) => + @self().accent_id accent_id if not error? + + ### + Change username. + @param name [String] New user name + ### + change_username: (name) -> + if name.length >= z.config.MINIMUM_USERNAME_LENGTH + @user_service.update_own_user_profile {name: name}, (response, error) => + @self().name name if not error? + + ### + Change the profile image. + @param picture [String, Object] New user picture + @param on_success [Function] Function to be executed on success + ### + change_picture: (picture, on_success) -> + @asset_service.upload_profile_image @self().id, picture, (upload_response, error) => + if upload_response + @user_service.update_own_user_profile {picture: upload_response}, (update_response, error) => + if not error? + @user_update {user: {id: @self().id, picture: upload_response}} + on_success?() + else + @logger.log @logger.levels.ERROR, "Error during profile image upload: #{error.message}", error + + + ############################################################################### + # Properties + ############################################################################### + + ### + Initialize properties on app startup. + ### + init_properties: => + return new Promise (resolve, reject) => + @user_service.get_user_properties() + .then (response) => + if response.includes z.config.PROPERTIES_KEY + @user_service.get_user_properties_by_key z.config.PROPERTIES_KEY + .then (response) => + $.extend true, @properties, response + @logger.log @logger.levels.INFO, 'Loaded user properties', @properties + else + @logger.log @logger.levels.INFO, 'User has no saved properties, using defaults' + .then => + amplify.publish z.event.WebApp.PROPERTIES.UPDATED, @properties + amplify.publish z.event.WebApp.ANALYTICS.INIT, @properties, @self() + resolve @properties + .catch (error) => + error = new Error "Failed to initialize user properties: #{error}" + @logger.log @logger.levels.ERROR, error.message, error + reject @properties + + properties_updated: (properties) -> + if properties.enable_debugging + amplify.publish z.util.Logger::LOG_ON_DEBUG, properties.enable_debugging + + ### + Save the user properties. + ### + save_properties: (key, value) => + @user_service.change_user_properties_by_key z.config.PROPERTIES_KEY, @properties + .then => + @logger.log @logger.levels.INFO, "Saved updated settings: '#{key}' - '#{value}'" + .catch (error) => + @logger.log @logger.levels.ERROR, 'Saving updated settings failed', error + + ### + Save timestamp for Google Contacts import. + @param timestamp [String] Timestamp to be saved + ### + save_property_contact_import_google: (timestamp) => + @properties.contact_import.google = timestamp + @save_properties 'contact_import.google', timestamp + .then -> amplify.publish z.event.WebApp.PROPERTIES.UPDATE.GOOGLE, timestamp + + ### + Save timestamp for OSX Contacts import. + @param timestamp [String] Timestamp to be saved + ### + save_property_contact_import_osx: (timestamp) => + @properties.contact_import.osx = timestamp + @save_properties 'contact_import.osx', timestamp + .then -> amplify.publish z.event.WebApp.PROPERTIES.UPDATE.OSX_CONTACTS, timestamp + + ### + Save data settings. + @param is_enabled [String] Data setting to be saved + ### + save_property_data_settings: (is_enabled) => + return if @properties.settings.privacy.report_errors is is_enabled + @properties.settings.privacy.report_errors = is_enabled + @properties.settings.privacy.improve_wire = is_enabled + @save_properties 'settings.privacy', is_enabled + .then -> amplify.publish z.event.WebApp.PROPERTIES.UPDATE.SEND_DATA, is_enabled + + ### + Save debug logging setting. + @param is_enabled [Boolean] Should debug logging be enabled despite domain + ### + save_property_enable_debugging: (is_enabled) => + return if @properties.enable_debugging is is_enabled + @properties.enable_debugging = is_enabled + @save_properties 'enable_debugging', is_enabled + .then -> amplify.publish z.util.Logger::LOG_ON_DEBUG, is_enabled + + ### + Save timestamp for Google Contacts import. + ### + save_property_has_created_conversation: => + @properties.has_created_conversation = true + @save_properties 'has_created_conversation', @properties.has_created_conversation + .then -> amplify.publish z.event.WebApp.PROPERTIES.UPDATE.HAS_CREATED_CONVERSATION + + ### + Save audio settings. + @param sound_alerts [String] Audio setting to be saved + ### + save_property_sound_alerts: (sound_alerts) => + if @properties.settings.sound.alerts isnt sound_alerts + @properties.settings.sound.alerts = sound_alerts + @save_properties 'settings.sound.alerts', @properties.settings.sound.alerts + .then -> amplify.publish z.event.WebApp.PROPERTIES.UPDATE.SOUND_ALERTS, sound_alerts + + + ############################################################################### + # Tracking helpers + ############################################################################### + + ### + Count of connections. + @return [Integer] Number of connections + ### + get_number_of_connections: -> + amount = + incoming: 0 + outgoing: 0 + + for connection_et in @connections() + if connection_et.status() is z.user.ConnectionStatus.PENDING + amount.incoming += 1 + else if connection_et.status() is z.user.ConnectionStatus.SENT + amount.outgoing += 1 + + return amount diff --git a/app/script/user/UserService.coffee b/app/script/user/UserService.coffee new file mode 100644 index 00000000000..876e23e1fad --- /dev/null +++ b/app/script/user/UserService.coffee @@ -0,0 +1,315 @@ +# +# 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.user ?= {} + +### +User service for all user and connection calls to the backend REST API. +@todo move everything that is not users or self (e.g. login,register,activate) to AuthService? +### +class z.user.UserService + URL_CONNECTIONS: '/connections' + URL_PROPERTIES: '/properties' + URL_SELF: '/self' + URL_USERS: '/users' + ### + Construct a new User Service. + @param client [z.service.Client] Client for the API calls + ### + constructor: (@client) -> + @logger = new z.util.Logger 'z.user.UserService', z.config.LOGGER.OPTIONS + + + ############################################################################### + # Connections + ############################################################################### + + ### + Create a connection request to another user. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/createConnection + + @param user_id [String] User ID of the user to request a connection with + @param name [String] Name of the conversation being initiated (1 - 256 characters) + @param message [String] The initial message in the request (1 - 256 characters) + @return [Promise] Promise that resolves when the connection request was created + ### + create_connection: (user_id, name, message) -> + payload = + user: user_id + name: name + message: message + + @client.send_json + type: 'POST' + url: @client.create_url @URL_CONNECTIONS + data: payload + + ### + Retrieves a list of connections to other users. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/connections + @note The list is already pre-ordered by the backend, so in order to fetch more connections + than the limit, you only have to pass the User ID (which is not from the self user) + of the last connection item from the received list. + + @param limit [Integer] Number of results to return (default 100, max 500) + @param user_id [String] User ID to start from + @return [Promise] Promise that resolves with user connections + ### + get_own_connections: (limit = 500, user_id = undefined) -> + @client.send_request + type: 'GET' + data: + size: limit + start: user_id + url: @client.create_url @URL_CONNECTIONS + + ### + Updates a connection to another user. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/updateConnection + @example status: ['accepted', 'blocked', 'pending', 'ignored', 'sent' or 'cancelled'] + + @param user_id [String] User ID of the other user + @param status [z.user.ConnectionStatus] New relation status + @return [Promise] Promise that resolves when the status was updated + ### + update_connection_status: (user_id, status) -> + @client.send_json + type: 'PUT' + url: @client.create_url "/connections/#{user_id}" + data: + status: status + + + ############################################################################### + # Password reset + ############################################################################### + + ### + Initiate a password reset. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/beginPasswordReset + + @param email [String] Email address + @param phone [String] E.164 formatted phone number + @return [Promise] Promise that resolves when password reset process has been triggered + ### + initiate_password_reset: (email, phone_number) -> + @client.send_json + type: 'POST' + url: @client.create_url '/password-reset' + data: + email: email + phone: phone_number + + ############################################################################### + # Profile + ############################################################################### + + ### + Get your profile. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/self + @return [Promise] Promise that will resolve with the self user + ### + get_own_user: -> + @client.send_request + type: 'GET' + url: @client.create_url @URL_SELF + + ### + Update your profile. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/updateSelf + + @param data [Object] Updated user profile information + @option data [Integer] accent_id + @option data [String] name + @option data [Array] picture + @param callback [Function] Function to be called on server return + ### + update_own_user_profile: (data, callback) -> + @client.send_json + type: 'PUT' + url: @client.create_url @URL_SELF + data: data + callback: callback + + ### + Change user email. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/changeEmail + + @param email [String] New email address for the user + @return [Promise] Promise that resolves when email changing process has been started on backend + ### + change_own_email: (email) -> + @client.send_json + type: 'PUT' + url: @client.create_url '/self/email' + data: + email: email + + ### + Change user password. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/changePassword + + @param new_password [String] New user password + @param old_password [String] Old password of the user (optional) + @return [Promise] Promise that resolves when password has been changed on backend + ### + change_own_password: (new_password, old_password) -> + @client.send_json + type: 'PUT' + url: @client.create_url '/self/password' + data: + new_password: new_password + old_password: old_password + + ### + Change your phone number. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/changePhone + + @param phone_number [String] Phone number in E.164 format + @return [Promise] Promise that resolves when phone number change process has been started on backend + ### + change_own_phone_number: (phone_number) -> + @client.send_json + type: 'PUT' + url: @client.create_url '/self/phone' + data: + phone: phone_number + + + ############################################################################### + # Properties + ############################################################################### + + ### + List all property keys stored for the user. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/listPropertyKeys + @return [Promise] Promise that resolves with a list of the property keys stored for the user + ### + get_user_properties: -> + @client.send_request + type: 'GET' + url: @client.create_url @URL_PROPERTIES + + ### + Clear all properties store for the user. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/clearProperties + @return [Promise] Promise that resolves when properties for user have been cleared + ### + clear_user_properties: -> + @client.send_request + type: 'DELETE' + url: @client.create_url @URL_PROPERTIES + + ### + Get a property value stored for a key. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/getProperty + + @param key [String] Key used to store user properties + @return [Promise] Promise that resolves with the properties for the given key + ### + get_user_properties_by_key: (key) -> + @client.send_request + type: 'GET' + url: @client.create_url "/properties/#{key}" + + + ### + Set a property value for a key. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/setProperty + + @param key [String] Key used to store user properties + @param properties [Object] Payload to be stored + ### + change_user_properties_by_key: (key, properties) -> + @client.send_json + type: 'PUT' + url: @client.create_url "/properties/#{key}" + data: properties + + ### + Delete a property. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/deleteProperty + + @param key [String] Key used to store user properties + @param callback [Function] Function to be called on server return + ### + delete_user_properties_by_key: (key) -> + @client.send_request + type: 'DELETE' + url: @client.create_url "/properties/#{key}" + callback: callback + + + ############################################################################### + # Users + ############################################################################### + + ### + Delete self user. + @return [Promise] Promise that resolves when account deletion has been initiated + ### + delete_self: -> + @client.send_json + type: 'DELETE' + url: @client.create_url @URL_SELF + data: + todo: 'Change this to normal request!' + + ### + Check if a user ID exists. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/userExists + @example "496d0d21-0b05-49b5-8087-de94f3465b7b" + + @param user_id [String] User ID + @param callback [Function] Function to be called on server return + ### + is_existing_user: (user_id, callback) -> + @client.send_request + type: 'HEAD' + url: @client.create_url "/users/#{user_id}" + callback: callback + + ### + Get a set of users. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/users + @example ['0bb84213-8cc2-4bb1-9e0b-b8dd522396d5', '15ede065-72b3-433a-9917-252f076ed031'] + + @param users [Array] ID of users to be fetched + @param callback [Function] Function to be called on server return + ### + get_users: (users, callback) -> + @client.send_request + type: 'GET' + url: @client.create_url @URL_USERS + data: + ids: users.join ',' + callback: callback + + ### + Get a user by ID. + @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/user + + @param user_id [String] User ID + @param callback [Function] Function to be called on server return + ### + get_user_by_id: (user_id, callback) -> + @client.send_request + type: 'GET' + url: @client.create_url "/users/#{user_id}" + callback: callback diff --git a/app/script/util/ArrayUtil.coffee b/app/script/util/ArrayUtil.coffee new file mode 100644 index 00000000000..8d167cfc689 --- /dev/null +++ b/app/script/util/ArrayUtil.coffee @@ -0,0 +1,72 @@ +# +# 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.util ?= {} +z.util.ArrayUtil ?= {} + +### +Moves an element from one place to another by it's index. +This change happens in place which means that the array is modified immediately. + +todo: nice idea, DSL with "move_element.from(...).to(...).in(...)" + +@param from [Number] Source index of the element which should be moved +@param to [Number] Destination index of the element which should be moved +@param array [Array] Array where to move the array +@return [Array] Given array +### +z.util.ArrayUtil.move_element = (from, to, array) -> + element = array[from] + array.splice from, 1 + array.splice to, 0, element + +### +Returns random element + +@param array [Array] source +@return [Object] random element +### +z.util.ArrayUtil.random_element = (array) -> + array[Math.floor(Math.random() * array.length)] + +z.util.ArrayUtil.contains = (array, value) -> + return array.indexOf(value) > -1 + +### +Interpolates an array of numbers using linear interpolation + +@param array [Array] source +@param length [Number] new length +@return [Array] new array with interpolated values +### +z.util.ArrayUtil.interpolate = (array, length) -> + new_array = [] + scale_factor = (array.length - 1) / (length - 1) + + new_array[0] = array[0] + new_array[length - 1] = array[array.length - 1] + + for i in [1...length - 1] + original_index = i * scale_factor + before = Math.floor(original_index).toFixed() + after = Math.ceil(original_index).toFixed() + point = original_index - before + new_array[i] = array[before] + (array[after] - array[before]) * point # linear interpolation + + return new_array diff --git a/app/script/util/BrowserPermissionType.coffee b/app/script/util/BrowserPermissionType.coffee new file mode 100644 index 00000000000..b92201a8780 --- /dev/null +++ b/app/script/util/BrowserPermissionType.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.util ?= {} + + +# https://developer.mozilla.org/en-US/docs/Web/API/Notification.permission#Return_Value +z.util.BrowserPermissionType = + DEFAULT: 'default' + DENIED: 'denied' + GRANTED: 'granted' diff --git a/app/script/util/Comparison.coffee b/app/script/util/Comparison.coffee new file mode 100644 index 00000000000..883272c229e --- /dev/null +++ b/app/script/util/Comparison.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/. +# + +z.util.Comparison = do -> + + public_methods = + max_number: (a, b) -> + return a if window.isNaN(b) and not window.isNaN(a) + return b if window.isNaN(a) and not window.isNaN(b) + return if a >= b then a else b + min_number: (a, b) -> + return a if window.isNaN(b) and not window.isNaN(a) + return b if window.isNaN(a) and not window.isNaN(b) + return if a >= b then b else a + + return public_methods diff --git a/app/script/util/CountryCodes.coffee b/app/script/util/CountryCodes.coffee new file mode 100644 index 00000000000..c53eca87ef7 --- /dev/null +++ b/app/script/util/CountryCodes.coffee @@ -0,0 +1,1511 @@ +# +# 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.util ?= {} + +z.util.CountryCodes = do -> + + ### + Get the country code matching an ISO name + + @param iso_name [String] ISO standard country name + + @return [Integer] Matching country code + ### + get_country_code = (iso_name) -> + for country in COUNTRY_CODES + return country.code if country.iso is iso_name + + ### + Get the full-text country name matching an ISO name + + @param iso_name [String] ISO standard country name + + @return [String] Matching full-text country name + ### + get_country_name = (iso_name) -> + for country in COUNTRY_CODES + return country.name if country.iso is iso_name + + ### + Get the country code matching to an ISO name + + @param code [Integer] Country code + + @return [String] Returns the ISO standard country name of the most populated country with the matching country code + ### + get_country_by_code = (code) -> + countries = [] + + for country in COUNTRY_CODES + countries.push country if country.code is window.parseInt code, 10 + + countries = countries.sort (country_a, country_b) -> + return country_a.population - country_b.population + + return countries.pop()?.iso + + COUNTRY_CODES = [ + { + 'code': 93 + 'iso': 'AF' + 'name': 'Afghanistan' + 'population': 31 + } + { + 'code': 355 + 'iso': 'AL' + 'name': 'Albania' + 'population': 2 + } + { + 'code': 213 + 'iso': 'DZ' + 'name': 'Algeria' + 'population': 34 + } + { + 'code': 1684 + 'iso': 'AS' + 'name': 'American Samoa' + 'population': 0 + } + { + 'code': 376 + 'iso': 'AD' + 'name': 'Andorra' + 'population': 0 + } + { + 'code': 244 + 'iso': 'AO' + 'name': 'Angola' + 'population': 13 + } + { + 'code': 1264 + 'iso': 'AI' + 'name': 'Anguilla' + 'population': 0 + } + { + 'code': 672 + 'iso': 'AQ' + 'name': 'Antarctica' + 'population': 0 + } + { + 'code': 1268 + 'iso': 'AG' + 'name': 'Antigua and Barbuda' + 'population': 0 + } + { + 'code': 54 + 'iso': 'AR' + 'name': 'Argentina' + 'population': 41 + } + { + 'code': 374 + 'iso': 'AM' + 'name': 'Armenia' + 'population': 2 + } + { + 'code': 297 + 'iso': 'AW' + 'name': 'Aruba' + 'population': 0 + } + { + 'code': 61 + 'iso': 'AU' + 'name': 'Australia' + 'population': 21 + } + { + 'code': 43 + 'iso': 'AT' + 'name': 'Austria' + 'population': 8 + } + { + 'code': 994 + 'iso': 'AZ' + 'name': 'Azerbaijan' + 'population': 8 + } + { + 'code': 1242 + 'iso': 'BS' + 'name': 'Bahamas' + 'population': 0 + } + { + 'code': 973 + 'iso': 'BH' + 'name': 'Bahrain' + 'population': 0 + } + { + 'code': 880 + 'iso': 'BD' + 'name': 'Bangladesh' + 'population': 171 + } + { + 'code': 1246 + 'iso': 'BB' + 'name': 'Barbados' + 'population': 0 + } + { + 'code': 375 + 'iso': 'BY' + 'name': 'Belarus' + 'population': 9 + } + { + 'code': 32 + 'iso': 'BE' + 'name': 'Belgium' + 'population': 10 + } + { + 'code': 501 + 'iso': 'BZ' + 'name': 'Belize' + 'population': 0 + } + { + 'code': 229 + 'iso': 'BJ' + 'name': 'Benin' + 'population': 9 + } + { + 'code': 1441 + 'iso': 'BM' + 'name': 'Bermuda' + 'population': 0 + } + { + 'code': 975 + 'iso': 'BT' + 'name': 'Bhutan' + 'population': 0 + } + { + 'code': 591 + 'iso': 'BO' + 'name': 'Bolivia' + 'population': 9 + } + { + 'code': 387 + 'iso': 'BA' + 'name': 'Bosnia and Herzegovina' + 'population': 4 + } + { + 'code': 267 + 'iso': 'BW' + 'name': 'Botswana' + 'population': 2 + } + { + 'code': 55 + 'iso': 'BR' + 'name': 'Brazil' + 'population': 201 + } + { + 'code': 246 + 'iso': 'IO' + 'name': 'British Indian Ocean Territory' + 'population': 0 + } + { + 'code': 1284 + 'iso': 'VG' + 'name': 'British Virgin Islands' + 'population': 0 + } + { + 'code': 673 + 'iso': 'BN' + 'name': 'Brunei' + 'population': 0 + } + { + 'code': 359 + 'iso': 'BG' + 'name': 'Bulgaria' + 'population': 7 + } + { + 'code': 226 + 'iso': 'BF' + 'name': 'Burkina Faso' + 'population': 16 + } + { + 'code': 257 + 'iso': 'BI' + 'name': 'Burundi' + 'population': 9 + } + { + 'code': 855 + 'iso': 'KH' + 'name': 'Cambodia' + 'population': 14 + } + { + 'code': 237 + 'iso': 'CM' + 'name': 'Cameroon' + 'population': 19 + } + { + 'code': 1 + 'iso': 'CA' + 'name': 'Canada' + 'population': 33 + } + { + 'code': 238 + 'iso': 'CV' + 'name': 'Cape Verde' + 'population': 0 + } + { + 'code': 1345 + 'iso': 'KY' + 'name': 'Cayman Islands' + 'population': 0 + } + { + 'code': 236 + 'iso': 'CF' + 'name': 'Central African Republic' + 'population': 4 + } + { + 'code': 235 + 'iso': 'TD' + 'name': 'Chad' + 'population': 10 + } + { + 'code': 56 + 'iso': 'CL' + 'name': 'Chile' + 'population': 16 + } + { + 'code': 86 + 'iso': 'CN' + 'name': 'China' + 'population': 1376 + } + { + 'code': 61 + 'iso': 'CX' + 'name': 'Christmas Island' + 'population': 0 + } + { + 'code': 61 + 'iso': 'CC' + 'name': 'Cocos Islands' + 'population': 0 + } + { + 'code': 57 + 'iso': 'CO' + 'name': 'Colombia' + 'population': 47 + } + { + 'code': 269 + 'iso': 'KM' + 'name': 'Comoros' + 'population': 0 + } + { + 'code': 682 + 'iso': 'CK' + 'name': 'Cook Islands' + 'population': 0 + } + { + 'code': 506 + 'iso': 'CR' + 'name': 'Costa Rica' + 'population': 4 + } + { + 'code': 385 + 'iso': 'HR' + 'name': 'Croatia' + 'population': 4 + } + { + 'code': 53 + 'iso': 'CU' + 'name': 'Cuba' + 'population': 11 + } + { + 'code': 599 + 'iso': 'CW' + 'name': 'Curacao' + 'population': 0 + } + { + 'code': 357 + 'iso': 'CY' + 'name': 'Cyprus' + 'population': 1 + } + { + 'code': 420 + 'iso': 'CZ' + 'name': 'Czech Republic' + 'population': 10 + } + { + 'code': 243 + 'iso': 'CD' + 'name': 'Democratic Republic of the Congo' + 'population': 81 + } + { + 'code': 45 + 'iso': 'DK' + 'name': 'Denmark' + 'population': 5 + } + { + 'code': 253 + 'iso': 'DJ' + 'name': 'Djibouti' + 'population': 0 + } + { + 'code': 1767 + 'iso': 'DM' + 'name': 'Dominica' + 'population': 0 + } + { + 'code': 1809 + 'iso': 'DO' + 'name': 'Dominican Republic' + 'population': 9 + } + { + 'code': 670 + 'iso': 'TL' + 'name': 'East Timor' + 'population': 1 + } + { + 'code': 593 + 'iso': 'EC' + 'name': 'Ecuador' + 'population': 14 + } + { + 'code': 20 + 'iso': 'EG' + 'name': 'Egypt' + 'population': 91 + } + { + 'code': 503 + 'iso': 'SV' + 'name': 'El Salvador' + 'population': 6 + } + { + 'code': 240 + 'iso': 'GQ' + 'name': 'Equatorial Guinea' + 'population': 1 + } + { + 'code': 291 + 'iso': 'ER' + 'name': 'Eritrea' + 'population': 5 + } + { + 'code': 372 + 'iso': 'EE' + 'name': 'Estonia' + 'population': 1 + } + { + 'code': 251 + 'iso': 'ET' + 'name': 'Ethiopia' + 'population': 88 + } + { + 'code': 500 + 'iso': 'FK' + 'name': 'Falkland Islands' + 'population': 0 + } + { + 'code': 298 + 'iso': 'FO' + 'name': 'Faroe Islands' + 'population': 0 + } + { + 'code': 679 + 'iso': 'FJ' + 'name': 'Fiji' + 'population': 0 + } + { + 'code': 358 + 'iso': 'FI' + 'name': 'Finland' + 'population': 5 + } + { + 'code': 33 + 'iso': 'FR' + 'name': 'France' + 'population': 64 + } + { + 'code': 689 + 'iso': 'PF' + 'name': 'French Polynesia' + 'population': 0 + } + { + 'code': 241 + 'iso': 'GA' + 'name': 'Gabon' + 'population': 1 + } + { + 'code': 220 + 'iso': 'GM' + 'name': 'Gambia' + 'population': 1 + } + { + 'code': 995 + 'iso': 'GE' + 'name': 'Georgia' + 'population': 4 + } + { + 'code': 49 + 'iso': 'DE' + 'name': 'Germany' + 'population': 81 + } + { + 'code': 233 + 'iso': 'GH' + 'name': 'Ghana' + 'population': 24 + } + { + 'code': 350 + 'iso': 'GI' + 'name': 'Gibraltar' + 'population': 0 + } + { + 'code': 30 + 'iso': 'GR' + 'name': 'Greece' + 'population': 11 + } + { + 'code': 299 + 'iso': 'GL' + 'name': 'Greenland' + 'population': 0 + } + { + 'code': 1473 + 'iso': 'GD' + 'name': 'Grenada' + 'population': 0 + } + { + 'code': 1671 + 'iso': 'GU' + 'name': 'Guam' + 'population': 0 + } + { + 'code': 502 + 'iso': 'GT' + 'name': 'Guatemala' + 'population': 13 + } + { + 'code': 441481 + 'iso': 'GG' + 'name': 'Guernsey' + 'population': 0 + } + { + 'code': 224 + 'iso': 'GN' + 'name': 'Guinea' + 'population': 10 + } + { + 'code': 245 + 'iso': 'GW' + 'name': 'Guinea-Bissau' + 'population': 1 + } + { + 'code': 592 + 'iso': 'GY' + 'name': 'Guyana' + 'population': 0 + } + { + 'code': 509 + 'iso': 'HT' + 'name': 'Haiti' + 'population': 9 + } + { + 'code': 504 + 'iso': 'HN' + 'name': 'Honduras' + 'population': 7 + } + { + 'code': 852 + 'iso': 'HK' + 'name': 'Hong Kong' + 'population': 6 + } + { + 'code': 36 + 'iso': 'HU' + 'name': 'Hungary' + 'population': 9 + } + { + 'code': 354 + 'iso': 'IS' + 'name': 'Iceland' + 'population': 0 + } + { + 'code': 91 + 'iso': 'IN' + 'name': 'India' + 'population': 1173 + } + { + 'code': 62 + 'iso': 'ID' + 'name': 'Indonesia' + 'population': 242 + } + { + 'code': 98 + 'iso': 'IR' + 'name': 'Iran' + 'population': 76 + } + { + 'code': 964 + 'iso': 'IQ' + 'name': 'Iraq' + 'population': 29 + } + { + 'code': 353 + 'iso': 'IE' + 'name': 'Ireland' + 'population': 4 + } + { + 'code': 441624 + 'iso': 'IM' + 'name': 'Isle of Man' + 'population': 0 + } + { + 'code': 972 + 'iso': 'IL' + 'name': 'Israel' + 'population': 7 + } + { + 'code': 39 + 'iso': 'IT' + 'name': 'Italy' + 'population': 60 + } + { + 'code': 225 + 'iso': 'CI' + 'name': 'Ivory Coast' + 'population': 21 + } + { + 'code': 1876 + 'iso': 'JM' + 'name': 'Jamaica' + 'population': 2 + } + { + 'code': 81 + 'iso': 'JP' + 'name': 'Japan' + 'population': 127 + } + { + 'code': 441534 + 'iso': 'JE' + 'name': 'Jersey' + 'population': 0 + } + { + 'code': 962 + 'iso': 'JO' + 'name': 'Jordan' + 'population': 6 + } + { + 'code': 7 + 'iso': 'KZ' + 'name': 'Kazakhstan' + 'population': 15 + } + { + 'code': 254 + 'iso': 'KE' + 'name': 'Kenya' + 'population': 40 + } + { + 'code': 686 + 'iso': 'KI' + 'name': 'Kiribati' + 'population': 0 + } + { + 'code': 383 + 'iso': 'XK' + 'name': 'Kosovo' + 'population': 1 + } + { + 'code': 965 + 'iso': 'KW' + 'name': 'Kuwait' + 'population': 2 + } + { + 'code': 996 + 'iso': 'KG' + 'name': 'Kyrgyzstan' + 'population': 5 + } + { + 'code': 856 + 'iso': 'LA' + 'name': 'Laos' + 'population': 6 + } + { + 'code': 371 + 'iso': 'LV' + 'name': 'Latvia' + 'population': 2 + } + { + 'code': 961 + 'iso': 'LB' + 'name': 'Lebanon' + 'population': 4 + } + { + 'code': 266 + 'iso': 'LS' + 'name': 'Lesotho' + 'population': 1 + } + { + 'code': 231 + 'iso': 'LR' + 'name': 'Liberia' + 'population': 3 + } + { + 'code': 218 + 'iso': 'LY' + 'name': 'Libya' + 'population': 6 + } + { + 'code': 423 + 'iso': 'LI' + 'name': 'Liechtenstein' + 'population': 0 + } + { + 'code': 370 + 'iso': 'LT' + 'name': 'Lithuania' + 'population': 2 + } + { + 'code': 352 + 'iso': 'LU' + 'name': 'Luxembourg' + 'population': 0 + } + { + 'code': 853 + 'iso': 'MO' + 'name': 'Macao' + 'population': 0 + } + { + 'code': 389 + 'iso': 'MK' + 'name': 'Macedonia' + 'population': 2 + } + { + 'code': 261 + 'iso': 'MG' + 'name': 'Madagascar' + 'population': 21 + } + { + 'code': 265 + 'iso': 'MW' + 'name': 'Malawi' + 'population': 15 + } + { + 'code': 60 + 'iso': 'MY' + 'name': 'Malaysia' + 'population': 28 + } + { + 'code': 960 + 'iso': 'MV' + 'name': 'Maldives' + 'population': 0 + } + { + 'code': 223 + 'iso': 'ML' + 'name': 'Mali' + 'population': 13 + } + { + 'code': 356 + 'iso': 'MT' + 'name': 'Malta' + 'population': 0 + } + { + 'code': 692 + 'iso': 'MH' + 'name': 'Marshall Islands' + 'population': 0 + } + { + 'code': 222 + 'iso': 'MR' + 'name': 'Mauritania' + 'population': 3 + } + { + 'code': 230 + 'iso': 'MU' + 'name': 'Mauritius' + 'population': 1 + } + { + 'code': 262 + 'iso': 'YT' + 'name': 'Mayotte' + 'population': 0 + } + { + 'code': 52 + 'iso': 'MX' + 'name': 'Mexico' + 'population': 112 + } + { + 'code': 691 + 'iso': 'FM' + 'name': 'Micronesia' + 'population': 0 + } + { + 'code': 373 + 'iso': 'MD' + 'name': 'Moldova' + 'population': 4 + } + { + 'code': 377 + 'iso': 'MC' + 'name': 'Monaco' + 'population': 0 + } + { + 'code': 976 + 'iso': 'MN' + 'name': 'Mongolia' + 'population': 3 + } + { + 'code': 382 + 'iso': 'ME' + 'name': 'Montenegro' + 'population': 0 + } + { + 'code': 1664 + 'iso': 'MS' + 'name': 'Montserrat' + 'population': 0 + } + { + 'code': 212 + 'iso': 'MA' + 'name': 'Morocco' + 'population': 33 + } + { + 'code': 258 + 'iso': 'MZ' + 'name': 'Mozambique' + 'population': 22 + } + { + 'code': 95 + 'iso': 'MM' + 'name': 'Myanmar' + 'population': 53 + } + { + 'code': 264 + 'iso': 'NA' + 'name': 'Namibia' + 'population': 2 + } + { + 'code': 674 + 'iso': 'NR' + 'name': 'Nauru' + 'population': 0 + } + { + 'code': 977 + 'iso': 'NP' + 'name': 'Nepal' + 'population': 28 + } + { + 'code': 31 + 'iso': 'NL' + 'name': 'Netherlands' + 'population': 16 + } + { + 'code': 599 + 'iso': 'AN' + 'name': 'Netherlands Antilles' + 'population': 0 + } + { + 'code': 687 + 'iso': 'NC' + 'name': 'New Caledonia' + 'population': 0 + } + { + 'code': 64 + 'iso': 'NZ' + 'name': 'New Zealand' + 'population': 4 + } + { + 'code': 505 + 'iso': 'NI' + 'name': 'Nicaragua' + 'population': 5 + } + { + 'code': 227 + 'iso': 'NE' + 'name': 'Niger' + 'population': 15 + } + { + 'code': 234 + 'iso': 'NG' + 'name': 'Nigeria' + 'population': 182 + } + { + 'code': 683 + 'iso': 'NU' + 'name': 'Niue' + 'population': 0 + } + { + 'code': 850 + 'iso': 'KP' + 'name': 'North Korea' + 'population': 22 + } + { + 'code': 1670 + 'iso': 'MP' + 'name': 'Northern Mariana Islands' + 'population': 0 + } + { + 'code': 47 + 'iso': 'NO' + 'name': 'Norway' + 'population': 5 + } + { + 'code': 968 + 'iso': 'OM' + 'name': 'Oman' + 'population': 2 + } + { + 'code': 92 + 'iso': 'PK' + 'name': 'Pakistan' + 'population': 184 + } + { + 'code': 680 + 'iso': 'PW' + 'name': 'Palau' + 'population': 0 + } + { + 'code': 970 + 'iso': 'PS' + 'name': 'Palestine' + 'population': 3 + } + { + 'code': 507 + 'iso': 'PA' + 'name': 'Panama' + 'population': 3 + } + { + 'code': 675 + 'iso': 'PG' + 'name': 'Papua New Guinea' + 'population': 6 + } + { + 'code': 595 + 'iso': 'PY' + 'name': 'Paraguay' + 'population': 6 + } + { + 'code': 51 + 'iso': 'PE' + 'name': 'Peru' + 'population': 29 + } + { + 'code': 63 + 'iso': 'PH' + 'name': 'Philippines' + 'population': 102 + } + { + 'code': 64 + 'iso': 'PN' + 'name': 'Pitcairn' + 'population': 0 + } + { + 'code': 48 + 'iso': 'PL' + 'name': 'Poland' + 'population': 38 + } + { + 'code': 351 + 'iso': 'PT' + 'name': 'Portugal' + 'population': 10 + } + { + 'code': 1787 + 'iso': 'PR' + 'name': 'Puerto Rico' + 'population': 3 + } + { + 'code': 974 + 'iso': 'QA' + 'name': 'Qatar' + 'population': 0 + } + { + 'code': 242 + 'iso': 'CG' + 'name': 'Republic of the Congo' + 'population': 3 + } + { + 'code': 262 + 'iso': 'RE' + 'name': 'Reunion' + 'population': 0 + } + { + 'code': 40 + 'iso': 'RO' + 'name': 'Romania' + 'population': 21 + } + { + 'code': 7 + 'iso': 'RU' + 'name': 'Russia' + 'population': 140 + } + { + 'code': 250 + 'iso': 'RW' + 'name': 'Rwanda' + 'population': 11 + } + { + 'code': 590 + 'iso': 'BL' + 'name': 'Saint Barthelemy' + 'population': 0 + } + { + 'code': 290 + 'iso': 'SH' + 'name': 'Saint Helena' + 'population': 0 + } + { + 'code': 1869 + 'iso': 'KN' + 'name': 'Saint Kitts and Nevis' + 'population': 0 + } + { + 'code': 1758 + 'iso': 'LC' + 'name': 'Saint Lucia' + 'population': 0 + } + { + 'code': 590 + 'iso': 'MF' + 'name': 'Saint Martin' + 'population': 0 + } + { + 'code': 508 + 'iso': 'PM' + 'name': 'Saint Pierre and Miquelon' + 'population': 0 + } + { + 'code': 1784 + 'iso': 'VC' + 'name': 'Saint Vincent and the Grenadines' + 'population': 0 + } + { + 'code': 685 + 'iso': 'WS' + 'name': 'Samoa' + 'population': 0 + } + { + 'code': 378 + 'iso': 'SM' + 'name': 'San Marino' + 'population': 0 + } + { + 'code': 239 + 'iso': 'ST' + 'name': 'Sao Tome and Principe' + 'population': 0 + } + { + 'code': 966 + 'iso': 'SA' + 'name': 'Saudi Arabia' + 'population': 25 + } + { + 'code': 221 + 'iso': 'SN' + 'name': 'Senegal' + 'population': 12 + } + { + 'code': 381 + 'iso': 'RS' + 'name': 'Serbia' + 'population': 7 + } + { + 'code': 248 + 'iso': 'SC' + 'name': 'Seychelles' + 'population': 0 + } + { + 'code': 232 + 'iso': 'SL' + 'name': 'Sierra Leone' + 'population': 5 + } + { + 'code': 65 + 'iso': 'SG' + 'name': 'Singapore' + 'population': 4 + } + { + 'code': 1721 + 'iso': 'SX' + 'name': 'Sint Maarten' + 'population': 0 + } + { + 'code': 421 + 'iso': 'SK' + 'name': 'Slovakia' + 'population': 5 + } + { + 'code': 386 + 'iso': 'SI' + 'name': 'Slovenia' + 'population': 2 + } + { + 'code': 677 + 'iso': 'SB' + 'name': 'Solomon Islands' + 'population': 0 + } + { + 'code': 252 + 'iso': 'SO' + 'name': 'Somalia' + 'population': 10 + } + { + 'code': 27 + 'iso': 'ZA' + 'name': 'South Africa' + 'population': 54 + } + { + 'code': 82 + 'iso': 'KR' + 'name': 'South Korea' + 'population': 48 + } + { + 'code': 211 + 'iso': 'SS' + 'name': 'South Sudan' + 'population': 8 + } + { + 'code': 34 + 'iso': 'ES' + 'name': 'Spain' + 'population': 46 + } + { + 'code': 94 + 'iso': 'LK' + 'name': 'Sri Lanka' + 'population': 21 + } + { + 'code': 249 + 'iso': 'SD' + 'name': 'Sudan' + 'population': 35 + } + { + 'code': 597 + 'iso': 'SR' + 'name': 'Suriname' + 'population': 0 + } + { + 'code': 47 + 'iso': 'SJ' + 'name': 'Svalbard and Jan Mayen' + 'population': 0 + } + { + 'code': 268 + 'iso': 'SZ' + 'name': 'Swaziland' + 'population': 1 + } + { + 'code': 46 + 'iso': 'SE' + 'name': 'Sweden' + 'population': 9 + } + { + 'code': 41 + 'iso': 'CH' + 'name': 'Switzerland' + 'population': 8 + } + { + 'code': 963 + 'iso': 'SY' + 'name': 'Syria' + 'population': 22 + } + { + 'code': 886 + 'iso': 'TW' + 'name': 'Taiwan' + 'population': 22 + } + { + 'code': 992 + 'iso': 'TJ' + 'name': 'Tajikistan' + 'population': 7 + } + { + 'code': 255 + 'iso': 'TZ' + 'name': 'Tanzania' + 'population': 41 + } + { + 'code': 66 + 'iso': 'TH' + 'name': 'Thailand' + 'population': 67 + } + { + 'code': 228 + 'iso': 'TG' + 'name': 'Togo' + 'population': 6 + } + { + 'code': 690 + 'iso': 'TK' + 'name': 'Tokelau' + 'population': 0 + } + { + 'code': 676 + 'iso': 'TO' + 'name': 'Tonga' + 'population': 0 + } + { + 'code': 1868 + 'iso': 'TT' + 'name': 'Trinidad and Tobago' + 'population': 1 + } + { + 'code': 216 + 'iso': 'TN' + 'name': 'Tunisia' + 'population': 10 + } + { + 'code': 90 + 'iso': 'TR' + 'name': 'Turkey' + 'population': 77 + } + { + 'code': 993 + 'iso': 'TM' + 'name': 'Turkmenistan' + 'population': 4 + } + { + 'code': 1649 + 'iso': 'TC' + 'name': 'Turks and Caicos Islands' + 'population': 0 + } + { + 'code': 688 + 'iso': 'TV' + 'name': 'Tuvalu' + 'population': 0 + } + { + 'code': 1340 + 'iso': 'VI' + 'name': 'U.S. Virgin Islands' + 'population': 0 + } + { + 'code': 256 + 'iso': 'UG' + 'name': 'Uganda' + 'population': 33 + } + { + 'code': 380 + 'iso': 'UA' + 'name': 'Ukraine' + 'population': 45 + } + { + 'code': 971 + 'iso': 'AE' + 'name': 'United Arab Emirates' + 'population': 4 + } + { + 'code': 44 + 'iso': 'GB' + 'name': 'United Kingdom' + 'population': 65 + } + { + 'code': 1 + 'iso': 'US' + 'name': 'United States' + 'population': 324 + } + { + 'code': 598 + 'iso': 'UY' + 'name': 'Uruguay' + 'population': 3 + } + { + 'code': 998 + 'iso': 'UZ' + 'name': 'Uzbekistan' + 'population': 27 + } + { + 'code': 678 + 'iso': 'VU' + 'name': 'Vanuatu' + 'population': 0 + } + { + 'code': 379 + 'iso': 'VA' + 'name': 'Vatican' + 'population': 0 + } + { + 'code': 58 + 'iso': 'VE' + 'name': 'Venezuela' + 'population': 26 + } + { + 'code': 84 + 'iso': 'VN' + 'name': 'Vietnam' + 'population': 91 + } + { + 'code': 681 + 'iso': 'WF' + 'name': 'Wallis and Futuna' + 'population': 0 + } + { + 'code': 212 + 'iso': 'EH' + 'name': 'Western Sahara' + 'population': 0 + } + { + 'code': 967 + 'iso': 'YE' + 'name': 'Yemen' + 'population': 24 + } + { + 'code': 260 + 'iso': 'ZM' + 'name': 'Zambia' + 'population': 16 + } + { + 'code': 263 + 'iso': 'ZW' + 'name': 'Zimbabwe' + 'population': 12 + } + ] + + return {} = + get_country_code: get_country_code + get_country_name: get_country_name + get_country_by_code: get_country_by_code + COUNTRY_CODES: COUNTRY_CODES diff --git a/app/script/util/Crypto.coffee b/app/script/util/Crypto.coffee new file mode 100644 index 00000000000..72393180346 --- /dev/null +++ b/app/script/util/Crypto.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/. +# + +z.util.Crypto = do -> + Hashing = {} + + # Jenkins's one-at-a-time hash + Hashing.joaat_hash = (str) -> + uint32 = window.uint32 + hash = uint32.toUint32 0 + key = str.toLowerCase() + + for i in [0..key.length - 1] + hash = uint32.addMod32 hash, uint32.toUint32 key.charCodeAt i + hash = uint32.addMod32 hash, uint32.shiftLeft hash, 10 + hash = uint32.xor hash, uint32.shiftRight hash, 6 + + hash = uint32.addMod32 hash, uint32.shiftLeft hash, 3 + hash = uint32.xor hash, uint32.shiftRight hash, 11 + hash = uint32.addMod32 hash, uint32.shiftLeft hash, 15 + + return hash + + public_methods = + Hashing: Hashing + + return public_methods diff --git a/app/script/util/DebugUtil.coffee b/app/script/util/DebugUtil.coffee new file mode 100644 index 00000000000..15ecce1fa48 --- /dev/null +++ b/app/script/util/DebugUtil.coffee @@ -0,0 +1,50 @@ +# +# 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.util ?= {} + +class z.util.DebugUtil + constructor: (@user_repository, @conversation_repository) -> + @logger = new z.util.Logger 'z.util.DebugUtil', z.config.LOGGER.OPTIONS + + _get_conversation_by_id: (conversation_id) -> + return new Promise (resolve) => + @conversation_repository.get_conversation_by_id conversation_id, (conversation_et) -> + resolve conversation_et + + _get_user_by_id: (user_id) -> + return new Promise (resolve) => + @user_repository.get_user_by_id user_id, (user_et) -> resolve user_et + + get_event_info: (event) -> + debug_information = + event: event + + return new Promise (resolve) => + @_get_conversation_by_id event.conversation + .then (conversation_et) => + debug_information.conversation = conversation_et + return @_get_user_by_id event.from + .then (user_et) => + debug_information.user = user_et + log_message = "Hey #{@user_repository.self().name()}, this is for you:" + @logger.log @logger.levels.WARN, log_message, debug_information + @logger.log @logger.levels.WARN, "Conversation: #{debug_information.conversation.name()}", debug_information.conversation + @logger.log @logger.levels.WARN, "From: #{debug_information.user.name()}", debug_information.user + resolve debug_information diff --git a/app/script/util/Environment.coffee b/app/script/util/Environment.coffee new file mode 100644 index 00000000000..3db4ead32a1 --- /dev/null +++ b/app/script/util/Environment.coffee @@ -0,0 +1,133 @@ +# +# 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.util ?= {} + +APP_ENV = + LOCALHOST: 'localhost' + PRODUCTION: 'app.wire.com' + PROD_NEXT: 'wire-webapp-prod-next.wire.com' + TACO: 'taco.wire.com' + VIRTUAL_HOST: 'wire.ms' # The domain "wire.ms" is our virtual host for testing contact uploads + +BROWSER_NAME = + CHROME: 'Chrome' + EDGE: 'Microsoft Edge' + ELECTRON: 'Electron' + FIREFOX: 'Firefox' + OPERA: 'Opera' + +PLATFORM_NAME = + MACINTOSH: 'Mac' + WINDOWS: 'Win' + +# TODO: We need to remove the "do ->" pattern and invoke the Environment ourselves! +# Otherwise we cannot override the "navigator.userAgent" in our integration tests to check the behaviour of this class. +z.util.Environment = do -> + _check = + is_chrome: -> + return platform.name is BROWSER_NAME.CHROME + is_edge: -> + return platform.name is BROWSER_NAME.EDGE + is_firefox: -> + return platform.name is BROWSER_NAME.FIREFOX + is_opera: -> + return platform.name is BROWSER_NAME.OPERA + is_electron: -> + return navigator.userAgent.includes BROWSER_NAME.ELECTRON + + get_version: -> + return window.parseInt platform.version?.split('.')[0], 10 + + requires_codec_rewrite: -> + return false if not @supports_calling() + return @is_chrome() and @get_version() in [50, 51] + + supports_notifications: -> + return false if window.Notification is undefined + return false if window.Notification.requestPermission is undefined + return false if document.visibilityState is undefined + return true + supports_calling: -> + return false if window.WebSocket is undefined + return false if not navigator.mediaDevices?.getUserMedia + return false if @is_edge() + return @is_chrome() or @is_firefox() or @is_opera() + supports_screen_sharing: -> + return true if window.desktopCapturer + return @is_firefox() and @get_version() >= 48 + + os = + is_mac: -> + return navigator.platform.includes PLATFORM_NAME.MACINTOSH + is_windows: -> + return navigator.platform.includes PLATFORM_NAME.WINDOWS + + # add body information + os_css_class = if os.is_mac() then 'os-mac' else 'os-pc' + platform_css_class = if _check.is_electron() then 'platform-electron' else 'platform-web' + $(document.body).addClass "#{os_css_class} #{platform_css_class}" + + app_version = -> + if $("[property='wire:version']").attr('version')? + version = $("[property='wire:version']").attr('version').trim().split '-' + return "#{version[0]}.#{version[1]}.#{version[2]}.#{version[3]}#{version[4]}" + + ################ + # PUBLIC METHODS + ################ + + # "backend.current" is "undefined" when you are not connected to the backend (for example, if you are on the login page). + # In such situations use methods like "is_staging" to detect environments. + # + backend: current: undefined + + frontend: + is_localhost: -> + return window.location.hostname in [APP_ENV.LOCALHOST, APP_ENV.VIRTUAL_HOST] + is_production: -> + return window.location.hostname in [APP_ENV.PRODUCTION, APP_ENV.PROD_NEXT, APP_ENV.TACO] + + browser: + name: platform.name + version: _check.get_version() + + chrome: _check.is_chrome() + edge: _check.is_edge() + firefox: _check.is_firefox() + opera: _check.is_opera() + + supports: + calling: _check.supports_calling() + notifications: _check.supports_notifications() + screen_sharing: _check.supports_screen_sharing() + requires: + calling_codec_rewrite: _check.requires_codec_rewrite() + + os: + linux: not os.is_mac() and not os.is_windows() + mac: os.is_mac() + win: os.is_windows() + + electron: _check.is_electron() + + version: (show_wrapper_version = true) -> + return 'dev' if z.util.Environment.frontend.is_localhost() + return window.electron_version if window.electron_version and show_wrapper_version + return app_version() diff --git a/app/script/util/Invite.coffee b/app/script/util/Invite.coffee new file mode 100644 index 00000000000..30085bc56a7 --- /dev/null +++ b/app/script/util/Invite.coffee @@ -0,0 +1,162 @@ +# +# 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/. +# + +### + Encoding + ========= + + The initial data is: + +-----------------------+--------------+--------------------------+ + | 14 bytes random data | 2 bytes time | 16 bytes UUID | + +-----------------------+--------------+--------------------------+ + + The initial data is then encoded with AES, no IV, no padding. + + Time is the number of hours since 01 Jan 2014 00:00 as a Big Endian unsigned int16. +### + +z.util.Invite = do -> + + RANDOM_DATA_SIZE = 14 + TIME_DATA_SIZE = 2 + UUID_DATA_SIZE = 16 + BUFFER_SIZE = RANDOM_DATA_SIZE + TIME_DATA_SIZE + UUID_DATA_SIZE + + HOUR_IN_SEC = 60 * 60 + + REFERENCE_TIMESTAMP = 1388534400 # 01 Jan 2014 00:00:00 + + INVITATION_TO_CONNECT_BASE_URL = 'https://wire.com/c/' + INVITATION_TO_CONNECT_TTL = 14 # 2 weeks + + # AES + KEY = new Uint8Array [ + 0x64, 0x68, 0xca, 0xee, 0x5c, 0x0, 0x25, 0xf5, 0x68, 0xe4, 0xd0, 0x85, 0xf8, 0x38, 0x28, 0x6a, + 0x8a, 0x98, 0x6d, 0x2d, 0xfa, 0x67, 0x5e, 0x48, 0xa3, 0xed, 0x2a, 0xef, 0xdd, 0xaf, 0xe8, 0xc1 + ] + + ### + Get difference between date and reference date in hours + + @param date [Date] + ### + hours_between_reference_date_and_date: (date) -> + diff = date.getTime() / 1000 - REFERENCE_TIMESTAMP + return Math.floor diff / HOUR_IN_SEC + + ### + Generate invitation token based on user id and expiration date + + @param user_id [String] + @param expiration_date [Date] + ### + encode_data: (user_id, expiration_date) -> + decrypted_data = new Uint8Array BUFFER_SIZE + + # 14 bytes random data + for i in [0...RANDOM_DATA_SIZE] + decrypted_data[i] = (Math.random() * 0x100000000) | 0 + + # 2 bytes time + hours = @hours_between_reference_date_and_date expiration_date + hours_left_byte = hours >>> 8 + hours_right_byte = hours & 255 + decrypted_data.set [hours_left_byte, hours_right_byte], RANDOM_DATA_SIZE + # 16 bytes UUID + uuid_bytes = z.util.uuid_to_bytes user_id + decrypted_data.set uuid_bytes, RANDOM_DATA_SIZE + TIME_DATA_SIZE + + # encrypt without initial vector and padding + encrypted_data = CryptoJS.AES.encrypt CryptoJS.lib.WordArray.create(decrypted_data), CryptoJS.lib.WordArray.create(KEY), + iv: new CryptoJS.lib.WordArray.init(), + padding: CryptoJS.pad.NoPadding + + encrypted_data_base64 = encrypted_data.toString() + + # remove padding and make base64 string url safe + encrypted_data_base64 = encrypted_data_base64 + .replace /\=$/, '' + .replace /\//g, '_' + .replace /\+/g, '-' + + return encrypted_data_base64 + + ### + Extract user id and expiration_date from given invitation token + + @param user_id [String] + @param expiration_date [Date] + ### + decode_data: (data) -> + # add padding and revert url safe characters + data = data + .concat '=' + .replace /_/g, '/' + .replace /-/g, '+' + + # decrypt + decrypted_data = CryptoJS.AES.decrypt data, CryptoJS.lib.WordArray.create(KEY), + iv: new CryptoJS.lib.WordArray.init(), + padding: CryptoJS.pad.NoPadding + + decrypted_data_bytes = z.util.base64_to_array decrypted_data.toString(CryptoJS.enc.Base64) + + return if not decrypted_data_bytes + + # no typed array slice in all browsers + decrypted_data_bytes = Array.prototype.slice.call decrypted_data_bytes + + # decode user id + uuid_bytes = decrypted_data_bytes.slice 16, decrypted_data_bytes.length + uuid = z.util.bytes_to_uuid uuid_bytes + + # decode timestamp + timestamp_bytes = decrypted_data_bytes.slice 14, 16 + timestamp = timestamp_bytes[0] << 8 | timestamp_bytes[1] & 255 + + return [uuid, timestamp] + + ### + Generate invite url for given user id + + @param user_id [String] receiver will be connected with this user + ### + get_invitation_to_connect_url: (user_id) -> + expiration_date = new Date() + expiration_date.setDate expiration_date.getDate() + INVITATION_TO_CONNECT_TTL + return INVITATION_TO_CONNECT_BASE_URL + @encode_data user_id, expiration_date + + ### + Extract user id from invite token. Will return undefined if the token is expired. + + @param token [String] invite to connect token + ### + get_user_from_invitation_token: (token) -> + [uuid, timestamp] = @decode_data token + current_timestamp = @hours_between_reference_date_and_date new Date() + is_valid_token = current_timestamp <= timestamp + return uuid if is_valid_token + return null + + ### + Extract user id from invite url. Will return undefined if the token is expired. + + @param url [String] invite to connect url + ### + get_user_from_invitation_url: (url) -> + return @get_user_from_invitation_token url.replace INVITATION_TO_CONNECT_BASE_URL, '' diff --git a/app/script/util/Statistics.coffee b/app/script/util/Statistics.coffee new file mode 100644 index 00000000000..933345867e2 --- /dev/null +++ b/app/script/util/Statistics.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.util ?= {} +z.util.Statistics ?= {} + +### +Calculates the average of all values within an array. + +@param values [Array] Input values +@param sum [Integer] (optional) Sum value + +@return [Integer] Average value +### +z.util.Statistics.average = (values) -> + return (z.util.Statistics.sum(values) / values.length).toFixed 2 + + +### +Calculates the sum of all value within an array. + +@param values [Array] Input values + +@return [Integer] Sum value +### +z.util.Statistics.sum = (values) -> + return values.reduce (sum, value) -> + sum + value + , 0 + + +### +Calculates the standard deviation within an array. + +@param values [Array] Input values +@param average [Integer] (optional) Average value +### +z.util.Statistics.standard_deviation = (values, average) -> + average = z.util.Statistics.average values if not average + squared_deviations = values.map (value) -> + deviation = value - average + return deviation * deviation + + return (Math.sqrt z.util.Statistics.average squared_deviations).toFixed 2 diff --git a/app/script/util/Time.coffee b/app/script/util/Time.coffee new file mode 100644 index 00000000000..d1220ff4467 --- /dev/null +++ b/app/script/util/Time.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/. +# + +z.util.Time = do -> + ISO8601 = {} + + ISO8601.get_time_difference_in_millis = (iso_string_a, iso_string_b) -> + date_a_in_millis = new Date(iso_string_a).getTime() + date_b_in_millis = new Date(iso_string_b).getTime() + difference = date_a_in_millis - date_b_in_millis + return undefined if window.isNaN difference + return Math.abs(date_a_in_millis - date_b_in_millis) + + ISO8601.get_time_difference_in_seconds = (iso_string_a, iso_string_b) -> + difference = ISO8601.get_time_difference_in_millis iso_string_a, iso_string_b + return undefined if window.isNaN difference + return window.parseInt Math.abs(difference / 1000), 10 + + ISO8601.get_time_difference_in_minutes = (iso_string_a, iso_string_b) -> + difference = ISO8601.get_time_difference_in_millis iso_string_a, iso_string_b + return undefined if window.isNaN difference + return Math.abs(((difference) / 1000) / 60) + + public_methods = + ISO8601: ISO8601 + + return public_methods diff --git a/app/script/util/TimeTracker.coffee b/app/script/util/TimeTracker.coffee new file mode 100644 index 00000000000..4cd3c10a4c7 --- /dev/null +++ b/app/script/util/TimeTracker.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.util ?= {} + +class z.util.TimeTracker + constructor: -> + @time = + start: -1 + stop: -1 + tracked: -1 + in_seconds: -1 + in_mseconds: -1 + + start_timer: => + @time.start = (window.performance or Date).now() + + stop_timer: => + if @time.start is -1 + # You have to start the timer before logging. + return + + @time.stop = (window.performance or Date).now() + @time.tracked = @time.stop - @time.start + @time.in_mseconds = Math.round @time.tracked + @time.in_seconds = (@time.tracked / 1000).toFixed 2 + @get_formatted_time_message() + + get_formatted_time: => + return "#{@time.in_mseconds}ms (#{@time.in_seconds}s)" + + get_formatted_time_message: => + return "Time measured was #{@get_formatted_time()}." diff --git a/app/script/util/confirm.coffee b/app/script/util/confirm.coffee new file mode 100644 index 00000000000..25ba95c21c0 --- /dev/null +++ b/app/script/util/confirm.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/. +# + + + +# show confirm template inside the specified element +# +# @example show confirm dialog +# +# $('#parent').confirm +# template: '#template-confirm' +# data: +# foo: 'bar' +# confirm: -> +# ... do something on confirm ... +# cancel: -> +# ... do something on cancel ... +# +# @param [Object] +# @option template: [String] template that will be displayed +# @option data: [object] used as viewmodel for this dialog +# @option confirm: [Function] will be executed when confirm button is clicked +# @option cancel: [Function] will be executed when cancel button is clicked +# +$.fn.confirm = (config) -> + + template_html = $(config.template).html() + parent = $(@) + parent.append template_html + confirm = parent.find '.confirm' + group = parent.find '.participants-group' + is_visible = true + + is_small = group.hasClass 'small' + group.removeClass 'small' if is_small + + ko.applyBindings config.data, confirm[0] + + if config.data?.user? + stripped_user_name = config.data.user.first_name() + parent + .find '.user' + .html z.util.escape_html stripped_user_name + + requestAnimFrame -> + confirm.addClass 'confirm-is-visible' + + @destroy = -> + is_visible = false + ko.cleanNode confirm[0] + group.addClass 'small' if is_small + parent.find('.confirm').remove() + + @is_visible = -> + return is_visible + + $('[data-action="cancel"]', confirm).click => + config.cancel? config.data + @destroy() + + $('[data-action="confirm"]', confirm).click => + config.confirm? config.data + @destroy() + + return @ diff --git a/app/script/util/keycode.coffee b/app/script/util/keycode.coffee new file mode 100644 index 00000000000..710efc1ea96 --- /dev/null +++ b/app/script/util/keycode.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 = window.z or {} +z.util = z.util or {} + +z.util.KEYCODE = + ARROW_DOWN: 40 + ARROW_LEFT: 37 + ARROW_RIGHT: 39 + ARROW_UP: 38 + BACKSPACE: 46 + DELETE: 8 + ESC: 27 + ENTER: 13 + V: 86 diff --git a/app/script/util/protobuf.coffee b/app/script/util/protobuf.coffee new file mode 100644 index 00000000000..7da0c62b6df --- /dev/null +++ b/app/script/util/protobuf.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.util ?= {} + +z.util.protobuf = + + load_protos: (file) -> + return new Promise (resolve, reject) -> + dcodeIO.ProtoBuf.loadProtoFile file, (error, builder) -> + if error + reject new Error "Loading protocol buffer file failed: #{error.message}" + else + z.proto ?= {} + _.extend z.proto, builder.build() + resolve() diff --git a/app/script/util/scroll-helpers.coffee b/app/script/util/scroll-helpers.coffee new file mode 100644 index 00000000000..9e72bab960e --- /dev/null +++ b/app/script/util/scroll-helpers.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/. +# + + + +$.fn.scroll_end = -> + element = $(@).get 0 + return if not element + return element.scrollHeight - element.clientHeight + + +$.fn.scroll_to_bottom = -> + $element = $(@) + return if $element.length is 0 + $element.scrollTop $element[0].scrollHeight + window.setTimeout => + $element.scrollTop $element[0].scrollHeight if not $(@).is_scrolled_bottom() + , 200 + +$.fn.scroll_by = (distance) -> + $element = $(@) + return if $element.length is 0 + scroll_top = $element[0].scrollTop + $element.scrollTop scroll_top + distance + + +$.fn.is_scrolled_bottom = (offset = 0) -> + $element = $(@) + return if $element.length is 0 + scroll_top = Math.ceil $element.scrollTop() + scroll_height = $element[0].scrollHeight + height = $element[0].clientHeight + return scroll_top + height + offset >= scroll_height + +$.fn.is_scrolled_top = -> + $element = $(@) + return if $element.length is 0 + return $element.scrollTop() is 0 + +$.fn.is_scrollable = -> + element = $(@).get 0 + return if not element + return element.scrollHeight > element.clientHeight diff --git a/app/script/util/shims.coffee b/app/script/util/shims.coffee new file mode 100644 index 00000000000..54cf4407da7 --- /dev/null +++ b/app/script/util/shims.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.util ?= {} + +# http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ +window.requestAnimFrame = (-> + window.requestAnimationFrame or + window.webkitRequestAnimationFrame or + window.mozRequestAnimationFrame or + (callback) -> + window.setTimeout callback, 1000 / 60 +)() diff --git a/app/script/util/storage.coffee b/app/script/util/storage.coffee new file mode 100644 index 00000000000..a9d6b0f927a --- /dev/null +++ b/app/script/util/storage.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.storage ?= {} + +### +# BIG TODO: Turn this into a "z.util.StorageUtil" because the "z.storage" namespace is already occupied! +### + +z.storage.get_value = (key) -> + amplify.store key + + +z.storage.reset_value = (key) -> + z.storage.set_value key, null + + +z.storage.set_value = (key, value, seconds_to_expire) -> + if seconds_to_expire + amplify.store key, value, + expires: seconds_to_expire * 1000 + else + amplify.store key, value diff --git a/app/script/util/types.coffee b/app/script/util/types.coffee new file mode 100644 index 00000000000..eec5f7ac906 --- /dev/null +++ b/app/script/util/types.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.util ?= {} +z.util.types ?= {} + +z.util.types.convert_array_buffer_to_string = (input) -> + if _.isString input + payload = input + else + uncompressed = pako.inflate input + payload = '' + for char, i in uncompressed + payload = "#{payload}#{String.fromCharCode uncompressed[i]}" + return payload diff --git a/app/script/util/util.coffee b/app/script/util/util.coffee new file mode 100644 index 00000000000..16c674d9a0f --- /dev/null +++ b/app/script/util/util.coffee @@ -0,0 +1,685 @@ +# +# 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/. +# +# This module of the Wire Software uses software code from Nicholas Fisher +# governed by the MIT license (https://github.com/KyleAMathews/deepmerge). +# +## The MIT License (MIT) +## +## Copyright (c) 2012 Nicholas Fisher +## +## Permission is hereby granted, free of charge, to any person obtaining a copy +## of this software and associated documentation files (the "Software"), to deal +## in the Software without restriction, including without limitation the rights +## to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +## copies of the Software, and to permit persons to whom the Software is +## furnished to do so, subject to the following conditions: +## +## The above copyright notice and this permission notice shall be included in all +## copies or substantial portions of the Software. +## +## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +## OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +## SOFTWARE. + +window.z ?= {} +z.util ?= {} + +window.LOG = -> + console?.log? arguments... + + +z.util.dummy_image = (width, height) -> + canvas = document.createElement 'canvas' + canvas.width = width + canvas.height = height + ctx = canvas.getContext '2d' + ctx.fillStyle = '#fff' + ctx.fillRect 0, 0, width, height + return canvas.toDataURL 'image/png' + + +z.util.is_same_location = (past_location, current_location) -> + return past_location isnt '' and current_location.startsWith past_location + + +z.util.iterate_array_index = (array, current_index) -> + return undefined if not _.isArray(array) or not _.isNumber current_index + return undefined if not array.length + return (current_index + 1) % array.length + + +z.util.load_file_buffer = (file) -> + return new Promise (resolve, reject) -> + reader = new FileReader() + reader.onload = -> resolve @result + reader.onerror = reject + reader.readAsArrayBuffer file + + +z.util.load_url_buffer = (url, xhr_accessor_function) -> + return new Promise (resolve, reject) -> + xhr = new XMLHttpRequest() + xhr.open 'GET', url, true + xhr.responseType = 'arraybuffer' + xhr.onload = -> resolve [xhr.response, xhr.getResponseHeader 'content-type'] + xhr.onerror = reject + xhr.onabort = reject + xhr_accessor_function? xhr + xhr.send() + + +z.util.load_url_blob = (url, callback) -> + z.util.load_url_buffer url + .then (value) -> + [buffer, type] = value + image_as_blob = new Blob [new Uint8Array buffer], type: type + callback? image_as_blob + + +z.util.append_url_parameter = (url, param) -> + separator = if z.util.contains url, '?' then '&' else '?' + return "#{url}#{separator}#{param}" + + +z.util.get_url_parameter = (name) -> + params = window.location.search.substring(1).split '&' + for param in params + value = param.split '=' + return unescape value[1] if value[0] is name + return null + + +### +Get extension of a filename. + +@param filename [String] filename including extension +@return [String] +### +z.util.get_file_extension = (filename) -> + return '' if not filename.includes '.' + return 'tar.gz' if filename.includes 'tar.gz' + return filename.substr filename.lastIndexOf('.') + 1 + +### +Remove extension of a filename. + +@param filename [String] filename including extension +@return [String] New String without extension +### +z.util.trim_file_extension = (filename) -> + filename = filename.replace '.tar.gz', '' + return filename.replace /\.[^/.]+$/, '' + +### +Format bytes into a human readable string. + +@param bytes [Number] bytes to format +@param decimals [Number] Number of decimals to keep +@return [String] +### +z.util.format_bytes = (bytes, decimals) -> + return '0B' if bytes is 0 + kilobytes = 1024 + decimals = decimals + 1 or 2 + unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + index = Math.floor Math.log(bytes) / Math.log(kilobytes) + return parseFloat((bytes / Math.pow(kilobytes, index)).toFixed(decimals)) + unit[index] + +### +Format seconds into hh:mm:ss. + +@param bytes [seconds] duration to format in seconds +@return [String] +### +z.util.format_seconds = (duration) -> + duration = Math.round duration or 0 + + hours = Math.floor duration / (60 * 60) + divisor_for_minutes = duration % (60 * 60) + minutes = Math.floor divisor_for_minutes / 60 + divisor_for_seconds = divisor_for_minutes % 60 + seconds = Math.ceil divisor_for_seconds + + components = [ + z.util.zero_padding minutes + z.util.zero_padding seconds + ] + + if hours > 0 + components.unshift hours + + return components.join ':' + + +z.util.get_content_type_from_data_url = (data_url) -> + return data_url.split(',')[0].split(':')[1].split(';')[0] + + + +z.util.strip_data_uri = (string) -> + return string.replace /^data:.*,/, '' + + +### +Convert base64 string to UInt8Array. +Function will remove data uri if present + +@param base64 [String] base64 encoded string +@return [UInt8Array] +### +z.util.base64_to_array = (base64) -> + return sodium.from_base64 z.util.strip_data_uri base64 + +### +Convert ArrayBuffer or UInt8Array to base64 string + +@param array [ArrayBuffer|UInt8Array] raw binary data or bytes +@return [String] base64 encoded string +### +z.util.array_to_base64 = (array) -> + return sodium.to_base64 new Uint8Array(array), true + +### +Convert base64 dataURI to Blob + +@param base64 [String] base64 encoded data uri +@return [Blob] +### +z.util.base64_to_blob = (base64) -> + mime_type = z.util.get_content_type_from_data_url base64 + bytes = z.util.base64_to_array base64 + return new Blob [bytes], 'type': mime_type + +z.util.encode_base64_md5_array_buffer_view = (array_buffer_view) -> + word_array = CryptoJS.lib.WordArray.create array_buffer_view + md5_hash = CryptoJS.MD5(word_array).toString() + md5_hash_hex = z.util.read_string_chars_as_hex md5_hash + return btoa md5_hash_hex + +z.util.download_text = (content) -> + blob = new Blob([content], 'type': 'text/plain') + z.util.download_blob blob, 'download.txt' + + +### +Downloads blob using a hidden link element. + +@param blob [Blob] Blob to store +@param filename [String] Data will be saved under this name +### +z.util.download_blob = (blob, filename) -> + url = window.URL.createObjectURL blob + link = document.createElement 'a' + document.body.appendChild link + link.href = url + link.download = filename + link.style = 'display: none' + link.click() + + # Wait before removing resource and link. Needed in FF + setTimeout -> + document.body.removeChild link + window.URL.revokeObjectURL url + , 100 + + +z.util.read_deferred = (file, type) -> + deferred = new $.Deferred() + reader = new FileReader() + reader.onload = (e) -> deferred.resolve e.target.result + reader.onerror = (e) -> deferred.reject @ + + if type is 'buffer' + reader.readAsArrayBuffer file + else if type is 'url' + reader.readAsDataURL file + else + reader.readAsText file + + return deferred.promise() + + +z.util.phone_uri_to_e164 = (phone_number) -> + return phone_number.replace /tel:|-/g, '' + + +z.util.phone_number_to_e164 = (phone_number, country_code) -> + return window.PhoneFormat.formatE164 "#{country_code}".toUpperCase(), "#{phone_number}" + + +z.util.create_random_uuid = -> + return UUID.genV4().hexString + + +z.util.bytes_to_uuid = (bytes) -> + hex = [] + i = 0 + while i < bytes.length + hex.push (bytes[i] >>> 4).toString 16 + hex.push (bytes[i] & 0xF).toString 16 + i++ + hex.join('').replace /(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5' + + +z.util.uuid_to_bytes = (hex) -> + parts = hex.split '-' + ints = [] + intPos = 0 + i = 0 + + while i < parts.length + j = 0 + + while j < parts[i].length + ints[intPos++] = window.parseInt parts[i].substr(j, 2), 16 + j += 2 + i++ + ints + + +z.util.encode_base64 = (text) -> + return window.btoa text + + +z.util.encode_sha256_base64 = (text) -> + return CryptoJS.SHA256(text).toString CryptoJS.enc.Base64 + + +z.util.escape_html = (html) -> + return _.escape html + + +z.util.alias = + # Note IE10 listens to "transitionend" instead of "animationend" + animationend: 'transitionend animationend oAnimationEnd MSAnimationEnd mozAnimationEnd webkitAnimationEnd' + + +z.util.add_blank_targets = (text_with_anchors) -> + return "#{text_with_anchors}".replace /rel="nofollow"/gi, 'target="_blank" rel="nofollow"' + + +z.util.auto_link_emails = (text) -> + email_pattern = /([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/gim + return text.replace email_pattern, '$1' + + +z.util.get_last_characters = (message, amount) -> + return false if message.length < amount + return message.substring message.length - amount + + +z.util.cut_last_characters = (message, amount) -> + return message.substring 0, message.length - amount + + +z.util.markup_links = (message) -> + return message.replace(/ + destination[key] = target[key] + + Object.keys(source).forEach (key) -> + if typeof source[key] isnt 'object' or not source[key] + destination[key] = source[key] + else + if not target[key] + destination[key] = source[key] + else + destination[key] = z.util.merge_objects target[key], source[key] + + return destination + +# Note: We are using "Underscore.js" to escape HTML in the original message +z.util.render_message = (message, theme_color) -> + message = marked message + message = z.util.auto_link_emails message + message = message.replace /\n/g, '
    ' + # Remove
    if it is the last thing in a message + if z.util.get_last_characters(message, '
    '.length) is '
    ' + message = z.util.cut_last_characters message, '
    '.length + + return z.media.MediaParser.render_media_embeds message, theme_color + + +z.util.read_string_chars_as_hex = (text) -> + text.match(/../g).map (x) -> + String.fromCharCode window.parseInt x, 16 + .join '' + + +# append array to knockout observableArray +# source: https://github.com/knockout/knockout/issues/416 +z.util.ko_array_push_all = (ko_array, values_to_push) -> + underlyingArray = ko_array() + ko_array.valueWillMutate() + ko.utils.arrayPushAll underlyingArray, values_to_push + ko_array.valueHasMutated() + + +# prepend array to knockout observableArray +z.util.ko_array_unshift_all = (ko_array, values_to_shift) -> + underlyingArray = ko_array() + ko_array.valueWillMutate() + Array.prototype.unshift.apply underlyingArray, values_to_shift + ko_array.valueHasMutated() + + +### +Add zero padding until limit is reached + +@param value [String|Number] +@return [String] +### +z.util.zero_padding = (value, length = 2) -> + if value.toString().length < length + return z.util.zero_padding "0#{value}", length + return "#{value}" + + +### +Human readable format of a timestamp. Not testable due to timezones :( + +@param timestamp [Number] +@return [String] +### +z.util.format_timestamp = (timestamp) -> + datetime = new Date timestamp + + year = datetime.getFullYear() + month = z.util.zero_padding datetime.getMonth() + 1 + day = z.util.zero_padding datetime.getDate() + hours = z.util.zero_padding datetime.getHours() + minutes = z.util.zero_padding datetime.getMinutes() + seconds = z.util.zero_padding datetime.getSeconds() + + return "#{day}.#{month}.#{year} (#{hours}:#{minutes}:#{seconds})" + + +z.util.sort_groups_by_last_event = (group_a, group_b) -> + return group_b.last_event_timestamp() - group_a.last_event_timestamp() + +### + Returns a copy of an object, which is ordered by the keys of the original object. +### +z.util.sort_object_by_keys = (object, reverse) -> + sorted_object = {} + + keys = Object.keys object + keys.sort() + + if reverse + for key in keys by -1 + value = object[key] + sorted_object[key] = value + else + for key in keys + value = object[key] + sorted_object[key] = value + + return sorted_object + +z.util.sort_user_by_first_name = (user_a, user_b) -> + name_a = user_a.first_name().toLowerCase() + name_b = user_b.first_name().toLowerCase() + return -1 if name_a < name_b + return 1 if name_a > name_b + return 0 + + +z.util.sort_user_by_name = (user_a, user_b) -> + name_a = user_a.name().toLowerCase() + name_b = user_b.name().toLowerCase() + return -1 if name_a < name_b + return 1 if name_a > name_b + return 0 + + +z.util.remove_line_breaks = (string) -> + string.replace /(\r\n|\n|\r)/gm, '' + + +z.util.trim_line_breaks = (string) -> + string.replace /^\s+|\s+$/g, '' + + +z.util.contains = (string = '', query) -> + string = string.toLowerCase() + query = query.toLowerCase() + return string.indexOf(query) isnt -1 + + +z.util.array_get_next = (array, item, filter) -> + index = array.indexOf item + next_index = index + 1 + + # couldn't find the item + return null if index is -1 + + # item is last item in the array + return array[index - 1] if next_index is array.length and index > 0 + + return if next_index >= array.length + + for i in [next_index..array.length] + current_item = array[i] + return current_item unless filter? and not filter current_item + + +z.util.array_is_last = (array, item) -> + return array.indexOf(item) is array.length - 1 + + +# returns chunks of the given size +z.util.array_chunks = (array, size) -> + chunks = [] + temp_array = [].concat array + while temp_array.length + chunks.push temp_array.splice 0, size + return chunks + + +# This will remove url(' and url(" from the beginning of the string. +# It will also remove ") and ') from the end if present. +z.util.strip_url_wrapper = (url) -> + return url.replace(/^url\(["']?/, '').replace /["']?\)$/, '' + + +z.util.valid_profile_image_size = (file, min_width, min_height, callback) -> + image = new Image() + image.onload = -> + callback image.width >= min_width and image.height >= min_height + image.src = window.URL.createObjectURL file + + +z.util.trunc_text = (text, output_length, word_boundary = true) -> + if text.length > output_length + trunc_index = output_length - 1 + if word_boundary and text.lastIndexOf(' ', output_length - 1) > output_length - 25 + trunc_index = text.lastIndexOf ' ', output_length - 1 + text = "#{text.substr 0, trunc_index}#{z.localization.Localizer.get_text z.string.truncation}" + return text + + +z.util.is_valid_email = (email) -> + re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + return re.test email + + +### +Checks if input has the format of an international phone number + +@note Begins with + and contains only numbers + +@param phone_number [String] Input value +@return [Boolean] Is the input a phone number string +### +z.util.is_valid_phone_number = (phone_number) -> + re = /^\+?[1-9]\d{1,14}$/ + return re.test phone_number + + +z.util.get_first_character = (string) -> + re = new RegExp /([\uE000-\uF8FF]|\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDDFF])/ + find_emoji_in_string = re.exec string + if find_emoji_in_string and find_emoji_in_string.index is 0 + return find_emoji_in_string[0] + else + return string[0] + + +z.util.string_format = -> + s = arguments[0] + for i in [0...arguments.length] + reg = new RegExp "\\{#{i}\\}", 'gm' + s = s.replace reg, arguments[++i] + return s + +### +JS Implementation of MurmurHash3 (r136) (as of May 20, 2011) + +@param key [String] ASCII only +@param seed [Integer] Positive integer only + +@return [Integer] 32-bit positive integer +### +z.util.murmurhash3 = `function(key, seed){ + var remainder, bytes, h1, h1b, c1, c1b, c2, c2b, k1, i; + + remainder = key.length & 3; // key.length % 4 + bytes = key.length - remainder; + h1 = seed; + c1 = 0xcc9e2d51; + c2 = 0x1b873593; + i = 0; + + while (i < bytes) { + k1 = + ((key.charCodeAt(i) & 0xff)) | + ((key.charCodeAt(++i) & 0xff) << 8) | + ((key.charCodeAt(++i) & 0xff) << 16) | + ((key.charCodeAt(++i) & 0xff) << 24); + ++i; + + k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff; + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); + h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff; + h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16)); + } + + k1 = 0; + + switch (remainder) { + case 3: + k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; + case 2: + k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; + case 1: + k1 ^= (key.charCodeAt(i) & 0xff); + + k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + h1 ^= k1; + } + + h1 ^= key.length; + + h1 ^= h1 >>> 16; + h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; + h1 ^= h1 >>> 13; + h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff; + h1 ^= h1 >>> 16; + + return h1 >>> 0; +}` + +z.util.get_unix_timestamp = -> + return Math.floor Date.now() / 1000 + + +z.util.get_first_name = (user_et, declension = z.string.Declension.NOMINATIVE) -> + if user_et.is_me + if declension is z.string.Declension.NOMINATIVE + return z.localization.Localizer.get_text z.string.conversation_you_nominative + else if declension is z.string.Declension.DATIVE + return z.localization.Localizer.get_text z.string.conversation_you_dative + else if declension is z.string.Declension.ACCUSATIVE + return z.localization.Localizer.get_text z.string.conversation_you_accusative + return user_et.first_name() + + +z.util.compare_names = (name_a, name_b) -> + z.util.contains window.getSlug(name_a), window.getSlug(name_b) + + +z.util.capitalize_first_char = (s) -> + return "#{s.charAt(0).toUpperCase()}#{s.substring 1}" + +z.util.print_devices_id = (id) -> + return '' if not id + id_with_padding = z.util.zero_padding id, 16 + prettified_id = '' + prettified_id += "#{part}" for part in id_with_padding.match /.{1,2}/g + return prettified_id + +### +Returns bucket for given value based on the specified bucket limits + +@example z.util.bucket_values(13, [0, 5, 10, 15, 20, 25]) will return '11-15') + +@param value [Number] Numeric value that +@param bucket_limits [Array] Array with numeric values that define the upper limit of the bucket + +@return [String] bucket +### +z.util.bucket_values = (value, bucket_limits) -> + return '0' if value is 0 + + for limit, i in bucket_limits + if value <= limit + previous_limit = bucket_limits[i - 1] + return "#{previous_limit+1}-#{limit}" + + last_limit = bucket_limits[bucket_limits.length - 1] + return "#{last_limit+1}-" diff --git a/app/script/view_model/ActionsViewModel.coffee b/app/script/view_model/ActionsViewModel.coffee new file mode 100644 index 00000000000..3b309de0d89 --- /dev/null +++ b/app/script/view_model/ActionsViewModel.coffee @@ -0,0 +1,125 @@ +# +# 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.ViewModel ?= {} + + +class z.ViewModel.ActionsViewModel + constructor: (element_id, @conversation_repository, @user_repository, @conversation_list) -> + @logger = new z.util.Logger 'z.ViewModel.ActionsViewModel', z.config.LOGGER.OPTIONS + + @action_bubbles = {} + @selected_conversation = ko.observable() + @conversations_archived = @conversation_repository.conversations_archived + @conversations_unarchived = @conversation_repository.conversations_unarchived + + @archive_conversation_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_list_archive + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.ARCHIVE} + } + @notify_conversation_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_list_notify + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.SILENCE} + } + @silence_conversation_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_list_silence + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.SILENCE} + } + + # fix for older wrapper versions + @conversation_list.click_on_archive_action = @click_on_archive_action + @conversation_list.click_on_block_action = @click_on_block_action + @conversation_list.click_on_cancel_action = @click_on_cancel_action + @conversation_list.click_on_clear_action = @click_on_clear_action + @conversation_list.click_on_leave_action = @click_on_leave_action + @conversation_list.click_on_mute_action = @click_on_mute_action + @conversation_list.selected_conversation = @selected_conversation + + @_init_subscriptions() + + ko.applyBindings @, document.getElementById element_id + + _init_subscriptions: => + amplify.subscribe z.event.WebApp.SHORTCUT.ARCHIVE, @click_on_archive_action + amplify.subscribe z.event.WebApp.SHORTCUT.SILENCE, @click_on_mute_action + amplify.subscribe z.event.WebApp.ACTION.SHOW, @click_on_actions + + click_on_actions: (conversation_et, event) => + @selected_conversation conversation_et + + $('.conversation-list-item').removeClass 'hover' + list_element = $(event.currentTarget.parentNode.parentNode).addClass 'hover' + + if not @action_bubbles[conversation_et.id] + @action_bubbles[conversation_et.id] = new zeta.webapp.module.Bubble + host_selector: "##{$(event.currentTarget).attr 'id'}" + scroll_selector: '.conversation-list-items' + on_hide: => + list_element.removeClass 'hover' + @action_bubbles[conversation_et.id] = undefined + + @action_bubbles[conversation_et.id].toggle() + + event.stopPropagation() + + click_on_archive_action: => + @conversation_repository.archive_conversation @_click_on_action() + + click_on_block_action: => + conversation_et = @_click_on_action() + next_conversation_et = @conversation_repository.get_next_conversation conversation_et + user_et = conversation_et.participating_user_ets()[0] + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.BLOCK, + data: user_et.first_name() + action: => @user_repository.block_user user_et, -> + amplify.publish z.event.WebApp.CONVERSATION.SWITCH, conversation_et, next_conversation_et + + click_on_cancel_action: => + conversation_et = @_click_on_action() + next_conversation_et = @conversation_repository.get_next_conversation conversation_et + @user_repository.cancel_connection_request conversation_et.participating_user_ets()[0], next_conversation_et + + click_on_clear_action: => + conversation_et = @_click_on_action() + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CLEAR, + data: conversation_et.display_name() + conversation: conversation_et + action: (leave = false) => @conversation_repository.clear_conversation conversation_et, leave + + click_on_leave_action: => + conversation_et = @_click_on_action() + next_conversation_et = @conversation_repository.get_next_conversation conversation_et + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.LEAVE, + data: conversation_et.display_name() + action: => @conversation_repository.leave_conversation conversation_et, next_conversation_et + + click_on_mute_action: => + @conversation_repository.toggle_silence_conversation @_click_on_action() + + click_on_unarchive_action: => + @conversation_repository.unarchive_conversation @_click_on_action(), => + amplify.subscribe z.event.WebApp.ARCHIVE.CLOSE if @conversation_repository.conversations_archived().length is 0 + + _click_on_action: => + conversation_et = @selected_conversation() or @conversation_repository.active_conversation() + return if not conversation_et + amplify.publish z.event.WebApp.ARCHIVE.CLOSE if not conversation_et.is_archived() + @action_bubbles[conversation_et.id]?.hide() + @selected_conversation null + return conversation_et diff --git a/app/script/view_model/ArchiveViewModel.coffee b/app/script/view_model/ArchiveViewModel.coffee new file mode 100644 index 00000000000..485caf73e82 --- /dev/null +++ b/app/script/view_model/ArchiveViewModel.coffee @@ -0,0 +1,72 @@ +# +# 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.ViewModel ?= {} + + +class z.ViewModel.ArchiveViewModel + constructor: (element_id, @conversation_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ArchiveViewModel', z.config.LOGGER.OPTIONS + + @conversations_archived = @conversation_repository.conversations_archived + @is_archive_visible = ko.observable() + + @should_update_scrollbar = (ko.computed => + return @is_archive_visible() + ).extend notify: 'always', rateLimit: 500 + + @_init_subscriptions() + + ko.applyBindings @, document.getElementById element_id + + _init_subscriptions: => + amplify.subscribe z.event.WebApp.ARCHIVE.SHOW, @open + amplify.subscribe z.event.WebApp.SEARCH.SHOW, @close + amplify.subscribe z.event.WebApp.ARCHIVE.CLOSE, @close + + click_on_actions: (conversation_et, event) -> + amplify.publish z.event.WebApp.ACTION.SHOW, conversation_et, event + + click_on_close_archive: -> + amplify.publish z.event.WebApp.ARCHIVE.CLOSE + + click_on_archived_conversation: (conversation_et) => + @conversation_repository.unarchive_conversation conversation_et + amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + amplify.publish z.event.WebApp.ARCHIVE.CLOSE + + open: => + $(document).on 'keydown.show_archive', (event) -> + amplify.publish z.event.WebApp.ARCHIVE.CLOSE if event.keyCode is z.util.KEYCODE.ESC + @conversation_repository.update_conversations @conversation_repository.conversations_archived() + @is_archive_visible Date.now() + @_show() + + close: => + $(document).off 'keydown.show_archive' + @_hide() + +############################################################################### +# Archive animations +############################################################################### + _show: -> + $('#archive').addClass 'archive-is-visible' + + _hide: -> + $('#archive').removeClass 'archive-is-visible' diff --git a/app/script/view_model/AuthViewModel.coffee b/app/script/view_model/AuthViewModel.coffee new file mode 100644 index 00000000000..e3d249f7609 --- /dev/null +++ b/app/script/view_model/AuthViewModel.coffee @@ -0,0 +1,1311 @@ +# +# 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.ViewModel ?= {} + +TIMEOUT = + SHORT: 500 + LONG: 2000 + +### +View model for the auth page. + +@param element_id [] CSS class of the element where this view should be applied to (like "auth-page") +@param auth [z.main.Auth] Class that holds objects needed for app authentication +### +# @formatter:off +class z.ViewModel.AuthViewModel + constructor: (element_id, @auth) -> + @logger = new z.util.Logger 'z.ViewModel.AuthViewModel', z.config.LOGGER.OPTIONS + + @event_tracker = new z.tracking.EventTrackingRepository() + @user_service = new z.user.UserService @auth.client + + # Cryptography + @asset_service = new z.assets.AssetService @auth.client + # TODO: Don't operate with the service directly. Get a repository! + @storage_service = new z.storage.StorageService() + @storage_repository = new z.storage.StorageRepository @storage_service + + @user_mapper = new z.user.UserMapper @asset_service + user_service = new z.user.UserService @auth.client + @user_repository = new z.user.UserRepository user_service, @asset_service + + @cryptography_service = new z.cryptography.CryptographyRepository @auth.client + @cryptography_repository = new z.cryptography.CryptographyRepository @cryptography_service, @storage_repository + @client_service = new z.client.ClientService @auth.client, @storage_service + @client_repository = new z.client.ClientRepository @client_service, @cryptography_repository + + @notification_service = new z.event.NotificationService @auth.client, @storage_service + @web_socket_service = new z.event.WebSocketService @auth.client + @event_repository = new z.event.EventRepository @web_socket_service, @notification_service, @cryptography_repository, @user_repository + + @pending_server_request = ko.observable false + @disabled_by_animation = ko.observable false + + @get_wire = ko.observable false + @session_expired = ko.observable false + + @client_type = ko.observable z.client.ClientType.TEMPORARY + @country_code = ko.observable '' + @country = ko.observable '' + @phone_number = ko.observable '' + @email = ko.observable '' + @name = ko.observable '' + @password = ko.observable '' + @persist = ko.observable if z.util.Environment.electron then true else false + @persist.subscribe (is_persistent) => + if is_persistent then @client_type z.client.ClientType.PERMANENT else z.client.ClientType.TEMPORARY + + @self_user = ko.observable() + + # Manage devices + @remove_form_error = ko.observable false + @device_modal = undefined + @permanent_devices = ko.computed => + client for client in @client_repository.clients() when client.type is z.client.ClientType.PERMANENT + + @registration_context = z.auth.AuthView.REGISTRATION_CONTEXT.EMAIL + @prefilled_email = '' + + @code_digits = ko.observableArray [ + ko.observable '' + ko.observable '' + ko.observable '' + ko.observable '' + ko.observable '' + ko.observable '' + ] + @code = ko.computed => return (digit() for digit in @code_digits()).join('').substr 0, 6 + @code.subscribe (code) => + @_clear_errors() if code.length is 0 + @verify_code() if code.length is 6 + @phone_number_e164 = => return "#{@country_code()}#{@phone_number()}" + + @code_interval_id = undefined + + @code_expiration_timestamp = ko.observable 0 + @code_expiration_in = ko.observable '' + @code_expiration_timestamp.subscribe (timestamp) => + @code_expiration_in moment.unix(timestamp).fromNow() + @code_interval_id = setInterval => + if timestamp <= z.util.get_unix_timestamp() + clearInterval @code_interval_id + return @code_expiration_timestamp 0 + @code_expiration_in moment.unix(timestamp).fromNow() + , 20000 + + @validation_errors = ko.observableArray [] + @failed_validation_email = ko.observable false + @failed_validation_password = ko.observable false + @failed_validation_name = ko.observable false + @failed_validation_code = ko.observable false + @failed_validation_phone = ko.observable false + @failed_validation_terms = ko.observable false + @accepted_terms_of_use = ko.observable false + @accepted_terms_of_use.subscribe => @clear_error z.auth.AuthView.TYPE.TERMS + + @can_login_email = ko.computed => + return not @disabled_by_animation() and @email().length isnt 0 and @password().length isnt 0 + + @can_login_phone = ko.computed => + return not @disabled_by_animation() and @country_code().length > 1 and @phone_number().length isnt 0 + + @can_register = ko.computed => + return not @disabled_by_animation() and @email().length isnt 0 and @name().length isnt 0 and @password().length isnt 0 + + @can_resend_code = ko.computed => + return not @disabled_by_animation() and @code_expiration_timestamp() < z.util.get_unix_timestamp() + + @can_resend_registration = ko.computed => + return not @disabled_by_animation() and @email().length isnt 0 + + @can_resend_verification = ko.computed => + return not @disabled_by_animation() and @email().length isnt 0 + + @account_retry_text = ko.computed => + return z.localization.Localizer.get_text + id: z.string.auth_posted_retry + replace: {placeholder: '%email', content: @email()} + @account_resend_text = ko.computed => + return z.localization.Localizer.get_text + id: z.string.auth_posted_resend + replace: {placeholder: '%email', content: @email()} + @verify_code_text = ko.computed => + phone_number = PhoneFormat.formatNumberForMobileDialing('', @phone_number_e164()) or @phone_number_e164() + return z.localization.Localizer.get_text + id: z.string.auth_verify_code_description + replace: {placeholder: '%@number', content: phone_number} + @verify_code_timer_text = ko.computed => + return z.localization.Localizer.get_text + id: z.string.auth_verify_code_resend_timer + replace: {placeholder: '%expiration', content: @code_expiration_in()} + @verify_email_headline = ko.computed => + return z.localization.Localizer.get_text + id: z.string.auth_verify_email_headline + replace: {placeholder: '%name', content: @self_user()?.first_name()} + + @visible_section = ko.observable undefined + @visible_mode = ko.observable undefined + @visible_method = ko.observable undefined + + @account_mode = ko.observable undefined + @account_mode_login = ko.computed => + login_modes = [ + z.auth.AuthView.MODE.ACCOUNT_LOGIN + z.auth.AuthView.MODE.ACCOUNT_EMAIL + z.auth.AuthView.MODE.ACCOUNT_PHONE + ] + return @account_mode() in login_modes + + @posted_mode = ko.observable undefined + @posted_mode_offline = ko.computed => + return @posted_mode() is z.auth.AuthView.MODE.POSTED_OFFLINE + @posted_mode_pending = ko.computed => + return @posted_mode() is z.auth.AuthView.MODE.POSTED_PENDING + @posted_mode_resend = ko.computed => + return @posted_mode() is z.auth.AuthView.MODE.POSTED_RESEND + @posted_mode_retry = ko.computed => + return @posted_mode() is z.auth.AuthView.MODE.POSTED_RETRY + @posted_mode_verify = ko.computed => + return @posted_mode() is z.auth.AuthView.MODE.POSTED_VERIFY + + # Debugging + if window.location.hostname is 'localhost' + live_reload = document.createElement 'script' + live_reload.id = 'live_reload' + live_reload.src = 'http://localhost:32123/livereload.js' + document.body.appendChild live_reload + $('html').addClass 'development' + + ko.applyBindings @, document.getElementById element_id + + @show_initial_animation = false + + @_init_base() + @_track_app_launch() + $(".#{element_id}").show() + + _init_base: -> + if z.util.get_url_parameter z.auth.URLParameter.CONNECT + @get_wire true + @registration_context = z.auth.AuthView.REGISTRATION_CONTEXT.GENERIC_INVITE + else if z.util.get_url_parameter z.auth.URLParameter.EXPIRED + @session_expired true + + modes_to_block = [ + z.auth.AuthView.MODE.HISTORY + z.auth.AuthView.MODE.LIMIT + z.auth.AuthView.MODE.POSTED + z.auth.AuthView.MODE.POSTED_PENDING + z.auth.AuthView.MODE.POSTED_RETRY + z.auth.AuthView.MODE.POSTED_VERIFY + z.auth.AuthView.MODE.VERIFY_CODE + z.auth.AuthView.MODE.VERIFY_ADD_EMAIL + ] + + if invite = z.util.get_url_parameter z.auth.URLParameter.INVITE + @register_from_invite invite + else if @_has_no_hash() and z.storage.get_value z.storage.StorageKey.AUTH.SHOW_LOGIN + @_set_hash z.auth.AuthView.MODE.ACCOUNT_LOGIN + else if @_get_hash() in modes_to_block + @_set_hash() + else + @_on_hash_change() + + $(window) + .on 'hashchange', @_on_hash_change + .on 'dragover drop', -> false + + $("[id^='wire-login'], [id^='wire-mail'], [id^='wire-register'], [id^='wire-phone-code']").prevent_prefill() + + # Select country based on location of user IP + @country_code (z.util.CountryCodes.get_country_code($('[name=geoip]').attr 'country') or 1).toString() + @changed_country_code() + + + ############################################################################### + # Invitation Stuff + ############################################################################### + + register_from_invite: (invite) => + @auth.repository.retrieve_invite invite + .then (invite_info) => + @registration_context = z.auth.AuthView.REGISTRATION_CONTEXT.PERSONAL_INVITE + @name invite_info.name + if invite_info.email + @email invite_info.email + @prefilled_email = invite_info.email + else + @logger.log @logger.levels.WARN, 'Invite information does not contain an email address' + @_set_hash z.auth.AuthView.MODE.ACCOUNT_REGISTER + @_on_hash_change() + .catch (error) => + if error.label is z.service.BackendClientError::LABEL.INVALID_INVITATION_CODE + @logger.log @logger.levels.WARN, 'Invalid Invitation Code' + else + Raygun.send new Error('Invitation not found'), {invite_code: invite, error: error} + @_on_hash_change() + + + ############################################################################### + # Form actions + ############################################################################### + + # Register a new user account. + register: => + return if @pending_server_request() or not @_validate_input z.auth.AuthView.MODE.ACCOUNT_REGISTER + + @pending_server_request true + @persist true + payload = @_create_payload z.auth.AuthView.MODE.ACCOUNT_REGISTER + @auth.repository.register payload + .then => + @get_wire false + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.ENTERED_CREDENTIALS, + {outcome: 'success'} + + # Track if the user changed the pre-filled email + if @prefilled_email is @email() + @auth.repository.get_access_token().then @_account_verified + else + @_set_hash z.auth.AuthView.MODE.POSTED + @auth.repository.get_access_token().then @_wait_for_activate + @pending_server_request false + .catch (error) => @_on_register_error error + + # Sign in using an email login. + sign_in_email: => + return if @pending_server_request() or not @_validate_input z.auth.AuthView.MODE.ACCOUNT_EMAIL + + @pending_server_request true + payload = @_create_payload z.auth.AuthView.MODE.ACCOUNT_EMAIL + @auth.repository.login payload, @persist() + .then => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.LOGGED_IN, + { + context: z.auth.AuthView.MODE.ACCOUNT_EMAIL + remember_me: @persist() + } + @_authentication_successful() + .catch (error) => + @pending_server_request false + $('#wire-login-password').focus() + if navigator.onLine + if error.label is z.service.BackendClientError::LABEL.PENDING_ACTIVATION + return @_set_hash z.auth.AuthView.MODE.POSTED_PENDING + else if error.label + @_add_error z.string.auth_error_sign_in, [z.auth.AuthView.TYPE.EMAIL, z.auth.AuthView.TYPE.PASSWORD] + else + @_add_error z.string.auth_error_misc + else + @_add_error z.string.auth_error_offline + @_has_errors() + + # Sign in using a phone number. + sign_in_phone: => + return if @pending_server_request() or not @_validate_input z.auth.AuthView.MODE.ACCOUNT_PHONE + + @pending_server_request true + payload = @_create_payload z.auth.AuthView.MODE.ACCOUNT_PHONE + @auth.repository.request_login_code payload + .then (response) => + clearInterval @code_interval_id + if response.expires_in + @code_expiration_timestamp z.util.get_unix_timestamp() + response.expires_in + else if not response.label + @code_expiration_timestamp z.util.get_unix_timestamp() + z.config.LOGIN_CODE_EXPIRATION + @_set_hash z.auth.AuthView.MODE.VERIFY_CODE + @pending_server_request false + event = new z.tracking.event.PhoneVerification 'signIn', 'succeeded', undefined + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes + .catch (error) => + @pending_server_request false + if navigator.onLine + switch error.label + when z.service.BackendClientError::LABEL.PENDING_LOGIN + return _on_code_request_success error + when z.service.BackendClientError::LABEL.PASSWORD_EXISTS + @_add_error z.string.auth_error_misc + when z.service.BackendClientError::LABEL.INVALID_PHONE + @_add_error z.string.auth_error_phone_number_unknown, z.auth.AuthView.TYPE.PHONE + else + @_add_error z.string.auth_error_misc + else + @_add_error z.string.auth_error_offline + @_has_errors() + event = new z.tracking.event.PhoneVerification 'signIn', 'error', error?.label + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes + + # Verify the security code on phone number login. + verify_code: => + return if @pending_server_request() or not @_validate_code() + + @pending_server_request true + payload = @_create_payload z.auth.AuthView.MODE.VERIFY_CODE + @auth.repository.login payload, @persist() + .then => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.LOGGED_IN, + { + context: z.auth.AuthView.MODE.ACCOUNT_EMAIL + remember_me: @persist() + } + event = new z.tracking.event.PhoneVerification 'postLogin', 'succeeded', undefined + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes + @_authentication_successful() + .catch (error) => + if @validation_errors().length is 0 + @_add_error z.string.auth_error_code, z.auth.AuthView.TYPE.CODE + @_has_errors() + @pending_server_request false + event = new z.tracking.event.PhoneVerification 'postLogin', 'error', error.label + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes + + # Add an email on phone number login. + verify_add_email: => + return if @pending_server_request() or not @_validate_input z.auth.AuthView.MODE.VERIFY_ADD_EMAIL + + @pending_server_request true + @user_service.change_own_password @password() + .catch (error) => + @logger.log @logger.levels.WARN, 'Could not change user password', error + return error + .then (error) => + if not error? or error.code is z.service.BackendClientError::STATUS_CODE.FORBIDDEN + @user_service.change_own_email @email() + else + @pending_server_request false + return @_has_errors() + .then => + @pending_server_request false + @_wait_for_update() + @_set_hash z.auth.AuthView.MODE.POSTED_VERIFY + .catch (error) => + if error + switch error.label + when z.service.BackendClientError::LABEL.BLACKLISTED_EMAIL + @_add_error z.string.auth_error_email_forbidden, z.auth.AuthView.TYPE.EMAIL + when z.service.BackendClientError::LABEL.KEY_EXISTS + @_add_error z.string.auth_error_email_exists, z.auth.AuthView.TYPE.EMAIL + when z.service.BackendClientError::LABEL.INVALID_EMAIL + @_add_error z.string.auth_error_email_malformed, z.auth.AuthView.TYPE.EMAIL + else + @_add_error z.string.auth_error_email_malformed, z.auth.AuthView.TYPE.EMAIL + @_has_errors() + + ### + Create the backend call payload. + + @param mode [z.auth.AuthView.MODE] View state of the authentication page + @private + ### + _create_payload: (mode) => + email = @email().trim().toLowerCase() + + switch mode + when z.auth.AuthView.MODE.ACCOUNT_REGISTER + payload = + email: email + invitation_code: z.util.get_url_parameter z.auth.URLParameter.INVITE + label: @client_repository.construct_cookie_label email, @client_type() + label_key: @client_repository.construct_cookie_label_key email, @client_type() + locale: moment.locale() + name: @name().trim() + password: @password() + return payload + when z.auth.AuthView.MODE.ACCOUNT_EMAIL + payload = + email: email + label: @client_repository.construct_cookie_label email, @client_type() + label_key: @client_repository.construct_cookie_label_key email, @client_type() + password: @password() + return payload + when z.auth.AuthView.MODE.ACCOUNT_PHONE then return {} = + force: false + phone: @phone_number_e164() + when z.auth.AuthView.MODE.POSTED_RESEND then return {} = + email: email + when z.auth.AuthView.MODE.VERIFY_CODE + payload = + code: @code() + label: @client_repository.construct_cookie_label @phone_number_e164(), @client_type() + label_key: @client_repository.construct_cookie_label_key @phone_number_e164(), @client_type() + phone: @phone_number_e164() + return payload + + ############################################################################### + # Events + ############################################################################### + + changed_country: (view_model, event) => + @clear_error z.auth.AuthView.TYPE.PHONE + @country_code "+#{z.util.CountryCodes.get_country_code event?.currentTarget.value or @country()}" + $('#wire-login-phone').focus() + + changed_country_code: (view_model, event) => + country_code = (event?.currentTarget.value or @country_code()).match(/\d+/g)?.join('').substr 0, 4 + + if country_code + @country_code "+#{country_code}" + country_iso = z.util.CountryCodes.get_country_by_code(country_code) or 'X1' + else + @country_code '' + country_iso = 'X0' + + @country country_iso + $('#wire-login-phone').focus() + + changed_phone_number: => + input_value = @phone_number() + @phone_number (@phone_number().match(/\d+/g))?.join('') or '' + if @phone_number().length is 0 and input_value.length > 0 + @_add_error z.string.auth_error_phone_number_invalid, z.auth.AuthView.TYPE.PHONE + + clear_error: (mode, event) => @_remove_error event?.currentTarget.classList[1] or mode + + clear_error_password: (view_model, event) -> + return if event.keyCode is z.util.KEYCODE.ENTER + @failed_validation_password false + if event.currentTarget.value.length is 0 or event.currentTarget.value.length >= 8 + @_remove_error event.currentTarget.classList[1] + + clicked_on_change_email: => @_set_hash z.auth.AuthView.MODE.ACCOUNT_REGISTER + + clicked_on_change_phone: => @_set_hash z.auth.AuthView.MODE.ACCOUNT_PHONE + + clicked_on_login: => + @_set_hash z.auth.AuthView.MODE.ACCOUNT_LOGIN + $('#wire-login-phone').focus_field() if @visible_method() is z.auth.AuthView.MODE.ACCOUNT_PHONE + + clicked_on_login_email: => @_set_hash z.auth.AuthView.MODE.ACCOUNT_EMAIL + + clicked_on_login_phone: => @_set_hash z.auth.AuthView.MODE.ACCOUNT_PHONE + + clicked_on_password: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.PASSWORD_RESET, value: 'fromSignIn' + (window.open z.localization.Localizer.get_text z.string.url_password_reset)?.focus() + + clicked_on_register: => @_set_hash z.auth.AuthView.MODE.ACCOUNT_REGISTER + + clicked_on_resend_code: => + return if not @can_resend_code() + @sign_in_phone() + + clicked_on_resend_registration: => + return if not @can_resend_registration() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.RESENT_EMAIL_VERIFICATION + @_fade_in_icon_spinner() + + if not @pending_server_request() + @pending_server_request true + payload = @_create_payload z.auth.AuthView.MODE.POSTED_RESEND + @auth.repository.resend_activation payload + .then (response) => @_on_resend_success response + .catch (error) => @_on_resend_error error + + clicked_on_resend_verification: => + return if not @can_resend_verification + @_fade_in_icon_spinner() + + if not @pending_server_request() + @pending_server_request true + @user_service.change_own_email @email() + .then (response) => @_on_resend_success response + .catch => + @pending_server_request false + $('.icon-spinner').fadeOut() + setTimeout => + $('.icon-error').fadeIn() + @disabled_by_animation false + , TIMEOUT.SHORT + + clicked_on_retry_registration: => + return if not @can_register() + @_fade_in_icon_spinner() + + if not @pending_server_request() + @pending_server_request true + payload = @_create_payload z.auth.AuthView.MODE.ACCOUNT_REGISTER + @auth.repository.register payload + .then (response) => @_on_resend_success response + .catch (error) => @_on_resend_error error + + clicked_on_terms: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.NAVIGATION.OPENED_TERMS + (window.open z.localization.Localizer.get_text z.string.url_terms_of_use)?.focus() + + clicked_on_verify_later: => @_authentication_successful() + + clicked_on_wire_link: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.NAVIGATION.OPENED_WIRE_WEBSITE + (window.open z.localization.Localizer.get_text z.string.url_wire)?.focus() + + keydown_phone_code: (view_model, event) => + combo_key = if z.util.Environment.os.win then event.ctrlKey else event.metaKey + return true if combo_key and event.keyCode is z.util.KEYCODE.V + return false if event.altKey or event.ctrlKey or event.metaKey or event.shiftKey + + target_id = event.currentTarget.id + target_digit = window.parseInt(target_id.substr target_id.length - 1) + + switch event.keyCode + when z.util.KEYCODE.ARROW_LEFT, z.util.KEYCODE.ARROW_UP + focus_digit = target_digit - 1 + $("#wire-phone-code-digit-#{Math.max 1, focus_digit}").focus() + when z.util.KEYCODE.ARROW_DOWN, z.util.KEYCODE.ARROW_RIGHT + focus_digit = target_digit + 1 + $("#wire-phone-code-digit-#{Math.min 6, focus_digit}").focus() + when z.util.KEYCODE.BACKSPACE, z.util.KEYCODE.DELETE + if event.currentTarget.value is '' + focus_digit = target_digit - 1 + $("#wire-phone-code-digit-#{Math.max 1, focus_digit}").focus() + return true + else + char = String.fromCharCode(event.keyCode).match(/\d+/g) or String.fromCharCode(event.keyCode - 48).match /\d+/g + if char + @code_digits()[target_digit - 1] char + focus_digit = target_digit + 1 + $("#wire-phone-code-digit-#{Math.min 6, focus_digit}").focus() + + input_phone_code: (view_model, event) => + target_id = event.currentTarget.id + target_digit = window.parseInt(target_id.substr target_id.length - 1) + array_digit = target_digit - 1 + target_value = event.currentTarget.value + input_value = target_value.match(/\d+/g)?.join '' + + if input_value + focus_digit = target_digit + input_value.length + $("#wire-phone-code-digit-#{Math.min 6, focus_digit}").focus() + digits = input_value.substr(0, 6 - array_digit).split '' + target_value = digits[0] + @code_digits()[array_digit + i] digit for digit, i in digits + else + @code_digits()[array_digit] null + + clicked_on_manage_devices: => + @device_modal ?= new zeta.webapp.module.Modal '#modal-limit' + if @device_modal.is_hidden() + @client_repository.get_clients_for_self false + @device_modal.toggle() + + close_model_manage_devices: => @device_modal.toggle() + + click_on_remove_device_submit: (password, device) => + @client_repository.delete_client device.id, password + .then => + return @_register_client() + .then => + @device_modal.toggle() + .catch (error) => + @remove_form_error true + @logger.log @logger.levels.ERROR, "Unable to replace device: #{error?.message}", error + + click_on_history_confirm: => @_redirect_to_app() + + + ############################################################################### + # Callbacks + ############################################################################### + + _on_register_error: (error) => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.ENTERED_CREDENTIALS, + {outcome: 'fail'} + + @pending_server_request false + switch error.label + when z.service.BackendClientError::LABEL.BLACKLISTED_EMAIL, z.service.BackendClientError::LABEL.UNAUTHORIZED + @_add_error z.string.auth_error_email_forbidden, z.auth.AuthView.TYPE.EMAIL + when z.service.BackendClientError::LABEL.KEY_EXISTS + payload = @_create_payload z.auth.AuthView.MODE.ACCOUNT_EMAIL + @auth.repository.login payload, @persist() + .then => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.LOGGED_IN, + { + context: z.auth.AuthView.MODE.ACCOUNT_REGISTER + remember_me: @persist() + } + @_authentication_successful() + .catch => + @_add_error z.string.auth_error_email_exists, z.auth.AuthView.TYPE.EMAIL + @_has_errors() + return + when z.service.BackendClientError::LABEL.MISSING_IDENTITY + @_add_error z.string.auth_error_email_missing, z.auth.AuthView.TYPE.EMAIL + + if @_has_errors() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.ENTERED_CREDENTIALS, + {outcome: 'fail', reason: error.label} + return + + @get_wire false + if navigator.onLine + @_set_hash z.auth.AuthView.MODE.POSTED_RETRY + else + @_set_hash z.auth.AuthView.MODE.POSTED_OFFLINE + + _on_resend_error: (error) => + @pending_server_request false + $('.icon-spinner').fadeOut() + setTimeout => + $('.icon-error').fadeIn() + @_on_register_error error + @disabled_by_animation false + , TIMEOUT.SHORT + + _on_resend_success: => + @pending_server_request false + $('.icon-spinner').fadeOut() + setTimeout => + $('.icon-check').fadeIn() + @posted_mode z.auth.AuthView.MODE.POSTED_RESEND if @posted_mode() is z.auth.AuthView.MODE.POSTED_RETRY + , TIMEOUT.SHORT + setTimeout => + $('.icon-check').fadeOut() + $('.icon-envelope').fadeIn() + @disabled_by_animation false + , TIMEOUT.LONG + + _wait_for_activate: => + @logger.log 'Opened WebSocket connection to wait for account activation' + @web_socket_service.connect (notification) => + event = notification.payload[0] + @logger.log "»» Event: '#{event.type}'", {event_object: event, event_json: JSON.stringify event} + if event.type is z.event.Backend.USER.ACTIVATE + @_account_verified() + + _wait_for_update: => + @logger.log 'Opened WebSocket connection to wait for user update' + @web_socket_service.connect (notification) => + event = notification.payload[0] + @logger.log "»» Event: '#{event.type}'", {event_object: event, event_json: JSON.stringify event} + if event.type is z.event.Backend.USER.UPDATE and event.user.email + @_account_verified false + + + ############################################################################### + # Views and Navigation + ############################################################################### + + show_account_register: (focus = 'wire-register-name') -> + switch_params = + section: z.auth.AuthView.SECTION.ACCOUNT + mode: z.auth.AuthView.MODE.ACCOUNT_REGISTER + focus: focus + @switch_ui switch_params + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.OPENED_EMAIL_SIGN_UP, + {context: @registration_context} + + show_account_email: -> + switch_params = + section: z.auth.AuthView.SECTION.ACCOUNT + mode: z.auth.AuthView.MODE.ACCOUNT_LOGIN + method: z.auth.AuthView.MODE.ACCOUNT_EMAIL + focus: 'wire-login-email' + @switch_ui switch_params + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.OPENED_LOGIN, + {context: z.auth.AuthView.MODE.ACCOUNT_EMAIL} + + show_account_login: => + switch_params = + section: z.auth.AuthView.SECTION.ACCOUNT + mode: z.auth.AuthView.MODE.ACCOUNT_LOGIN + focus: 'wire-login-email' + @switch_ui switch_params + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.OPENED_LOGIN, + {context: @visible_method()} + + show_account_phone: -> + switch_params = + section: z.auth.AuthView.SECTION.ACCOUNT + mode: z.auth.AuthView.MODE.ACCOUNT_LOGIN + method: z.auth.AuthView.MODE.ACCOUNT_PHONE + focus: 'wire-login-phone' + @switch_ui switch_params + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ACCOUNT.OPENED_LOGIN, + {context: z.auth.AuthView.MODE.ACCOUNT_PHONE} + + show_verify_code: => + if not z.util.is_valid_phone_number @phone_number_e164() + return @_set_hash z.auth.AuthView.MODE.ACCOUNT_PHONE + switch_params = + section: z.auth.AuthView.SECTION.VERIFY + mode: z.auth.AuthView.MODE.VERIFY_CODE + focus: 'wire-phone-code-digit-1' + @switch_ui switch_params + $('#wire-phone-code-digit-1').focus() + + show_verify_mail: -> + switch_params = + section: z.auth.AuthView.SECTION.VERIFY + mode: z.auth.AuthView.TYPE.EMAIL + focus: 'wire-mail-email' + @switch_ui switch_params + + show_posted_offline: -> + @_show_icon_error() + switch_params = + section: z.auth.AuthView.SECTION.POSTED + mode: z.auth.AuthView.MODE.POSTED_OFFLINE + @switch_ui switch_params + + show_posted_pending: -> + @_show_icon_envelope() + switch_params = + section: z.auth.AuthView.SECTION.POSTED + mode: z.auth.AuthView.MODE.POSTED_PENDING + @switch_ui switch_params + + show_posted_resend: -> + @_show_icon_envelope() + switch_params = + section: z.auth.AuthView.SECTION.POSTED + mode: z.auth.AuthView.MODE.POSTED_RESEND + @switch_ui switch_params + + show_posted_retry: -> + @_show_icon_error() + switch_params = + section: z.auth.AuthView.SECTION.POSTED + mode: z.auth.AuthView.MODE.POSTED_RETRY + @switch_ui switch_params + + show_posted_verify: -> + @_show_icon_envelope() + switch_params = + section: z.auth.AuthView.SECTION.POSTED + mode: z.auth.AuthView.MODE.POSTED_VERIFY + @switch_ui switch_params + + show_limit: -> + switch_params = + section: z.auth.AuthView.SECTION.LIMIT + mode: z.auth.AuthView.MODE.LIMIT + @switch_ui switch_params + + show_history: -> + switch_params = + section: z.auth.AuthView.SECTION.HISTORY + mode: z.auth.AuthView.MODE.HISTORY + @switch_ui switch_params + + + ############################################################################### + # Animations + ############################################################################### + + switch_ui: (switch_params) => + if @show_initial_animation + direction = z.auth.AuthView.ANIMATION_DIRECTION.VERTICAL_TOP + else if @visible_section() is z.auth.AuthView.SECTION.ACCOUNT + if switch_params.section isnt z.auth.AuthView.SECTION.ACCOUNT + direction = z.auth.AuthView.ANIMATION_DIRECTION.HORIZONTAL_LEFT + else if @visible_section() is z.auth.AuthView.SECTION.POSTED + if switch_params.section is z.auth.AuthView.SECTION.ACCOUNT + direction = z.auth.AuthView.ANIMATION_DIRECTION.HORIZONTAL_RIGHT + else if @visible_section() is z.auth.AuthView.SECTION.VERIFY + if switch_params.section is z.auth.AuthView.SECTION.ACCOUNT + direction = z.auth.AuthView.ANIMATION_DIRECTION.HORIZONTAL_RIGHT + else if switch_params.section is z.auth.AuthView.SECTION.POSTED + direction = z.auth.AuthView.ANIMATION_DIRECTION.HORIZONTAL_LEFT + else if @visible_mode() is z.auth.AuthView.MODE.VERIFY_CODE + if switch_params.mode is z.auth.AuthView.TYPE.EMAIL + direction = z.auth.AuthView.ANIMATION_DIRECTION.HORIZONTAL_LEFT + + if switch_params.section is z.auth.AuthView.SECTION.ACCOUNT + @account_mode switch_params.mode + else if switch_params.section is z.auth.AuthView.SECTION.POSTED + @posted_mode switch_params.mode + + @_clear_animations z.auth.AuthView.TYPE.SECTION + if switch_params.section isnt @visible_section() + animation_params = + type: z.auth.AuthView.TYPE.SECTION + section: switch_params.section + direction: direction + @_shift_ui animation_params + + @_clear_animations z.auth.AuthView.TYPE.FORM + if switch_params.mode isnt @visible_mode() + animation_params = + type: z.auth.AuthView.TYPE.FORM + section: switch_params.section + selector: switch_params.mode + direction: direction + @_shift_ui animation_params + + if not switch_params.method and not @visible_method() + @_show_method z.auth.AuthView.MODE.ACCOUNT_EMAIL + @visible_method z.auth.AuthView.MODE.ACCOUNT_EMAIL + else if switch_params.method and @visible_method() isnt switch_params.method + @_show_method switch_params.method + @visible_method switch_params.method + + $("##{switch_params.focus}").focus_field() if switch_params.focus + + _show_method: (method) -> + @_clear_errors() + $('.selector-method').find('.button').removeClass 'is-active' + $(".btn-login-#{method}").addClass 'is-active' + $('.method:visible').hide() + .css opacity: 0 + $("#login-method-#{method}").show() + .css opacity: 1 + + _shift_ui: (animation_params) => + direction = animation_params.direction + old_component = $(".#{animation_params.type}:visible") + new_component = $("##{animation_params.type}-#{animation_params.section}") + if animation_params.selector + new_component = $("##{animation_params.type}-#{animation_params.section}-#{animation_params.selector}") + new_component.show() + + _change_visible = => + switch animation_params.type + when z.auth.AuthView.TYPE.FORM then @visible_mode animation_params.selector + when z.auth.AuthView.TYPE.SECTION then @visible_section animation_params.section + + if not animation_params.direction + old_component.css + display: '' + opacity: '' + new_component.css opacity: 1 + _change_visible() + else if old_component.length is 0 + @disabled_by_animation true + + requestAnimFrame => + new_anim = $.Deferred() + + new_component + .addClass "incoming-#{animation_params.direction}" + .one z.util.alias.animationend, -> + new_anim.resolve() + $(@).css opacity: 1 + + $.when(new_anim).then => + _change_visible() + @disabled_by_animation false + @show_initial_animation = false + else + @disabled_by_animation true + + requestAnimFrame => + old_anim = $.Deferred() + new_anim = $.Deferred() + + $(old_component[0]) + .addClass "outgoing-#{animation_params.direction}" + .one z.util.alias.animationend, -> + old_anim.resolve() + $(@).css + display: '' + opacity: '' + new_component + .addClass "incoming-#{animation_params.direction}" + .one z.util.alias.animationend, -> + new_anim.resolve() + $(@).css opacity: 1 + + $.when(old_anim, new_anim).then => + _change_visible() + @disabled_by_animation false + + _clear_animations: (type = z.auth.AuthView.TYPE.FORM) -> + $(".#{type}") + .off z.util.alias.animationend + .removeClass (index, css) -> (css.match(/\boutgoing-\S+/g) or []).join ' ' + .removeClass (index, css) -> (css.match(/\bincoming-\S+/g) or []).join ' ' + + _fade_in_icon_spinner: => + @disabled_by_animation true + $('.icon-envelope').fadeOut() + $('.icon-error').fadeOut() + $('.icon-spinner').fadeIn() + + _show_icon_envelope: -> + $('.icon-error').hide() + $('.icon-envelope').show() + + _show_icon_error: -> + $('.icon-envelope').hide() + $('.icon-error').show() + + + ############################################################################### + # URL changes + ############################################################################### + + ### + Set location hash + @private + @param hash [String] Hash value + ### + _set_hash: (hash = '') -> window.location.hash = hash + + ### + Get location hash + @private + @return [String] Hash value + ### + _get_hash: -> return window.location.hash.substr 1 + + ### + No hash value + @private + @return [Boolean] No location hash value + ### + _has_no_hash: -> return window.location.hash.length is 0 + + ### + Navigation on hash change + @private + ### + _on_hash_change: => + @_clear_errors() + switch @_get_hash() + when z.auth.AuthView.MODE.ACCOUNT_EMAIL then @show_account_email() + when z.auth.AuthView.MODE.ACCOUNT_LOGIN then @show_account_login() + when z.auth.AuthView.MODE.VERIFY_CODE then @show_verify_code() + when z.auth.AuthView.MODE.VERIFY_ADD_EMAIL then @show_verify_mail() + when z.auth.AuthView.MODE.POSTED then @show_posted_resend() + when z.auth.AuthView.MODE.POSTED_OFFLINE then @show_posted_offline() + when z.auth.AuthView.MODE.POSTED_PENDING then @show_posted_pending() + when z.auth.AuthView.MODE.POSTED_RETRY then @show_posted_retry() + when z.auth.AuthView.MODE.POSTED_VERIFY then @show_posted_verify() + when z.auth.AuthView.MODE.LIMIT then @show_limit() + when z.auth.AuthView.MODE.HISTORY then @show_history() + else @show_account_register() + + + ############################################################################### + # Validation errors + ############################################################################### + + ### + Add a validation error. + + @private + @param string_identifier [String] Identifier of error message + @param types [Array | String] Input type(s) of validation error + ### + _add_error: (string_identifier, types) -> + error = new z.auth.ValidationError types or [], string_identifier + @validation_errors.push error + for type in error.types + switch type + when z.auth.AuthView.TYPE.CODE then @failed_validation_code true + when z.auth.AuthView.TYPE.EMAIL then @failed_validation_email true + when z.auth.AuthView.TYPE.NAME then @failed_validation_name true + when z.auth.AuthView.TYPE.PASSWORD then @failed_validation_password true + when z.auth.AuthView.TYPE.PHONE then @failed_validation_phone true + when z.auth.AuthView.TYPE.TERMS then @failed_validation_terms true + + ### + Removes all validation errors. + @private + ### + _clear_errors: -> + @failed_validation_code false + @failed_validation_email false + @failed_validation_name false + @failed_validation_password false + @failed_validation_phone false + @failed_validation_terms false + @validation_errors [] + + ### + Get the validation error by inout type. + @param type [z.auth.AuthView.TYPE] Input type to get error for + @return [z.auth.ValidationError] Validation Error + ### + _get_error_by_type: (type) => + return ko.utils.arrayFirst @validation_errors(), (error) -> + type in error.types + + ### + Check whether a form has errors and play the alert sound. + @private + @return [Boolean] Does the form have an error + ### + _has_errors: -> + has_error = false + if @validation_errors().length > 0 + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + has_error = true + return has_error + + ### + Remove a validation error. + @private + @param type [String] Input type of validation error + ### + _remove_error: (type) -> + @validation_errors.remove @_get_error_by_type type + switch type + when z.auth.AuthView.TYPE.CODE then @failed_validation_code false + when z.auth.AuthView.TYPE.EMAIL then @failed_validation_email false + when z.auth.AuthView.TYPE.NAME then @failed_validation_name false + when z.auth.AuthView.TYPE.PASSWORD then @failed_validation_password false + when z.auth.AuthView.TYPE.PHONE then @failed_validation_phone false + when z.auth.AuthView.TYPE.TERMS then @failed_validation_terms false + + ### + Validate code input. + @private + @return [Boolean] Phone code is long enough + ### + _validate_code: -> + return @code().length >= 6 + + ### + Validate email input. + @private + ### + _validate_email: -> + if @email().length is 0 + @_add_error z.string.auth_error_email_missing, z.auth.AuthView.TYPE.EMAIL + else if not z.util.is_valid_email @email() + @_add_error z.string.auth_error_email_malformed, z.auth.AuthView.TYPE.EMAIL + + ### + Validate the user input. + + @private + @param mode [z.auth.AuthView.MODE] View state of the authentication page + @return [Boolean] Does the user input have validation errors + ### + _validate_input: (mode) -> + @_clear_errors() + + if mode is z.auth.AuthView.MODE.ACCOUNT_REGISTER + @_validate_name() + + email_and_password_modes = [ + z.auth.AuthView.MODE.ACCOUNT_EMAIL + z.auth.AuthView.MODE.ACCOUNT_REGISTER + z.auth.AuthView.MODE.VERIFY_ADD_EMAIL + ] + if mode in email_and_password_modes + @_validate_email() + @_validate_password mode + + if mode is z.auth.AuthView.MODE.ACCOUNT_PHONE + @_validate_phone() + + if mode is z.auth.AuthView.MODE.ACCOUNT_REGISTER + @_validate_terms_of_use() + + return not @_has_errors() + + ### + Validate name input. + @private + ### + _validate_name: -> + if @name().length < z.config.MINIMUM_USERNAME_LENGTH + @_add_error z.string.auth_error_name_short, z.auth.AuthView.TYPE.NAME + + ### + Validate password input. + @private + @param mode [z.auth.AuthView.MODE] View state of the authentication page + ### + _validate_password: (mode) -> + if @password().length < z.config.MINIMUM_PASSWORD_LENGTH + if mode is z.auth.AuthView.MODE.ACCOUNT_EMAIL + return @_add_error z.string.auth_error_password_wrong, z.auth.AuthView.TYPE.PASSWORD + @_add_error z.string.auth_error_password_short, z.auth.AuthView.TYPE.PASSWORD + + ### + Validate phone input. + @private + ### + _validate_phone: -> + if not z.util.is_valid_phone_number(@phone_number_e164()) and z.util.Environment.backend.current is 'production' + @_add_error z.string.auth_error_phone_number_invalid, z.auth.AuthView.TYPE.PHONE + + ### + Validate terms of use. + @private + ### + _validate_terms_of_use: -> + if not @accepted_terms_of_use() + @_add_error z.string.auth_error_terms_of_use, z.auth.AuthView.TYPE.TERMS + + + ############################################################################### + # Misc + ############################################################################### + + ### + Logout the user again. + @todo: What do we actually need to delete here + ### + logout: => + @auth.repository.logout() + .then => + @auth.repository.delete_access_token() + window.location.replace '/auth' + + ### + User account has been verified. + @private + @param registration [Boolean] Verification from registration + ### + _account_verified: (registration = true) => + @logger.log @logger.levels.INFO, 'User account verified. User can now login.' + if registration + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.REGISTRATION.SUCCEEDED, + {content: @registration_context} + @_authentication_successful() + + ### + User successfully authenticated on the backend side + @note Gets the client and forwards the user to the login. + @private + ### + _authentication_successful: => + @logger.log @logger.levels.INFO, 'Logging in' + @_get_self_user() + .then => + return @client_repository.get_valid_local_client() + .catch (error) => + @logger.log @logger.levels.INFO, "No valid local client found: #{error.message}", error + if error.type is z.client.ClientError::TYPE.MISSING_ON_BACKEND + @logger.log @logger.levels.INFO, 'Local client rejected as invalid by backend. Reinitializing storage.' + @storage_service.init @self_user().id + .then => + return @storage_repository.init true + .then => + return @cryptography_repository.init() + .then => + if @client_repository.current_client() + @logger.log @logger.levels.INFO, 'Active client found. Redirecting to app...' + @_redirect_to_app() + else + @logger.log @logger.levels.INFO, 'No active client found. We need to register one...' + @_register_client() + .catch (error) => + @logger.log @logger.levels.ERROR, "Login failed: #{error?.message}", error + @_add_error z.string.auth_error_misc + @_has_errors() + @_set_hash z.auth.AuthView.MODE.ACCOUNT_LOGIN + + ### + Get and store the self user. + @private + @return [Promise] Self user + ### + _get_self_user: -> + return new Promise (resolve, reject) => + @user_repository.get_me() + .then (user_et) => + @self_user user_et + @logger.log @logger.levels.INFO, "Got self user: #{@self_user().id}" + @pending_server_request false + + if @self_user().email()? + @storage_service.init @self_user().id + .then => + @client_repository.init @self_user() + resolve @self_user() + .catch (error) -> reject error + else + @_set_hash z.auth.AuthView.MODE.VERIFY_ADD_EMAIL + + ### + Redirects to the app after successful login + @private + ### + _redirect_to_app: => + url = '/' + url = "/#{@auth.settings.parameter}" if @auth.settings.parameter? + connect_token = z.util.get_url_parameter z.auth.URLParameter.CONNECT + url = z.util.append_url_parameter url, "#{z.auth.URLParameter.CONNECT}=#{connect_token}" if connect_token + window.location.replace url + + _register_client: => + @client_repository.register_client @password() + .then (client_observable) => + @event_repository.current_client = client_observable + @event_repository.get_last_notification_id() + .then (last_notification_id) => + @event_repository.last_notification_id last_notification_id + @logger.log @logger.levels.INFO, "Set starting point on notification stream to '#{last_notification_id}'" + .catch (error) => + if error.code is z.service.BackendClientError::STATUS_CODE.NOT_FOUND + @logger.log @logger.levels.WARN, + "Cannot set starting point on notification stream: #{error.message}", error + else + throw error + .then => + return @client_repository.get_clients_for_self() + .then (client_ets) => + @logger.log @logger.levels.INFO, "User has '#{client_ets?.length}' registered clients", client_ets + + # Show history screen if there are already registered clients + if client_ets?.length > 0 + @_set_hash z.auth.AuthView.MODE.HISTORY + # Make sure client entities always see the history screen + else if @client_repository.current_client().is_temporary() + @_set_hash z.auth.AuthView.MODE.HISTORY + # Don't show history screen if the webapp is the first client that has been registered + else + @_redirect_to_app() + .catch (error) => + if error.type is z.client.ClientError::TYPE.TOO_MANY_CLIENTS + @logger.log @logger.levels.WARN, 'User has already registered the maximum number of clients', error + window.location.hash = z.auth.AuthView.MODE.LIMIT + else + @logger.log @logger.levels.ERROR, "Failed to register a new client: #{error.message}", error + + ### + Track app launch for Localytics + @private + ### + _track_app_launch: -> + mechanism = 'direct' + if document.referrer.startsWith 'https://wire.com/verify/' + mechanism = 'email_verify' + else if document.referrer.startsWith 'https://wire.com/forgot/' + mechanism = 'password_reset' + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.APP_LAUNCH, mechanism: mechanism + +$ -> + if $('.auth-page').length isnt 0 + wire.auth.view = new z.ViewModel.AuthViewModel 'auth-page', wire.auth + +# jQuery helpers +$.fn.extend + focus_field: -> + @each -> + # Timeout needed (for Chrome): http://stackoverflow.com/a/17384592/451634 + setTimeout => + $(@).focus() + , 0 + + # FIX to prevent unwanted auto form fill on Chrome + prevent_prefill: -> + if z.util.Environment.browser.chrome or z.util.Environment.browser.opera + @each -> + $(@) + .attr 'readonly', true + .on 'focus', -> + $(@).removeAttr 'readonly' diff --git a/app/script/view_model/BackgroundViewModel.coffee b/app/script/view_model/BackgroundViewModel.coffee new file mode 100644 index 00000000000..c64332d185c --- /dev/null +++ b/app/script/view_model/BackgroundViewModel.coffee @@ -0,0 +1,63 @@ +# +# 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.ViewModel ?= {} + + +class z.ViewModel.BackgroundViewModel + constructor: (element_id, @content, @conversation_repository, @user_repository) -> + + @webapp_loaded = ko.observable false + + @self_user = ko.computed => + @user_repository.self()?.picture_medium_url() if @webapp_loaded() + + @background_list_element = $("##{element_id}") + @background_list_element.on z.util.alias.animationend, -> + if not $(@).hasClass 'background-fullscreen' + $('.conversation, .connect-requests-wrapper').css 'z-index': '' + + @content.state.subscribe => + if @content.state() is z.ViewModel.CONTENT_STATE.PROFILE + requestAnimFrame => + @background_list_element.addClass 'background-fullscreen' + requestAnimFrame => + if @background_list_element.hasClass 'background-fullscreen-anim-disabled' + @background_list_element.removeClass 'background-fullscreen-anim-disabled' + $('.conversation, .connect-requests-wrapper').css 'z-index': '' + else + if @background_list_element.hasClass 'background-fullscreen' + @background_list_element.removeClass 'background-fullscreen' + $('.conversation, .connect-requests-wrapper').css 'z-index': '-1' + + @blur_background = _.throttle (ratio) => + blur_radius = (ratio * 24) | 0 + blur_css = "blur(#{blur_radius}px)" + requestAnimFrame => + @background_list_element.find('.background').css + '-webkit-filter': blur_css + 'filter': blur_css + , 50 + + amplify.subscribe z.event.WebApp.LOADED, => @webapp_loaded true + amplify.subscribe z.event.WebApp.LIST.BLUR, @blur_background + amplify.subscribe z.event.WebApp.LIST.FULLSCREEN_ANIM_DISABLED, => + @background_list_element.addClass 'background-fullscreen-anim-disabled' + + ko.applyBindings @, document.getElementById element_id diff --git a/app/script/view_model/CallShortcutsViewModel.coffee b/app/script/view_model/CallShortcutsViewModel.coffee new file mode 100644 index 00000000000..483e074490f --- /dev/null +++ b/app/script/view_model/CallShortcutsViewModel.coffee @@ -0,0 +1,70 @@ +# +# 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.ViewModel ?= {} + +### +Last remainder of the CallBannerViewModel. +@todo Move functionality elsewhere and remove +### +class z.ViewModel.CallShortcutsViewModel + constructor: (@call_center) -> + @logger = new z.util.Logger 'z.ViewModel.CallShortcutsViewModel', z.config.LOGGER.OPTIONS + + @joined_call = @call_center.joined_call + + @joined_call.subscribe (call_et) => + @_update_shortcut_subscription call_et + + ########################### + # Shortcuts + ########################### + + _update_shortcut_subscription: (call_et) => + @_unsubscribe_shortcuts() + return if not call_et + + switch call_et.state() + when z.calling.enum.CallState.ONGOING, z.calling.enum.CallState.OUTGOING + @_subscribe_shortcuts_outgoing_ongoing() + when z.calling.enum.CallState.INCOMING + @_subscribe_shortcuts_incoming() + + conversation_name = call_et.conversation_et.display_name() + @logger.log @logger.levels.DEBUG, "Updated call shortcuts for '#{call_et.state()}' call in conversation '#{call_et.id}' (#{conversation_name})" + + _subscribe_shortcuts_incoming: => + amplify.subscribe z.event.WebApp.SHORTCUT.CALL_IGNORE, @on_ignore_call + + _subscribe_shortcuts_outgoing_ongoing: => + amplify.subscribe z.event.WebApp.SHORTCUT.CALL_MUTE, @on_mute_call + + _unsubscribe_shortcuts: => + amplify.unsubscribe z.event.WebApp.SHORTCUT.CALL_MUTE, @on_mute_call + amplify.unsubscribe z.event.WebApp.SHORTCUT.CALL_IGNORE, @on_ignore_call + + ########################### + # Component actions + ########################### + + on_ignore_call: => + @call_center.state_handler.ignore_call @joined_call()?.id + + on_mute_call: => + @call_center.state_handler.toggle_audio @joined_call()?.id diff --git a/app/script/view_model/ConnectRequestsViewModel.coffee b/app/script/view_model/ConnectRequestsViewModel.coffee new file mode 100644 index 00000000000..e860402ec38 --- /dev/null +++ b/app/script/view_model/ConnectRequestsViewModel.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.ViewModel ?= {} + +# View model for connection requests. +class z.ViewModel.ConnectRequestsViewModel + constructor: (element_id, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ConnectRequestsViewModel', z.config.LOGGER.OPTIONS + + @connect_requests = @user_repository.connect_requests + + ### + Click on accept. + @param user_et [z.entity.User] User to accept connection request from + @return [Promise] Promise that resolves when the connection request was accepted + ### + click_on_accept: (user_et) => + @user_repository.accept_connection_request user_et, @connect_requests().length is 1 + + ### + Click on ignore. + @param user_et [z.entity.User] User to ignore connection request from + @return [Promise] Promise that resolves when the connection request was ignored + ### + click_on_ignore: (user_et) => + @user_repository.ignore_connection_request user_et + + ### + Called after each connection request is rendered. + @param elements [Object] rendered objects + @param request [z.entity.User] Rendered connection request + ### + after_render: (elements, request) => + if z.util.array_is_last @connect_requests(), request + requestAnimFrame -> $('.connect-requests').scroll_to_bottom() diff --git a/app/script/view_model/ConversationInputViewModel.coffee b/app/script/view_model/ConversationInputViewModel.coffee new file mode 100644 index 00000000000..749fec74f68 --- /dev/null +++ b/app/script/view_model/ConversationInputViewModel.coffee @@ -0,0 +1,170 @@ +# +# 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.ViewModel ?= {} + +# Parent: z.ViewModel.RightViewModel +class z.ViewModel.ConversationInputViewModel + constructor: (element_id, @conversation_repository, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ConversationInputViewModel', z.config.LOGGER.OPTIONS + + @conversation_et = @conversation_repository.active_conversation + @conversation_et.subscribe => @conversation_has_focus true + + @self = @user_repository.self + @list_not_bottom = ko.observable true + + @conversation_has_focus = ko.observable(true).extend notify: 'always' + @browser_has_focus = ko.observable true + + @blinking_cursor = ko.computed => + return @browser_has_focus() and @conversation_has_focus() + + @has_text_input = ko.computed => + return @conversation_et()?.input().length > 0 + + @show_giphy_button = ko.computed => + return @has_text_input() and @conversation_et()?.input().length <= 15 + + @ping_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_ping + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.PING} + } + @picture_tooltip = z.localization.Localizer.get_text z.string.tooltip_conversation_picture + @file_tooltip = z.localization.Localizer.get_text z.string.tooltip_conversation_file + + @ping_disabled = ko.observable false + + $(window) + .blur => @browser_has_focus false + .focus => @browser_has_focus true + + @_init_subscriptions() + + _init_subscriptions: -> + amplify.subscribe z.event.WebApp.SEARCH.SHOW, => @conversation_has_focus false + amplify.subscribe z.event.WebApp.SEARCH.HIDE, => window.requestAnimFrame => @conversation_has_focus true + amplify.subscribe z.event.WebApp.EXTENSIONS.GIPHY.SEND, => @conversation_et()?.input '' + amplify.subscribe z.event.WebApp.CONVERSATION.IMAGE.SEND, @upload_images + + added_to_view: => + setTimeout => + amplify.subscribe z.event.WebApp.SHORTCUT.PING, => @ping() + , 50 + + removed_from_view: -> + amplify.unsubscribe z.event.WebApp.SHORTCUT.PING + + ping: => + return if @ping_disabled() + + @ping_disabled true + @conversation_repository.send_encrypted_knock @conversation_et() + .then => + window.setTimeout => + @ping_disabled false + , 2000 + + toggle_extensions_menu: -> + amplify.publish z.event.WebApp.EXTENSIONS.GIPHY.SHOW + + send_message: (data, event) => + message = z.util.trim_line_breaks @conversation_et().input() + if message.length is 0 + return + + if message.length > z.config.MAXIMUM_MESSAGE_LENGTH + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.TOO_LONG_MESSAGE, + data: z.config.MAXIMUM_MESSAGE_LENGTH + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.CHARACTER_LIMIT_REACHED, + characters: message.length + return + + link_data = z.links.LinkPreviewHelpers.get_first_link_with_offset message + if link_data? + [url, offset] = link_data + @conversation_repository.send_encrypted_message_with_link_preview message, url, offset, @conversation_et() + else + @conversation_repository.send_encrypted_message message, @conversation_et() + + @conversation_et().input '' + $(event.target).focus() + + upload_images: (images) => + for image in images + return @_show_upload_warning image if image.size > z.config.MAXIMUM_IMAGE_FILE_SIZE + + @conversation_repository.upload_images @conversation_et(), images + + _show_upload_warning: (image) -> + warning = z.localization.Localizer.get_text { + id: if image.type is 'image/gif' then z.string.alert_gif_too_large else z.string.alert_upload_too_large + replace: {placeholder: '%no', content: z.config.MAXIMUM_IMAGE_FILE_SIZE / 1024 / 1024} + } + + attributes = + reason: 'too large' + type: image.type + size: image.size + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.IMAGE_SENT_ERROR, attributes + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + setTimeout -> + window.alert warning + , 200 + + upload_files: (files) => + pending_uploads = @conversation_repository.get_number_of_pending_uploads() + if pending_uploads + files.length > z.config.MAXIMUM_ASSET_UPLOADS + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.UPLOAD_PARALLEL, + data: z.config.MAXIMUM_ASSET_UPLOADS + return + + for file in files + if file.size > z.config.MAXIMUM_ASSET_FILE_SIZE + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.FILE.UPLOAD_TOO_BIG, + {size: file.size, type: file.type} + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + setTimeout -> + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.UPLOAD_TOO_LARGE, + data: z.util.format_bytes(z.config.MAXIMUM_ASSET_FILE_SIZE) + , 200 + return + + @conversation_repository.upload_files @conversation_et(), files + + scroll_message_list: (list_height_new, list_height_old) -> + diff = list_height_new - list_height_old + input_height = $('.conversation-input').height() + is_scrolled_bottom = $('.messages-wrap').is_scrolled_bottom() + + $('.message-list') + .css 'bottom', input_height + .data('antiscroll')?.rebuild() + + if is_scrolled_bottom + $('.messages-wrap').scroll_to_bottom() + else + $('.messages-wrap').scroll_by diff + + show_separator: (is_scrolled_bottom) => + @list_not_bottom not is_scrolled_bottom + + on_input_click: => + if not @has_text_input() + $('.messages-wrap').scroll_to_bottom() diff --git a/app/script/view_model/ConversationListViewModel.coffee b/app/script/view_model/ConversationListViewModel.coffee new file mode 100644 index 00000000000..a4a43c0673c --- /dev/null +++ b/app/script/view_model/ConversationListViewModel.coffee @@ -0,0 +1,202 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.ConversationListViewModel + ### + @param element_id [String] HTML selector + @param content [z.ViewModel.RightViewModel] View model + @param call_center [z.calling.CallCenter] Call center + @param user_repository [z.user.UserRepository] User repository + @param conversation_repository [z.conversation.ConversationRepository] Conversation repository + ### + constructor: (element_id, @content, @call_center, @user_repository, @conversation_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ConversationListViewModel', z.config.LOGGER.OPTIONS + + @selected_list_item = @content.state + @selected_conversation = ko.observable() + @status = + call: ko.computed => + call_et = @call_center.joined_call() + call_status = 'none' + + if call_et?.self_user_joined() + call_status = 'participating-in-group-call' + else + call_status = 'not-participating-in-group-call' + + return call_status + + @user = @user_repository.self + @show_badge = ko.observable false + + @connect_requests = @user_repository.connect_requests + @connect_requests_text = ko.computed => + count = @connect_requests().length + if count > 1 + return z.localization.Localizer.get_text { + id: z.string.conversation_list_many_connection_request + replace: {placeholder: '%no', content: count} + } + else + return z.localization.Localizer.get_text z.string.conversation_list_one_connection_request + + @is_conversation_list_visible = ko.observable true + @is_self_profile_visible = ko.observable false + + @conversations_calls = @conversation_repository.conversations_call + @conversations_archived = @conversation_repository.conversations_archived + @conversations_unarchived = @conversation_repository.conversations_unarchived + + @joined_call = @call_center.joined_call + + @webapp_is_loaded = ko.observable false + + @should_update_scrollbar = (ko.computed => + return @webapp_is_loaded() or + @is_conversation_list_visible() or + @conversations_unarchived().length or + @connect_requests().length or + @conversations_calls().length + ).extend notify: 'always', rateLimit: 500 + + @active_conversation_id = ko.computed => + if @conversation_repository.active_conversation()? + @conversation_repository.active_conversation().id + + @archive_tooltip = ko.computed => + return z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_list_archived + replace: {placeholder: '%no', content: @conversations_archived().length} + } + + @start_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_list_tooltip_start + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.START} + } + + @self_stream_state = @call_center.media_stream_handler.self_stream_state + + @show_toggle_screen = ko.pureComputed -> + return z.calling.CallCenter.supports_screen_sharing() + @disable_toggle_screen = ko.pureComputed => + return @joined_call()?.is_remote_screen_shared() + + @_init_subscriptions() + + ko.applyBindings @, document.getElementById element_id + + click_on_actions: (conversation_et, event) -> + amplify.publish z.event.WebApp.ACTION.SHOW, conversation_et, event + + click_on_connect_requests: -> + return if @selected_list_item() is z.ViewModel.CONTENT_STATE.PENDING + @is_self_profile_visible false + amplify.publish z.event.WebApp.PENDING.SHOW + + click_on_conversation: (conversation_et) => + return if @_is_selected_conversation conversation_et + @is_self_profile_visible false + amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + + _init_subscriptions: => + amplify.subscribe z.event.WebApp.SEARCH.BADGE.SHOW, => @show_badge true + amplify.subscribe z.event.WebApp.SEARCH.BADGE.HIDE, => @show_badge false + amplify.subscribe z.event.WebApp.SHORTCUT.NEXT, @_go_to_next_conversation + amplify.subscribe z.event.WebApp.SHORTCUT.PREV, @_go_to_prev_conversation + amplify.subscribe z.event.WebApp.SHORTCUT.START, @click_on_people_button + amplify.subscribe z.event.WebApp.LOADED, @on_webapp_loaded + amplify.subscribe z.event.WebApp.CONVERSATION_LIST.SHOW, @_show + amplify.subscribe z.event.WebApp.ARCHIVE.CLOSE, @_show + amplify.subscribe z.event.WebApp.ARCHIVE.SHOW, @_hide + amplify.subscribe z.event.WebApp.SEARCH.SHOW, @_hide + + _go_to_next_conversation: => + conversations = @conversation_repository.conversations_unarchived() + index = conversations.indexOf(@conversation_repository.active_conversation()) - 1 + next_conversation_et = conversations[index] + amplify.publish z.event.WebApp.CONVERSATION.SHOW, next_conversation_et if next_conversation_et + + _go_to_prev_conversation: => + conversations = @conversation_repository.conversations_unarchived() + index = conversations.indexOf(@conversation_repository.active_conversation()) + 1 + prev_conversation_et = conversations[index] + amplify.publish z.event.WebApp.CONVERSATION.SHOW, prev_conversation_et if prev_conversation_et + + _is_selected_conversation: (conversation_et) => + @selected_list_item() is z.ViewModel.CONTENT_STATE.CONVERSATION and conversation_et.id is @active_conversation_id() + + on_webapp_loaded: => + @webapp_is_loaded true + + +############################################################################### +# Call stuff +############################################################################### + + on_accept_call: (conversation_et) => + @call_center.state_handler.join_call conversation_et.id, false + + on_accept_video: (conversation_et) => + @call_center.state_handler.join_call conversation_et.id, true + + on_cancel_call: (conversation_et) => + @call_center.state_handler.leave_call conversation_et.id + + on_ignore_call: (conversation_et) => + @call_center.state_handler.ignore_call conversation_et.id + + on_toggle_audio: (conversation_et) => + @call_center.state_handler.toggle_audio conversation_et.id + + on_toggle_screen: (conversation_et) -> + amplify.publish z.event.WebApp.CALL.STATE.TOGGLE_SCREEN, conversation_et.id + + on_toggle_video: (conversation_et) => + @call_center.state_handler.toggle_video conversation_et.id + +############################################################################### +# Footer actions +############################################################################### + click_on_archived_button: -> + amplify.publish z.event.WebApp.ARCHIVE.SHOW + + click_on_settings_button: => + if @is_self_profile_visible() + amplify.publish z.event.WebApp.PROFILE.HIDE + @is_self_profile_visible false + else + amplify.publish z.event.WebApp.PROFILE.SHOW + @is_self_profile_visible true + + click_on_people_button: -> + amplify.publish z.event.WebApp.SEARCH.SHOW + +############################################################################### +# Conversation List animations +############################################################################### + _hide: -> + $('.conversation-list').addClass 'conversation-list-is-hidden' + + _show: => + $('#conversation-list').removeClass('conversation-list-is-hidden') + + @is_conversation_list_visible true + @is_self_profile_visible false diff --git a/app/script/view_model/ConversationTitlebarViewModel.coffee b/app/script/view_model/ConversationTitlebarViewModel.coffee new file mode 100644 index 00000000000..c511424b0dd --- /dev/null +++ b/app/script/view_model/ConversationTitlebarViewModel.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.ViewModel ?= {} + +# Parent: z.ViewModel.ConversationTitlebarViewModel +class z.ViewModel.ConversationTitlebarViewModel + constructor: (element_id, @conversation_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ConversationTitlebarViewModel', z.config.LOGGER.OPTIONS + + # TODO remove this for now to ensure that buttons are clickable in osx wrappers + window.setTimeout -> + $('.titlebar').remove() + , 1000 + + @conversation_et = @conversation_repository.active_conversation + + @show_call_controls = ko.computed => + return false if not @conversation_et() + is_supported_conversation = @conversation_et().is_group() or @conversation_et().is_one2one() + is_active_conversation = @conversation_et().participating_user_ids().length and not @conversation_et().removed_from_conversation() + is_self_client_joined = @conversation_et().call()?.self_client_joined() + return is_supported_conversation and is_active_conversation and not is_self_client_joined + + @people_tooltip = z.localization.Localizer.get_text { + id: z.string.tooltip_conversation_people + replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.PEOPLE} + } + + added_to_view: => + setTimeout => + amplify.subscribe z.event.WebApp.SHORTCUT.PEOPLE, => @show_participants() + amplify.subscribe z.event.WebApp.SHORTCUT.ADD_PEOPLE, => @show_participants true + , 50 + + removed_from_view: -> + amplify.unsubscribe z.event.WebApp.SHORTCUT.PEOPLE + amplify.unsubscribe z.event.WebApp.SHORTCUT.ADD_PEOPLE + + click_on_call_button: => + amplify.publish z.event.WebApp.CALL.STATE.TOGGLE, @conversation_et().id + + click_on_participants: => + @show_participants() + + click_on_video_button: => + if @conversation_et().is_group() + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALL_NO_VIDEO_IN_GROUP + else + amplify.publish z.event.WebApp.CALL.STATE.TOGGLE, @conversation_et().id, true + + show_participants: (add_people) -> + amplify.publish z.event.WebApp.PEOPLE.TOGGLE, add_people diff --git a/app/script/view_model/DebugViewModel.coffee b/app/script/view_model/DebugViewModel.coffee new file mode 100644 index 00000000000..9333192b633 --- /dev/null +++ b/app/script/view_model/DebugViewModel.coffee @@ -0,0 +1,63 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.DebugViewModel + constructor: (element_id, @conversation_repository, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.DebugViewModel', z.config.LOGGER.OPTIONS + @debug_view = $("##{element_id}") + if @debug_view.length is 0 + return + + @title = ko.observable 'Debug info' + @info = ko.observable '' + @conversation = @conversation_repository.active_conversation + @self_user = @user_repository.self + + amplify.subscribe z.event.WebApp.SHORTCUT.DEBUG, -> $('#debug').toggleClass 'hide' + + ko.applyBindings @, @debug_view.get(0) + + inject_audio: (audio_file) -> + audio_file_path = "audio/buzzer/#{audio_file}" + return audio_file_path + + inject_audio_to_participants: -> + wire.app.repository.call_center.count_flows @conversation().id + + download_call_trace: -> + file_name = "#{wire.app.repository.user.self().name()} (#{wire.app.repository.user.self().id}).js" + text = JSON.stringify wire.app.repository.call_center.log_trace() + element = document.createElement 'a' + element.setAttribute 'href', "data:text/plain;charset=utf-8,#{encodeURIComponent(text)}" + element.setAttribute 'download', file_name + element.style.display = 'none' + document.body.appendChild element + element.click() + document.body.removeChild element + + log_call_to_console: -> + wire.app.repository.call_center.log_call() + + log_call_banner_state_to_console: -> + wire.app.view.content.call_controls.log_state() + + log_session_ids_to_console: -> + wire.app.repository.call_center.log_calls() diff --git a/app/script/view_model/GiphyViewModel.coffee b/app/script/view_model/GiphyViewModel.coffee new file mode 100644 index 00000000000..285dc941230 --- /dev/null +++ b/app/script/view_model/GiphyViewModel.coffee @@ -0,0 +1,143 @@ +# +# 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.ViewModel ?= {} + +NUMBER_OF_GIFS = 6 + +STATE = + DEFAULT: '' + ERROR: 'error' + LOADING: 'loading' + RESULTS: 'results' + +class z.ViewModel.GiphyViewModel + constructor: (@element_id, @conversation_repository, @giphy_repository) -> + @logger = new z.util.Logger 'z.ViewModel.GiphyViewModel', z.config.LOGGER.OPTIONS + + @modal = undefined + @state = ko.observable STATE.DEFAULT + @query = ko.observable '' + @show_giphy_button = ko.observable true + @sending_giphy_message = false + + # gif presented in the single gif view + @gif = ko.observable() + + # gifs rendered in the modal + @gifs = ko.observableArray() + + # gif selected by user or single gif when in single gif view + @selected_gif = ko.observable() + + @_init_subscriptions() + + _init_subscriptions: -> + amplify.subscribe z.event.WebApp.EXTENSIONS.GIPHY.SHOW, @show_giphy + + + show_giphy: => + @sending_giphy_message = false + @query @conversation_repository.active_conversation().input() + @state STATE.DEFAULT + @_get_random_gif() + @modal ?= new zeta.webapp.module.Modal '#giphy-modal' + @modal.show() + + + on_back: => + @gifs [@gif()] + @selected_gif @gif() + @show_giphy_button true + + on_try_another: => + @_get_random_gif() + + on_giphy_button: => + @_get_random_gifs() + + on_send: => + if @selected_gif() and not @sending_giphy_message + conversation_et = @conversation_repository.active_conversation() + @sending_giphy_message = true + @conversation_repository.send_gif conversation_et, @selected_gif().animated, @query(), -> + @sending_giphy_message = false + event = new z.tracking.event.PictureTakenEvent 'conversation', 'giphy', 'button' + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes + amplify.publish z.event.WebApp.EXTENSIONS.GIPHY.SEND + @modal.hide() + + on_close: => + @modal.hide() + + on_clicked_gif: (clicked_gif, event) => + return if @gifs().length is 1 + gif_item = $(event.currentTarget) + gif_items = gif_item.parent().children() + + remove_unselected = -> + $(@).removeClass 'gif-container-item-unselected' + + add_unselected = -> + $(@).addClass 'gif-container-item-unselected' + + if @selected_gif() is clicked_gif + gif_items.each remove_unselected + @selected_gif undefined + else + gif_items.each add_unselected + remove_unselected.apply gif_item + @selected_gif clicked_gif + + _clear_gifs: => + @gifs.removeAll() + @selected_gif undefined + @state STATE.LOADING + + _get_random_gif: => + return if @state() is STATE.ERROR + @_clear_gifs() + @show_giphy_button true + + @giphy_repository.get_random_gif + tag: @query() + .then (gif) => + @gif gif + @gifs.push @gif() + @selected_gif @gif() + @state STATE.RESULTS + .catch (error) => + @logger.log @logger.levels.ERROR, "No gif found for query: #{@query()}", error + @state STATE.ERROR + + _get_random_gifs: => + return if @state() is STATE.ERROR + @_clear_gifs() + @show_giphy_button false + + @giphy_repository.get_gifs + query: @query() + number: NUMBER_OF_GIFS + .then (gifs) => + @gifs gifs + @selected_gif(gifs[0]) if gifs.length is 1 + @state STATE.RESULTS + .catch (error) => + @logger.log @logger.levels.ERROR, "No gifs found for query: #{@query()}", error + @state STATE.ERROR diff --git a/app/script/view_model/ImageDetailViewViewModel.coffee b/app/script/view_model/ImageDetailViewViewModel.coffee new file mode 100644 index 00000000000..21d0ea27fc6 --- /dev/null +++ b/app/script/view_model/ImageDetailViewViewModel.coffee @@ -0,0 +1,73 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.ImageDetailViewViewModel + constructor: (@element_id) -> + + @image_element = undefined + @button_element = undefined + @image_modal = undefined + + amplify.subscribe z.event.WebApp.CONVERSATION.DETAIL_VIEW.SHOW, @show_detail_view + + show_detail_view: (src) => + element = $("##{@element_id}") + + @image_element = element.find '.detail-view-image' + @image_element[0].src = src + + @button_element = element.find '.detail-view-close-button' + + @image_modal.destroy() if @image_modal? + @image_modal = new zeta.webapp.module.Modal '#detail-view', @_hide_callback, @_before_hide_callback + @image_modal.show() + + @_show_image() + + hide_detail_view: => + @image_modal.hide() + + _before_hide_callback: => + @image_element.removeClass 'modal-content-anim-open' + + _hide_callback: => + $(window).off 'resize', @_center_image + + _show_image: => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.IMAGE_DETAIL_VIEW_OPENED + setTimeout => + @_center_image() + @image_element + .addClass 'modal-content-anim-open' + .one z.util.alias.animationend, => @_check_close_button() + , 0 + $(window).on 'resize', @_center_image + + _check_close_button: => + rect_image = @image_element[0].getBoundingClientRect() + rect_button = @button_element[0].getBoundingClientRect() + is_overlapping = rect_button.left < rect_image.right and rect_button.bottom > rect_image.top + @button_element.toggleClass 'detail-view-close-button-fullscreen', is_overlapping + + _center_image: => + @image_element.css + 'margin-left': (window.innerWidth - @image_element.width()) / 2 + 'margin-top': (window.innerHeight - @image_element.height()) / 2 diff --git a/app/script/view_model/LoadingViewModel.coffee b/app/script/view_model/LoadingViewModel.coffee new file mode 100644 index 00000000000..f42068a4991 --- /dev/null +++ b/app/script/view_model/LoadingViewModel.coffee @@ -0,0 +1,63 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.LoadingViewModel + constructor: (element_id, @user_repository) -> + @loading_message = ko.observable '' + @loading_step_current = ko.observable 0 + @loading_step_percentage = ko.observable 0 + @loading_step_total = 10 + + amplify.subscribe z.event.WebApp.APP.UPDATE_INIT, @switch_message + + ko.applyBindings @, document.getElementById element_id + + switch_message: (message_locator, next_step = false, replace_content) => + _create_message = (message_locator, replacements) -> + replacements = ({placeholder: replacement[0], content: replacement[1]} for replacement in replacements) + return z.localization.Localizer.get_text { + id: message_locator + replace: replacements + } + + switch message_locator + when z.string.init_received_self_user + message = _create_message message_locator, [['%name', @user_repository.self().first_name()]] + when z.string.init_events_expectation + if replace_content[0] > 200 + message_locator = z.string.init_events_expectation_long + message = _create_message message_locator, [['%events', replace_content[0]]] + when z.string.init_events_progress, z.string.init_sessions_progress + message = _create_message message_locator, [['%progress', replace_content[0]], ['%total', replace_content[1]]] + when z.string.init_sessions_expectation + if replace_content[0] > 100 + message_locator = z.string.init_sessions_expectation_long + message = _create_message message_locator, [['%sessions', replace_content[0]]] + else + message = z.localization.Localizer.get_text message_locator + + if not z.util.Environment.frontend.is_production() + @loading_message message + @_next_step() if next_step + + _next_step: -> + @loading_step_current (@loading_step_current() + 1) % @loading_step_total + @loading_step_percentage "#{@loading_step_current() / @loading_step_total * 100}%" diff --git a/app/script/view_model/MainViewModel.coffee b/app/script/view_model/MainViewModel.coffee new file mode 100644 index 00000000000..c14fbd3cedb --- /dev/null +++ b/app/script/view_model/MainViewModel.coffee @@ -0,0 +1,47 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.MainViewModel + constructor: (element_id, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.MainViewModel', z.config.LOGGER.OPTIONS + + @user = @user_repository.self + + @main_classes = ko.computed => + if @user()? + main_css_classes = "main-accent-color-#{@user().accent_id()}" # deprecated - still used on input control hover + main_css_classes += " #{@user().accent_theme()}" + return main_css_classes + + ko.applyBindings @, document.getElementById element_id + + amplify.subscribe z.event.WebApp.APP.HIDE, @_hide_app + amplify.subscribe z.event.WebApp.APP.FADE_IN, @_fade_in_app + + _hide_app: -> + $('#left, #right').css + opacity: 0 + 'pointer-events': 'none' + + _fade_in_app: -> + $('#left, #right').css + opacity: 1 + 'pointer-events': '' diff --git a/app/script/view_model/MessageListViewModel.coffee b/app/script/view_model/MessageListViewModel.coffee new file mode 100644 index 00000000000..42f2ccac14d --- /dev/null +++ b/app/script/view_model/MessageListViewModel.coffee @@ -0,0 +1,471 @@ +# +# 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.ViewModel ?= {} + +### +Message list rendering view model. + +@todo Get rid of the $('.conversation') opacity +@todo Get rid of the participants dependencies whenever bubble implementation has changed +@todo Remove all jquery selectors +### +class z.ViewModel.MessageListViewModel + constructor: (element_id, @conversation_repository, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.MessageListViewModel', z.config.LOGGER.OPTIONS + + @conversation = ko.observable new z.entity.Conversation() + @center_messages = ko.computed => + return not @conversation().has_further_messages() and @conversation().messages_visible().length is 1 and @conversation().messages_visible()[0].is_connection() + + @conversation_is_changing = false + + # is there a rendered message with an unread dot + @first_unread_timestamp = ko.observable() + + # store conversation to mark as read when browser gets focus + @mark_as_read_on_focus = undefined + + # can we used to prevent scroll handler from being executed (e.g. when using scrollTop()) + @capture_scrolling_event = false + + # store message subscription id + @messages_subscription = undefined + + # Last open bubble + @participant_bubble = undefined + @participant_bubble_last_id = undefined + + @viewport_changed = ko.observable false + @viewport_changed.extend rateLimit: 100 + + @recalculate_timeout = undefined + @on_initial_rendering = undefined + + @should_scroll_to_bottom = true + + # Check if the message container is to small and then pull new events + @on_mouse_wheel = _.throttle (e) => + is_not_scrollable = not $(e.currentTarget).is_scrollable() + is_scrolling_up = e.deltaY > 0 + if is_not_scrollable and is_scrolling_up + @_pull_events() + , 200 + + @on_scroll = _.throttle (data, e) => + return if not @capture_scrolling_event + + @viewport_changed not @viewport_changed() + + element = $ e.currentTarget + + # On some HDPI screen scrollTop returns a floating point number instead of an integer + # https://github.com/jquery/api.jquery.com/issues/608 + scroll_position = Math.ceil element.scrollTop() + scroll_end = element.scroll_end() + scrolled_bottom = false + + if scroll_position is 0 + @_pull_events() + + if scroll_position >= scroll_end + scrolled_bottom = true + + if document.hasFocus() + @conversation_repository.mark_as_read @conversation() + else + @mark_as_read_on_focus = @conversation() + + @should_scroll_to_bottom = scroll_position > scroll_end - z.config.SCROLL_TO_LAST_MESSAGE_THRESHOLD + + amplify.publish z.event.WebApp.LIST.SCROLL, scrolled_bottom + , 100 + + $(window) + .on 'resize', => + @viewport_changed not @viewport_changed() + .on 'focus', => + if @mark_as_read_on_focus? + window.setTimeout => + @conversation_repository.mark_as_read @mark_as_read_on_focus + @mark_as_read_on_focus = undefined + , 1000 + + amplify.subscribe z.event.WebApp.CONVERSATION.PEOPLE.HIDE, @hide_bubble + + ### + Remove all subscriptions and reset states. + @param conversation_et [z.entity.Conversation] Conversation entity to change to + ### + release_conversation: (conversation_et) => + conversation_et?.release() + @messages_subscription?.dispose() + @capture_scrolling_event = false + @first_unread_timestamp undefined + + ### + Change conversation. + @param conversation_et [z.entity.Conversation] Conversation entity to change to + @param callback [Function] Executed when all events are loaded an conversation is ready to be displayed + ### + change_conversation: (conversation_et, callback) => + @conversation_is_changing = true + + # clean up old conversation + @release_conversation @conversation() if @conversation() + + # update new conversation + @conversation conversation_et + + if not conversation_et.is_loaded() + @conversation_repository.update_participating_user_ets conversation_et, (conversation_et) => + @conversation_repository.get_events conversation_et + .then => + @_set_conversation conversation_et, callback + conversation_et.is_loaded true + else + @_set_conversation conversation_et, callback + + ### + Sets the conversation and waits for further processing until knockout has rendered the messages. + @param conversation_et [z.entity.Conversation] Conversation entity to set + @param callback [Function] Executed when message list is ready to fade in + ### + _set_conversation: (conversation_et, callback) => + # hide conversation until everything is processed + $('.conversation').css opacity: 0 + + @conversation_is_changing = false + + if @conversation().messages_visible().length is 0 + # return immediately if nothing to render + @_initial_rendering conversation_et, callback + else + # will be executed after all messages are rendered + @on_initial_rendering = _.once => @_initial_rendering conversation_et, callback + + ### + Registers for mouse wheel events and incoming messages. + + @note Call this once after changing conversation. + @param conversation_et [z.entity.Conversation] Conversation entity to render + @param callback [Function] Executed when message list is ready to fade in + ### + _initial_rendering: (conversation_et, callback) => + messages_container = $('.messages-wrap') + messages_container.on 'mousewheel', @on_mouse_wheel + + window.requestAnimFrame => + is_current_conversation = conversation_et is @conversation() + if not is_current_conversation + @logger.log @logger.levels.INFO, 'Skipped loading conversation', conversation_et.display_name() + return + + # reset scroll position + messages_container.scrollTop 0 + + @capture_scrolling_event = true + + if not messages_container.is_scrollable() + @conversation_repository.mark_as_read conversation_et + else + unread_message = $ '.message-timestamp-unread' + if unread_message.length > 0 + messages_container.scroll_by unread_message.parent().parent().position().top + else + messages_container.scroll_to_bottom() + $('.conversation').css opacity: 1 + + # subscribe for incoming messages + @messages_subscription = conversation_et.messages_visible.subscribe @_on_message_add, null, 'arrayChange' + @_subscribe_to_iframe_clicks() + callback?() + + ### + Checks how to scroll message list and if conversation should be marked as unread. + + @param message [Array] Array of message entities + ### + _on_message_add: (messages) => + messages_container = $('.messages-wrap') + last_item = messages[messages.length - 1] + last_message = last_item.value + + # we are only interested in items that were added + if last_item.status isnt 'added' + return + + # message was prepended + if last_message?.timestamp isnt @conversation().last_event_timestamp() + return + + # scroll to bottom if self user send the message + if last_message?.from is @user_repository.self().id + window.requestAnimFrame -> messages_container.scroll_to_bottom() + return + + # scroll to the end of the list if we are under a certain threshold + if @should_scroll_to_bottom + @conversation_repository.mark_as_read @conversation() if document.hasFocus() + window.requestAnimFrame -> messages_container.scroll_to_bottom() + + # mark as read when conversation is not scrollable + is_scrollable = messages_container.is_scrollable() + is_browser_has_focus = document.hasFocus() + if not is_scrollable + if is_browser_has_focus + @conversation_repository.mark_as_read @conversation() + else + @mark_as_read_on_focus = @conversation() + + # Get previous messages from the backend. + _pull_events: => + if not @conversation().is_pending() and @conversation().has_further_messages() + inner_container = $('.messages-wrap').children()[0] + old_list_height = inner_container.scrollHeight + + @capture_scrolling_event = false + @conversation_repository.get_events @conversation() + .then => + new_list_height = inner_container.scrollHeight + $('.messages-wrap').scrollTop new_list_height - old_list_height + @capture_scrolling_event = true + + scroll_height: (change_in_height) -> + $('.messages-wrap').scroll_by change_in_height + + ### + Triggered when user clicks on an avatar in the message list. + @param user_et [z.entity.User] User entity of the selected user + @param message [DOMElement] Selected DOMElement + ### + on_message_user_click: (user_et, element) => + BUBBLE_HEIGHT = 440 + MESSAGE_LIST_MIN_HEIGHT = 400 + list_height = $('.message-list').height() + element_rect = element.getBoundingClientRect() + element_distance_top = element_rect.top + element_distance_bottom = list_height - element_rect.top - element_rect.height + largest_distance = Math.max element_distance_top, element_distance_bottom + difference = BUBBLE_HEIGHT - largest_distance + + create_bubble = (element_id) => + wire.app.view.content.participants.reset_view() + @participant_bubble_last_id = element_id + @participant_bubble = new zeta.webapp.module.Bubble + host_selector: "##{element_id}" + scroll_selector: '.messages-wrap' + modal: true + on_show: -> + amplify.publish z.event.WebApp.PEOPLE.SHOW, user_et + on_hide: => + @participant_bubble = undefined + @participant_bubble_last_id = undefined + @participant_bubble.toggle() + + show_bubble = => + wire.app.view.content.participants.confirm_dialog?.destroy() + # we clicked on the same bubble + if @participant_bubble and @participant_bubble_last_id is element.id + @participant_bubble.toggle() + return + + # dismiss old bubble and wait with creating the new one when another bubble is open + if @participant_bubble or wire.app.view.content.participants.participants_bubble?.is_visible() + @participant_bubble?.hide() + window.setTimeout -> + create_bubble(element.id) + , 550 + else + create_bubble(element.id) + + if difference > 0 and list_height > MESSAGE_LIST_MIN_HEIGHT + if largest_distance is element_distance_top + @scroll_by -difference, show_bubble + else + @scroll_by difference, show_bubble + else + show_bubble() + + ### + Triggered when user clicks on the session reset link in a decrypt error message. + @param message_et [z.entity.DecryptErrorMessage] Decrypt error message + ### + on_session_reset_click: (message_et) => + reset_progress = -> + window.setTimeout -> + message_et.is_resetting_session false + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.SESSION_RESET + , 550 + + message_et.is_resetting_session true + @conversation_repository.reset_session message_et.from, message_et.client_id, @conversation().id + .then -> reset_progress() + .catch -> reset_progress() + + ### + Is called by knockout whenever a new message is rendered. + @param elements [Array] List of elements that were added to the DOM (Note: This also contains html comments) + @param message [z.entity.Message] rendered message + ### + after_message_render: (elements, message) => + window.requestAnimFrame => + message_index = @conversation_repository.active_conversation().messages_visible().indexOf message + if message_index > 0 + last_message = @conversation_repository.active_conversation().messages_visible()[message_index - 1] + + last = moment.unix last_message?.timestamp / 1000 + current = moment.unix message.timestamp / 1000 + + if not last_message? or moment(current).diff(last, 'minutes') > 60 and message.is_content() + $(elements) + .find '.message-timestamp' + .removeClass 'message-timestamp-hidden' + + if last_message? + ### + @note For content messages (except pings): + Don't show user avatar next to message if the last message in the conversation was already sent by the same user + ### + if last_message.is_content() and last_message.user().id is message.user().id and message.is_content() + $(elements) + .find '.message-header-user-is-hideable' + .addClass 'hide-user' + + if last_message.timestamp is @conversation().last_read_timestamp() and not @first_unread_timestamp() + @first_unread_timestamp message.timestamp + $(elements) + .find '.message-timestamp' + .addClass 'message-timestamp-unread' + .removeClass 'message-timestamp-hidden' + .end() + .find '.message-header-user-is-hideable' + .removeClass 'hide-user' + + if not last.isSame current, 'day' + $(elements) + .find '.message-timestamp' + .addClass 'message-timestamp-day' + .removeClass 'message-timestamp-hidden' + .end() + .find '.message-header-user-is-hideable' + .removeClass 'hide-user' + + if message?.is_ping() + now = Date.now() + message.animated now - current < 2000 + + if z.util.array_is_last @conversation().messages_visible(), message + # Defer initial rendering + window.requestAnimFrame => @on_initial_rendering() + + before_message_remove: (dom_node) -> + if $(dom_node).hasClass 'message' and not @conversation_is_changing + + has_timestamp = $(dom_node).find('.message-timestamp-hidden').length is 0 + has_day_timestamp = $(dom_node).find('.message-timestamp-day').length > 0 + has_avatar = $(dom_node).find('.hide-user').length is 0 + next_message = $(dom_node).next() + + if has_timestamp + next_message + .find '.message-timestamp' + .removeClass 'message-timestamp-hidden' + .end() + .find '.message-header-user-is-hideable' + .removeClass 'hide-user' + else if has_day_timestamp + next_message + .find '.message-timestamp' + .addClass 'message-timestamp-day' + .removeClass 'message-timestamp-hidden' + .end() + .find '.message-header-user-is-hideable' + .removeClass 'hide-user' + else if has_avatar + next_message + .find '.message-header-user-is-hideable' + .removeClass 'hide-user' + + $(dom_node) + .addClass 'message-fade-out' + .on 'transitionend', -> + $(@).remove() + else + # clean up whatever this is + $(dom_node).remove() + + # Subscribes to iFrame click events. + _subscribe_to_iframe_clicks: -> + $('iframe.soundcloud').iframeTracker blurCallback: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.SOUNDCLOUD_CONTENT_CLICKED + + $('iframe.youtube').iframeTracker blurCallback: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.YOUTUBE_CONTENT_CLICKED + + # Hides participant bubble. + hide_bubble: => + @participant_bubble?.hide() + + ### + Scrolls whole message list by given distance. + + @note Scrolling is animated with jQuery + @param distance [Number] Distance by which the container is shifted + @param callback [Function] Executed when scroll animation is finished + ### + scroll_by: (distance, callback) -> + current_scroll = $('.messages-wrap').scrollTop() + new_scroll = current_scroll + distance + $('.messages-wrap').animate {scrollTop: new_scroll}, 300, callback + + ### + Gets CSS class that will be applied to the message div in order to style. + @param message [z.entity.Message] Message entity for generating css class + @return [String] CSS class that is applied to the element + ### + get_css_class: (message) -> + switch message.super_type + when z.message.SuperType.CALL + return 'message-system message-call' + when z.message.SuperType.CONTENT + return 'message-normal' + when z.message.SuperType.MEMBER + return 'message message-system message-member' + when z.message.SuperType.PING + return 'message-ping' + when z.message.SuperType.SYSTEM + if message.system_message_type is z.message.SystemMessageType.CONVERSATION_RENAME + return 'message-system message-rename' + when z.message.SuperType.UNABLE_TO_DECRYPT + return 'message-system' + + ### + Shows detail image view. + @param asset_et [z.assets.Asset] Asset to be displayed + @param event [UIEvent] Actual scroll event + ### + show_detail: (asset_et, event) -> + target_element = $(event.currentTarget) + return if target_element.hasClass 'image-loading' + amplify.publish z.event.WebApp.CONVERSATION.DETAIL_VIEW.SHOW, target_element.find('img')[0].src + + click_on_cancel_request: (message_et) => + next_conversation_et = @conversation_repository.get_next_conversation @conversation_repository.active_conversation() + @user_repository.cancel_connection_request message_et.other_user(), next_conversation_et diff --git a/app/script/view_model/ModalsViewModel.coffee b/app/script/view_model/ModalsViewModel.coffee new file mode 100644 index 00000000000..d1afe912d56 --- /dev/null +++ b/app/script/view_model/ModalsViewModel.coffee @@ -0,0 +1,210 @@ +# +# 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.ViewModel ?= {} + +z.ViewModel.ModalType = + BLOCK: '.modal-block' + CALLING: '.modal-calling' + CALL_EMPTY_CONVERSATION: '.modal-call-conversation-empty' + CALL_FULL_CONVERSATION: '.modal-call-conversation-full' + CALL_FULL_VOICE_CHANNEL: '.modal-call-voice-channel-full' + CALL_NO_VIDEO_IN_GROUP: '.modal-call-no-video-in-group' + CALL_START_ANOTHER: '.modal-call-second' + CLEAR: '.modal-clear' + CLEAR_GROUP: '.modal-clear-group' + CONNECTED_DEVICE: '.modal-connected-device' + CONTACTS: '.modal-contacts' + DELETE_MESSAGE: '.modal-delete-message' + TOO_LONG_MESSAGE: '.modal-too-long-message' + TOO_MANY_MEMBERS: '.modal-too-many-members' + LEAVE: '.modal-leave' + LOGOUT: '.modal-logout' + NEW_DEVICE: '.modal-new-device' + SESSION_RESET: '.modal-session-reset' + UPLOAD_PARALLEL: '.modal-asset-upload-parallel' + UPLOAD_TOO_LARGE: '.modal-asset-upload-too-large' + WHITELIST_SCREENSHARING: '.modal-whitelist-screensharing' + +class z.ViewModel.ModalsViewModel + constructor: (element_id) -> + @logger = new z.util.Logger 'z.ViewModel.ModalsViewModel', z.config.LOGGER.OPTIONS + + @modals = {} + + amplify.subscribe z.event.WebApp.WARNINGS.MODAL, @show_modal + + ko.applyBindings @, document.getElementById element_id + + ### + Show modal + + @param type [z.ViewModel.ModalType] Indicates which modal to show + @param options [Object] + @option data [Object] Content needed for visualization on modal + @option action [Function] Function to be called when action in modal is triggered + ### + show_modal: (type, options = {}) => + message_element = $(type).find('.modal-text') + title_element = $(type).find('.modal-title') + switch type + when z.ViewModel.ModalType.BLOCK + @_show_modal_block options.data, title_element, message_element + when z.ViewModel.ModalType.CALL_FULL_CONVERSATION + @_show_modal_call_full_conversation options.data, message_element + when z.ViewModel.ModalType.CALL_FULL_VOICE_CHANNEL + @_show_modal_call_full_voice_channel options.data, message_element + when z.ViewModel.ModalType.CALL_START_ANOTHER + @_show_modal_call_start_another options.data, title_element, message_element + when z.ViewModel.ModalType.CLEAR + type = @_show_modal_clear options, type + when z.ViewModel.ModalType.CONNECTED_DEVICE + @_show_modal_connected_device options.data + when z.ViewModel.ModalType.LEAVE + @_show_modal_leave options.data, title_element + when z.ViewModel.ModalType.NEW_DEVICE + @_show_modal_new_device options.data, title_element + when z.ViewModel.ModalType.TOO_MANY_MEMBERS + @_show_modal_too_many_members options.data, message_element + when z.ViewModel.ModalType.UPLOAD_PARALLEL + @_show_modal_upload_parallel options.data, title_element + when z.ViewModel.ModalType.UPLOAD_TOO_LARGE + @_show_modal_upload_too_large options.data, title_element + when z.ViewModel.ModalType.TOO_LONG_MESSAGE + @_show_modal_message_too_long options.data, message_element + + modal = new zeta.webapp.module.Modal type, null, -> + $(type).find('.modal-close').off 'click' + $(type).find('.modal-action').off 'click' + $(type).find('.modal-secondary').off 'click' + modal.destroy() + options.close?() + + $(type).find('.modal-close').click -> + modal.hide() + + $(type).find('.modal-secondary').click -> + modal.hide -> options.secondary?() + + $(type).find('.modal-action').click -> + modal.hide -> + checkbox = $(type).find('.modal-option-checkbox') + if checkbox + options.action checkbox.is ':checked' + checkbox.attr 'checked', false + else + options.action?() + + @logger.log @logger.levels.INFO, "Toggle modal of type '#{type}'" + modal.toggle() + + _show_modal_block: (content, title_element, message_element) -> + title_element.text z.localization.Localizer.get_text { + id: z.string.modal_block_conversation_headline + replace: {placeholder: '%@.name', content: content} + } + message_element.text z.localization.Localizer.get_text { + id: z.string.modal_block_conversation_message + replace: {placeholder: '%@.name', content: content} + } + + _show_modal_call_full_conversation: (content, message_element) -> + message_element.text z.localization.Localizer.get_text { + id: z.string.modal_call_conversation_full_message + replace: {placeholder: '%no', content: content} + } + + _show_modal_call_full_voice_channel: (content, message_element) -> + message_element.text z.localization.Localizer.get_text { + id: z.string.modal_call_voice_channel_full_message + replace: {placeholder: '%no', content: content} + } + + _show_modal_call_start_another: (is_outgoing, title_element, message_element) -> + action_element = $(z.ViewModel.ModalType.CALL_START_ANOTHER).find('.modal-action') + if is_outgoing + action_element.text z.localization.Localizer.get_text z.string.modal_call_second_outgoing_action + message_element.text z.localization.Localizer.get_text z.string.modal_call_second_outgoing_message + title_element.text z.localization.Localizer.get_text z.string.modal_call_second_outgoing_headline + else + action_element.text z.localization.Localizer.get_text z.string.modal_call_second_incoming_action + message_element.text z.localization.Localizer.get_text z.string.modal_call_second_incoming_message + title_element.text z.localization.Localizer.get_text z.string.modal_call_second_incoming_headline + + _show_modal_clear: (options, type) -> + if options.conversation.is_group() and not options.conversation.removed_from_conversation() + type = z.ViewModel.ModalType.CLEAR_GROUP + + title_element = $(type).find('.modal-title') + title_element.text z.localization.Localizer.get_text { + id: z.string.modal_clear_conversation_headline + replace: {placeholder: '%@.name', content: options.data} + } + + return type + + _show_modal_connected_device: (devices) -> + devices_element = $(z.ViewModel.ModalType.CONNECTED_DEVICE).find('.modal-connected-devices') + devices_element.empty() + for device in devices + $('
    ') + .text "#{moment(device.time).format 'MMMM Do YYYY, HH:mm'} - UTC" + .appendTo devices_element + $('
    ') + .text "#{z.localization.Localizer.get_text z.string.modal_connected_device_from} #{device.model}" + .appendTo devices_element + + _show_modal_leave: (content, title_element) -> + title_element.text z.localization.Localizer.get_text { + id: z.string.modal_leave_conversation_headline + replace: {placeholder: '%@.name', content: content} + } + + _show_modal_new_device: (content, title_element) -> + title_element.text z.localization.Localizer.get_text { + id: z.string.modal_new_device_headline + replace: {placeholder: '%@.name', content: content} + } + + _show_modal_too_many_members: (content, message_element) -> + message_element.text z.localization.Localizer.get_text { + id: z.string.modal_too_many_members_message + replace: [ + {placeholder: '%no', content: content.open_spots} + {placeholder: '%max', content: content.max} + ] + } + + _show_modal_upload_parallel: (content, title_element) -> + title_element.text z.localization.Localizer.get_text { + id: z.string.modal_uploads_parallel + replace: {placeholder: '%no', content: content} + } + + _show_modal_upload_too_large: (content, title_element) -> + title_element.text z.localization.Localizer.get_text { + id: z.string.conversation_asset_upload_too_large + replace: {placeholder: '%no', content: content} + } + + _show_modal_message_too_long: (content, message_element) -> + message_element.text z.localization.Localizer.get_text { + id: z.string.modal_too_long_message + replace: {placeholder: '%no', content: content} + } diff --git a/app/script/view_model/ParticipantsViewModel.coffee b/app/script/view_model/ParticipantsViewModel.coffee new file mode 100644 index 00000000000..07adc25d983 --- /dev/null +++ b/app/script/view_model/ParticipantsViewModel.coffee @@ -0,0 +1,238 @@ +# +# 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.ViewModel ?= {} + +# participants view state +STATE = + PARTICIPANTS: 'participants' + SEARCH: 'search' + +class z.ViewModel.ParticipantsViewModel + constructor: (@element_id, @user_repository, @conversation_repository, @search_repository) -> + @logger = new z.util.Logger 'z.ViewModel.ParticipantsViewModel', z.config.LOGGER.OPTIONS + + @state = ko.observable STATE.PARTICIPANTS + + @conversation = ko.observable new z.entity.Conversation() + @conversation.subscribe => @render_participants false + + @render_participants = ko.observable false + + @participants = ko.observableArray() + @participants_verified = ko.observableArray() + @participants_unverified = ko.observableArray() + + ko.computed => + conversation_et = @conversation() + participants = [].concat conversation_et.participating_user_ets() + participants.sort z.util.sort_user_by_first_name + + @participants participants + @participants_verified (user_et for user_et in participants when user_et.is_verified()) + @participants_unverified (user_et for user_et in participants when not user_et.is_verified()) + + # confirm dialog reference + @confirm_dialog = undefined + + # selected group user + @user_profile = ko.observable new z.entity.User() + + # switch between div and input field to edit the conversation name + @editing = ko.observable false + @edit = -> @editing true + + @editing.subscribe (value) => + if value is false + name = $('.group-header .name span') + $('.group-header textarea').css('height', "#{name.height()}px") + else + $('.group-header textarea').val(@conversation().display_name()) + + @participants_bubble = new zeta.webapp.module.Bubble + host_selector: '#show-participants' + scroll_selector: '.messages-wrap' + modal: true + on_hide: => @reset_view() + + # TODO create a viewmodel search? + @user_input = ko.observable '' + @user_selected = ko.observableArray [] + @connected_users = ko.computed => + connected_users = ko.utils.arrayFilter @user_repository.users(), (user_et) => + is_participant = ko.utils.arrayFirst @participants(), (participant) -> user_et.id is participant.id + is_connected = user_et.connection().status() is z.user.ConnectionStatus.ACCEPTED + return is_participant is null and is_connected + connected_users.sort z.util.sort_user_by_first_name + , @, deferEvaluation: true + + @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} + } + + amplify.subscribe z.event.WebApp.PENDING.SHOW, => + @participants_bubble.hide() + + amplify.subscribe z.event.WebApp.PEOPLE.SHOW, (user_et) => + @user_profile user_et + $("##{@element_id}").addClass 'single-user-mode' + + toggle_participants_bubble: (add_people = false) => + toggle_bubble = => + if not @participants_bubble.is_visible() + @reset_view() + + if @conversation().is_group() + @user_profile new z.entity.User() + else + @user_profile @participants()[0] + + @render_participants true + $("##{@element_id}").removeClass 'single-user-mode' + + if add_people + if not @participants_bubble.is_visible() + @participants_bubble.show() + @add_people() + else if @state() is STATE.SEARCH or @confirm_dialog?.is_visible() + @participants_bubble.hide() + else + @add_people() + else + @participants_bubble.toggle() + + if wire.app.view.content.message_list.participant_bubble?.is_visible() + setTimeout -> + toggle_bubble() + , 550 + else + toggle_bubble() + + change_conversation: (conversation_et) -> + @participants_bubble.hide() + @conversation conversation_et + @user_profile new z.entity.User() + + reset_view: => + @state STATE.PARTICIPANTS + @user_selected.removeAll() + @confirm_dialog?.destroy() + $("##{@element_id}").removeClass 'single-user-mode' + + add_people: => + @state STATE.SEARCH + $('.participants-search').addClass 'participants-search-show' + + leave_conversation: => + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + @confirm_dialog = $('#participants').confirm + template: '#template-confirm-leave' + confirm: => + next_conversation_et = @conversation_repository.get_next_conversation @conversation() + @participants_bubble.hide() + @conversation_repository.leave_conversation @conversation(), next_conversation_et + + show_participant: (user_et) => + @user_profile user_et + + rename_conversation: (data, event) => + new_name = z.util.remove_line_breaks event.target.value.trim() + old_name = @conversation().display_name().trim() + event.target.value = old_name + @editing false + if new_name.length > 0 and new_name isnt old_name + @conversation_repository.rename_conversation @conversation(), new_name + + on_search_add: => + user_ids = ko.utils.arrayMap @user_selected(), (user_et) -> user_et.id + @participants_bubble.hide() + + if @conversation().is_group() + @conversation_repository.add_members @conversation(), user_ids, => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.ADD_TO_GROUP_CONVERSATION, + {numberOfParticipantsAdded: user_ids.length, numberOfGroupParticipants: @conversation().number_of_participants()} + else + user_ids = user_ids.concat @user_profile().id + @conversation_repository.create_new_conversation user_ids, null, (conversation_et) -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.CREATE_GROUP_CONVERSATION, + {creationContext: 'addedToOneToOne', numberOfParticipants: user_ids.length} + amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + + on_search_close: => + @reset_view() + + close: => + @user_profile new z.entity.User() + @reset_view() + + remove: (user_et) => + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + @confirm_dialog = $('#participants').confirm + template: '#template-confirm-remove' + data: + user: user_et + confirm: => + @conversation_repository.remove_member @conversation(), user_et.id, (response, error) => + @reset_view() if response + + show_self_profile: -> + amplify.publish z.event.WebApp.PROFILE.SHOW + + unblock: (user_et) => + @confirm_dialog = $('#participants').confirm + template: '#template-confirm-unblock' + data: + user: user_et + confirm: => + @user_repository.unblock_user user_et + .then => + @participants_bubble.hide() + conversation_et = @conversation_repository.get_one_to_one_conversation user_et.id + @conversation_repository.update_participating_user_ets conversation_et + + block: (user_et) => + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + @confirm_dialog = $('#participants').confirm + template: '#template-confirm-block' + data: + user: user_et + confirm: => + next_conversation_et = @conversation_repository.get_next_conversation @conversation() + @participants_bubble.hide() + @user_repository.block_user user_et + .then => + amplify.publish z.event.WebApp.CONVERSATION.SWITCH, @conversation(), next_conversation_et + + connect: (user_et) => + @participants_bubble.hide() + + pending: (user_et) => + on_success = => @participants_bubble.hide() + + @confirm_dialog = $('#participants').confirm + template: '#template-confirm-connect' + data: + user: @user_profile() + confirm: => + @user_repository.accept_connection_request user_et, true + .then -> on_success() + cancel: => + @user_repository.ignore_connection_request user_et + .then -> on_success() diff --git a/app/script/view_model/RightViewModel.coffee b/app/script/view_model/RightViewModel.coffee new file mode 100644 index 00000000000..97d00d6889d --- /dev/null +++ b/app/script/view_model/RightViewModel.coffee @@ -0,0 +1,176 @@ +# +# 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.ViewModel ?= {} + + +z.ViewModel.CONTENT_STATE = + PENDING: 'pending' + CONVERSATION: 'conversation' + PROFILE: 'profile' + BLANK: '' + + +class z.ViewModel.RightViewModel + constructor: (element_id, @user_repository, @conversation_repository, @call_repository, @search_repository, @giphy_repository, @client_repository) -> + @logger = new z.util.Logger 'z.ViewModel.RightViewModel', z.config.LOGGER.OPTIONS + + # state + @state = ko.observable z.ViewModel.CONTENT_STATE.BLANK + + # nested view models + @call_shortcuts = new z.ViewModel.CallShortcutsViewModel @call_repository + @video_calling = new z.ViewModel.VideoCallingViewModel 'video-calling', @call_repository, @user_repository, @conversation_repository + @connect_requests = new z.ViewModel.ConnectRequestsViewModel 'connect-requests', @user_repository + @conversation_titlebar = new z.ViewModel.ConversationTitlebarViewModel 'conversation-titlebar', @conversation_repository + @conversation_input = new z.ViewModel.ConversationInputViewModel 'conversation-input', @conversation_repository, @user_repository + @message_list = new z.ViewModel.MessageListViewModel 'message-list', @conversation_repository, @user_repository + @participants = new z.ViewModel.ParticipantsViewModel 'participants', @user_repository, @conversation_repository, @search_repository + @self_profile = new z.ViewModel.SelfProfileViewModel 'self-profile', @user_repository, @client_repository + @giphy = new z.ViewModel.GiphyViewModel 'giphy-modal', @conversation_repository, @giphy_repository + @detail_view = new z.ViewModel.ImageDetailViewViewModel 'detail-view' + + @previous_state = undefined + @previous_conversation = undefined + + @state.subscribe (value) => + if value is z.ViewModel.CONTENT_STATE.CONVERSATION + @conversation_input.added_to_view() + @conversation_titlebar.added_to_view() + else + @conversation_input.removed_from_view() + @conversation_titlebar.removed_from_view() + + @user_repository.connect_requests.subscribe (requests) => + if @state() is z.ViewModel.CONTENT_STATE.PENDING and requests.length is 0 + @show_conversation @conversation_repository.get_most_recent_conversation() + + @_init_subscriptions() + + ko.applyBindings @, document.getElementById element_id + + _init_subscriptions: => + amplify.subscribe z.event.WebApp.CONVERSATION.SHOW, @show_conversation + amplify.subscribe z.event.WebApp.CONVERSATION.SWITCH, @switch_conversation + amplify.subscribe z.event.WebApp.LIST.SCROLL, @conversation_input.show_separator + amplify.subscribe z.event.WebApp.PENDING.SHOW, @show_connect_requests + amplify.subscribe z.event.WebApp.PEOPLE.TOGGLE, @participants.toggle_participants_bubble + amplify.subscribe z.event.WebApp.PROFILE.SHOW, @show_self_profile + amplify.subscribe z.event.WebApp.PROFILE.HIDE, @hide_self_profile + amplify.subscribe z.event.WebApp.WINDOW.RESIZE.HEIGHT, @message_list.scroll_height + + ### + Slide in specified content. + + @param content_selector [String] dom element to apply slide in animation + ### + _shift_content: (content_selector) -> + incoming_css_class = 'content-animation-incoming' + + $(content_selector) + .removeClass incoming_css_class + .off z.util.alias.animationend + .addClass incoming_css_class + .one z.util.alias.animationend, -> + $(@).removeClass(incoming_css_class).off z.util.alias.animationend + + ### + Opens the specified conversation. + + @note If the conversation_et is not defined, it will open the incoming connection requests instead + Conversation_et can also just be the conversation ID + + @param conversation_et [z.entity.Conversation | String] Conversation entity or conversation ID + ### + show_conversation: (conversation_et) => + return @show_connect_requests() if not conversation_et? + conversation_et = @conversation_repository.get_conversation_by_id conversation_et if not conversation_et.id? + return if conversation_et is @conversation_repository.active_conversation() + + show_conversation = => + @logger.log @logger.levels.LEVEL_1, "Switching view to conversation: #{conversation_et.id}" + @state z.ViewModel.CONTENT_STATE.CONVERSATION + @message_list.change_conversation conversation_et, => + @_shift_content '.conversation' + @participants.change_conversation conversation_et + + @conversation_repository.active_conversation conversation_et + + if @state() is z.ViewModel.CONTENT_STATE.PROFILE + @self_profile.hide() + setTimeout show_conversation, 750 # wait for self profile to disappear + else + show_conversation() + + ### + Opens the incoming connection requests. + + @note If there are no connection requests, it will open the self profile instead + ### + show_connect_requests: => + return @show_self_profile() if @user_repository.connect_requests().length < 1 + + show_connect_request = => + @conversation_repository.active_conversation null + @state z.ViewModel.CONTENT_STATE.PENDING + @_shift_content '.connect-requests' + + @message_list.release_conversation() if @state() is z.ViewModel.CONTENT_STATE.CONVERSATION + + if @state() is z.ViewModel.CONTENT_STATE.PROFILE + @self_profile.hide() + setTimeout show_connect_request, 750 # wait for self profile to disappear + else + show_connect_request() + + ### + Open self profile. + + @param animate [Boolean] Do background animation + ### + show_self_profile: (animate = true) => + return if @state() is z.ViewModel.CONTENT_STATE.PROFILE + + @previous_state = @state() + @previous_conversation = @conversation_repository.active_conversation() + + @message_list.release_conversation() if @state() is z.ViewModel.CONTENT_STATE.CONVERSATION + + @conversation_repository.active_conversation null + amplify.publish z.event.WebApp.LIST.FULLSCREEN_ANIM_DISABLED if not animate + @state z.ViewModel.CONTENT_STATE.PROFILE + @self_profile.show() + + ### + Close self profile. + ### + hide_self_profile: => + if @previous_state is z.ViewModel.CONTENT_STATE.PENDING + @show_connect_requests() + else + @show_conversation @previous_conversation + + ### + Switches the conversation if the other one is shown. + + @param conversation_et [z.entity.Conversation] Conversation entity to be verified as currently active for the switch + @param next_conversation_et [z.entity.Conversation] Conversation entity to be shown + ### + switch_conversation: (conversation_et, next_conversation_et) => + @show_conversation next_conversation_et if @conversation_repository.is_active_conversation conversation_et diff --git a/app/script/view_model/SelfProfileViewModel.coffee b/app/script/view_model/SelfProfileViewModel.coffee new file mode 100644 index 00000000000..920ae66d18d --- /dev/null +++ b/app/script/view_model/SelfProfileViewModel.coffee @@ -0,0 +1,165 @@ +# +# 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.ViewModel ?= {} + +SETTING = + ALL: '0' + NONE: '2' + SOME: '1' + +LOCALYTICS_SOUND_SETTING = + ALL: 'alwaysPlay' + SOME: 'FirstMessageOnly' + NONE: 'neverPlay' + +class z.ViewModel.SelfProfileViewModel + constructor: (@element_id, @user_repository, @client_repository) -> + @logger = new z.util.Logger 'z.ViewModel.SelfProfileViewModel', z.config.LOGGER.OPTIONS + + @user = @user_repository.self + @settings_bubble = new zeta.webapp.module.Bubble host_selector: '#show-settings' + + @new_clients = ko.observableArray() + + $('.self-profile').on z.util.alias.animationend, -> + profile = $(@) + profile.removeClass 'self-profile-transition-in self-profile-transition-out' + profile.hide() if profile.hasClass 'self-profile-hidden' + + amplify.subscribe z.event.WebApp.LOGOUT.ASK_TO_CLEAR_DATA, @logout + amplify.subscribe z.event.WebApp.PROFILE.UPLOAD_PICTURE, @set_picture + amplify.subscribe z.event.WebApp.SELF.CLIENT_ADD, @on_client_add + +############################################################################### +# Self Profile +############################################################################### + + change_accent_color: (color) => + @user_repository.change_accent_color color.id + + change_username: (name) => + @user_repository.change_username name + + hide: -> + $('.self-profile') + .addClass 'self-profile-hidden self-profile-transition-out' + .removeClass 'self-profile-visible' + + show: -> + $('.self-profile').show() + + window.setTimeout -> + $('.self-profile') + .addClass 'self-profile-visible self-profile-transition-in' + .removeClass 'self-profile-hidden' + , 17 + + if @new_clients().length + setTimeout @on_show_new_clients, 1000 + + toggle_settings_menu: -> + @settings_bubble.toggle() + +############################################################################### +# Settings menu +############################################################################### + + logout: => + # TODO: Rely on client repository + if @client_repository.current_client().type is z.client.ClientType.PERMANENT + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.LOGOUT, + action: (clear_data) -> + amplify.publish z.event.WebApp.SIGN_OUT, 'user_requested', clear_data + else + @client_repository.delete_temporary_client() + .then -> amplify.publish z.event.WebApp.SIGN_OUT, 'user_requested', true + + show_support_page: -> + (window.open z.string.url_support)?.focus() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SETTINGS_MENU.SHOW_SUPPORT_PAGE + + toggle_about: -> + @about_modal ?= new zeta.webapp.module.Modal '#self-about' + @about_modal.toggle() + if @about_modal.is_shown() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SETTINGS_MENU.SHOW_ABOUT_SCREEN + @settings_bubble.hide() + + toggle_settings: -> + @settings_bubble.hide() + amplify.publish z.event.WebApp.PROFILE.SETTINGS.SHOW + +############################################################################### +# Profile Picture +############################################################################### + + set_picture: (files, callback) => + input_picture = files[0] + warning_file_format = z.localization.Localizer.get_text z.string.alert_upload_file_format + warning_file_size = z.localization.Localizer.get_text { + id: z.string.alert_upload_too_large + replace: {placeholder: '%no', content: z.config.MAXIMUM_IMAGE_FILE_SIZE / 1024 / 1024} + } + warning_min_size = z.localization.Localizer.get_text z.string.alert_upload_too_small + + if input_picture.size > z.config.MAXIMUM_IMAGE_FILE_SIZE + return @_show_upload_warning warning_file_size, callback + + if not input_picture.type in z.config.SUPPORTED_IMAGE_TYPES + return @_show_upload_warning warning_file_format, callback + + max_width = z.config.MINIMUM_PROFILE_IMAGE_SIZE.WIDTH + max_height = z.config.MINIMUM_PROFILE_IMAGE_SIZE.HEIGHT + z.util.valid_profile_image_size input_picture, max_width, max_height, (valid) => + if valid + @user_repository.change_picture input_picture, callback + else + @_show_upload_warning warning_min_size, callback + + click_on_change_picture: (files) => + @set_picture files, -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.PROFILE_PICTURE_CHANGED, source: 'fromPhotoLibrary' + + _show_upload_warning: (warning, callback) -> + amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT + setTimeout -> + callback? null, 'error' + window.alert warning + , 200 + +############################################################################### +# Clients +############################################################################### + + # TODO handle clients + on_client_add: (client) => + amplify.publish z.event.WebApp.SEARCH.BADGE.SHOW + @new_clients.push client + + on_show_new_clients: => + amplify.publish z.event.WebApp.SEARCH.BADGE.HIDE + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CONNECTED_DEVICE, + data: @new_clients() + close: => + @new_clients.removeAll() + secondary: => + setTimeout => + @toggle_settings() + , 1000 diff --git a/app/script/view_model/SettingsViewModel.coffee b/app/script/view_model/SettingsViewModel.coffee new file mode 100644 index 00000000000..78a45adc8ea --- /dev/null +++ b/app/script/view_model/SettingsViewModel.coffee @@ -0,0 +1,194 @@ +# +# 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.ViewModel ?= {} + +SETTING = + ALL: '0' + NONE: '2' + SOME: '1' + +LOCALYTICS_SOUND_SETTING = + ALL: 'alwaysPlay' + SOME: 'FirstMessageOnly' + NONE: 'neverPlay' + +class z.ViewModel.SettingsViewModel + constructor: (@element_id, @user_repository, @conversation_repository, @client_repository, @cryptography_repository) -> + @logger = new z.util.Logger 'z.ViewModel.SelfProfileViewModel', z.config.LOGGER.OPTIONS + + @user = @user_repository.self + + @settings_modal = undefined + + @remove_form_visible = ko.observable false + @remove_form_error = ko.observable false + + @selected_device = ko.observable() + @selected_device.subscribe => + if @selected_device() + @is_resetting_session false + @remove_form_visible false + @remove_form_error false + @_update_fingerprints() + + @fingerprint_remote = ko.observable '' + @fingerprint_local = ko.observable '' + @is_resetting_session = ko.observable false + + @current_client = @client_repository.current_client + # All clients except the current client + @devices = ko.observableArray() + @client_repository.clients.subscribe (client_ets) => + client_ets = client_ets.filter (client_et) => + return client_et.meta.user_id is @user().id and client_et.id isnt @current_client()?.id + @devices client_ets + + @data_setting = ko.observable() + @data_setting.subscribe (setting) => @user_repository.save_property_data_settings setting + + @delete_status = ko.observable 'button' + @delete_confirm_text = ko.observable '' + + @sound_setting = ko.observable() + @sound_setting.subscribe (setting) => + audio_setting = z.audio.AudioSetting.ALL + tracking_value = LOCALYTICS_SOUND_SETTING.ALL + + if setting is SETTING.SOME + audio_setting = z.audio.AudioSetting.SOME + tracking_value = LOCALYTICS_SOUND_SETTING.SOME + else if setting is SETTING.NONE + audio_setting = z.audio.AudioSetting.NONE + tracking_value = LOCALYTICS_SOUND_SETTING.NONE + + @user_repository.save_property_sound_alerts audio_setting + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SOUND_SETTINGS_CHANGED, value: tracking_value + + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATED, @update_properties + amplify.subscribe z.event.WebApp.PROFILE.SETTINGS.SHOW, @toggle_settings + + ko.applyBindings @, document.getElementById @element_id + + + ############################################################################### + # Settings menu + ############################################################################### + + toggle_settings: => + @settings_modal ?= new zeta.webapp.module.Modal '#self-settings', => @selected_device null + if @settings_modal.is_hidden() + @client_repository.get_clients_for_self() + @settings_modal.toggle() + + + ############################################################################### + # Settings + ############################################################################### + + _update_fingerprints: => + @cryptography_repository.get_session @user().id, @selected_device().id + .then (cryptobox_session) => + @fingerprint_remote cryptobox_session.fingerprint_remote() + @fingerprint_local cryptobox_session.fingerprint_local() + + click_on_delete: -> + @delete_confirm_text z.localization.Localizer.get_text + id: z.string.preferences_delete_info + replace: {placeholder: '%email', content: @user().email()} + + @delete_status 'dialog' + + click_on_delete_send: -> + @user_repository.delete_me() + @delete_status 'sent' + setTimeout => + @delete_status 'button' + , 5000 + + click_on_delete_cancel: -> + @delete_status 'button' + + click_on_reset_password: -> + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.PASSWORD_RESET, value: 'fromProfile' + (window.open z.string.url_password_reset)?.focus() + + click_on_device: (client_et) => + @selected_device client_et + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SETTINGS.VIEWED_DEVICE, outcome: 'success' + + click_on_device_close: => + @selected_device null + + click_on_verify_client: => + toggle_verified = !!!@selected_device().meta.is_verified() + client_id = @selected_device().id + user_id = @user().id + changes = + meta: + is_verified: toggle_verified + + @client_repository.update_client_in_db user_id, client_id, changes + .then => @selected_device().meta.is_verified toggle_verified + + 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_repository.self_conversation().id + .then -> reset_progress() + .catch -> reset_progress() + + click_on_remove_device_submit: (password) => + @client_repository.delete_client @selected_device().id, password + .then => + @selected_device null + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SETTINGS.REMOVED_DEVICE, outcome: 'success' + .catch => + @logger.log @logger.levels.WARN, 'Unable to remove device' + @remove_form_error true + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.SETTINGS.REMOVED_DEVICE, outcome: 'fail' + + update_properties: (properties) => + if properties.settings.sound.alerts is z.audio.AudioSetting.ALL + @sound_setting SETTING.ALL + else if properties.settings.sound.alerts is z.audio.AudioSetting.SOME + @sound_setting SETTING.SOME + else if properties.settings.sound.alerts is z.audio.AudioSetting.NONE + @sound_setting SETTING.NONE + + @data_setting properties.settings.privacy.report_errors + + + ############################################################################### + # Google/Contacts upload + ############################################################################### + + connect_google: -> + amplify.publish z.event.WebApp.CONNECT.IMPORT_CONTACTS, z.connect.ConnectSource.GMAIL, z.connect.ConnectTrigger.SETTINGS + amplify.publish z.event.WebApp.SEARCH.SHOW + @settings_modal.hide() + + connect_osx_contacts: -> + amplify.publish z.event.WebApp.CONNECT.IMPORT_CONTACTS, z.connect.ConnectSource.ICLOUD, z.connect.ConnectTrigger.SETTINGS + amplify.publish z.event.WebApp.SEARCH.SHOW + @settings_modal.hide() diff --git a/app/script/view_model/StartUIViewModel.coffee b/app/script/view_model/StartUIViewModel.coffee new file mode 100644 index 00000000000..4fe5667cdde --- /dev/null +++ b/app/script/view_model/StartUIViewModel.coffee @@ -0,0 +1,486 @@ +# +# 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.ViewModel ?= {} + + +class z.ViewModel.StartUIViewModel + constructor: (element_id, @conversation_repository, @search_repository, @user_repository, @connect_repository) -> + @logger = new z.util.Logger 'z.ViewModel.StartUIViewModel', z.config.LOGGER.OPTIONS + + @search = _.debounce (query) => + query = query.trim() + if query + @clear_search_results() + + # contacts + @search_results.contacts @user_repository.get_user_by_name query + + # search for groups + @search_results.groups @conversation_repository.get_groups_by_name query + + # search for others + @search_repository.search_by_name query + .then (user_ets) => + if query is @search_input().trim() + @search_results.others user_ets + else + @logger.log @logger.levels.INFO, "Resolved Search query #{query} is outdated" + .catch (error) => + @logger.log @logger.levels.ERROR, "Error searching for contacts: #{error.message}", error + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.BOOLEAN.SEARCHED_FOR_PEOPLE, true + , 300 + + @user = @user_repository.self + + @search_input = ko.observable '' + @search_input.subscribe @search + @selected_people = ko.observableArray [] + + @has_created_conversation = ko.observable false + @show_hint = ko.computed => @selected_people().length is 1 and not @has_created_conversation() + + @group_hint_text = z.localization.Localizer.get_text z.string.search_group_hint + + # results + @top_users = ko.observableArray [] + @suggestions = ko.observableArray [] + @connections = ko.computed => + @conversation_repository.sorted_conversations() + .filter (conversation_et) -> conversation_et.type() is z.conversation.ConversationType.ONE2ONE + @connections.extend rateLimit: 500 + + @search_results = + groups: ko.observableArray [] + contacts: ko.observableArray [] + others: ko.observableArray [] + + # view states + @show_no_contacts_on_wire = ko.observable false + @is_searching = ko.observable false + @show_spinner = ko.observable false + @show_people_picker = ko.observable() + + @has_uploaded_contacts = ko.observable false + + @has_results = ko.computed => + return @search_results.groups().length > 0 or + @search_results.contacts().length > 0 or + @search_results.others().length > 0 + + @show_top_people = ko.computed => + return !!@top_users().length + + @show_suggestions = ko.computed => + return !!@suggestions().length + + @show_connections = ko.computed => + return @top_users().length > 9 and not @show_suggestions() + + @show_search_results = ko.computed => + if @selected_people().length is 0 and @search_input().length is 0 + @clear_search_results() + return false + return @has_results() or @search_input().length > 0 + + @show_invite = ko.computed => + no_top_people_and_suggestions = not @show_search_results() and not @show_top_people() and not @show_suggestions() + no_search_results = @show_search_results() and not @has_results() and not @is_searching() + return no_top_people_and_suggestions or no_search_results + + # invite bubble states + @show_invite_form = ko.observable true + @show_invite_form_only = ko.computed => + return true if @has_uploaded_contacts() + return true if not @has_uploaded_contacts() and not @show_top_people() and not @show_suggestions() + return false + + # selected user + @user_profile = ko.observable null + @user_bubble = null + + # invite bubble + @invite_bubble = null + @invite_message = ko.observable '' + @invite_message_selected = ko.observable true + @invite_hints = ko.computed => + meta_key_mac = z.localization.Localizer.get_text z.string.invite_meta_key_mac + meta_key_pc = z.localization.Localizer.get_text z.string.invite_meta_key_pc + meta_key = if z.util.Environment.os.mac then meta_key_mac else meta_key_pc + + if @invite_message_selected() + return z.localization.Localizer.get_text + id: z.string.invite_hint_selected + replace: [ + placeholder: '%meta_key', content: meta_key + ] + else + return z.localization.Localizer.get_text + id: z.string.invite_hint_unselected + replace: [ + placeholder: '%meta_key', content: meta_key + ] + + @invite_button_text = ko.computed => + button_text = if @show_invite_form_only() then z.string.people_invite else z.string.people_bring_your_friends + z.localization.Localizer.get_text button_text + + # last open bubble + @user_bubble = undefined + @user_bubble_last_id = undefined + + amplify.subscribe z.event.WebApp.CONNECT.IMPORT_CONTACTS, @import_contacts + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATED, @update_properties + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATE.GOOGLE, @update_properties + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATE.OSX_CONTACTS, @update_properties + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATE.HAS_CREATED_CONVERSATION, @update_properties + amplify.subscribe z.event.WebApp.PROPERTIES.UPDATED, @update_properties + amplify.subscribe z.event.WebApp.SEARCH.HIDE, @close + amplify.subscribe z.event.WebApp.SEARCH.SHOW, @open + + ko.applyBindings @, document.getElementById element_id + + clear_search_results: -> + @search_results.groups.removeAll() + @search_results.contacts.removeAll() + @search_results.others.removeAll() + @is_searching false + @show_no_contacts_on_wire false + + click_on_close: => + @_close() + + _track_import: (source, ui_identifier, error) -> + if ui_identifier is z.connect.ConnectTrigger.ONBOARDING + event_name = z.tracking.EventName.ONBOARDING.IMPORTED_CONTACTS + else + event_name = z.tracking.EventName.SETTINGS.IMPORTED_CONTACTS + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, event_name, + source: source + outcome: if error then 'fail' else 'success' + + ### + Connect with contacts. + @param source [z.connect.ConnectSource] Source for the contacts import + @param ui_identifier [String] UI component that triggered the event (used for Analytics) + ### + import_contacts: (source, ui_identifier = z.connect.ConnectTrigger.ONBOARDING) => + @show_spinner true + if source is z.connect.ConnectSource.GMAIL + import_promise = @connect_repository.get_google_contacts() + else if source is z.connect.ConnectSource.ICLOUD + import_promise = @connect_repository.get_osx_contacts() + + import_promise.then (response) => + @_show_onboarding_results response + .catch (error) => + if error.type isnt z.connect.ConnectError::TYPE.NO_CONTACTS + @logger.log @logger.levels.ERROR, "Importing contacts from '#{source}' failed: #{error.message}", error + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CONTACTS, action: => + @import_contacts source, ui_identifier + .then (error) => + @show_spinner false + @_track_import source, ui_identifier, error + + _show_onboarding_results: (response) => + @search_repository.show_onboarding response + .then (matched_user_ets) => + @suggestions matched_user_ets + return @search_repository.get_top_people() + .then (user_ets) => + @top_users user_ets + @selected_people.removeAll() + if @suggestions().length is 0 + if @top_users().length > 0 + @suggestions @top_users() + else + @show_no_contacts_on_wire true + .catch (error) => + @logger.log @logger.levels.ERROR, "Could not show the on-boarding results: #{error.message}", error + + open: (update = true) => + $(document).on 'keydown.show_search', (event) => @_close() if event.keyCode is z.util.KEYCODE.ESC + + if update + @search_repository.get_top_people() + .then (user_ets) => + @top_users user_ets if user_ets.length > 0 + .catch (error) => + @logger.log @logger.levels.ERROR, "Could not update the top people: #{error.message}", error + + @show_spinner false + + # clean up + @suggestions.removeAll() + @selected_people.removeAll() + @clear_search_results() + @user_profile null + @_show() + + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.SEARCH_OPENED + + _close: -> + amplify.publish z.event.WebApp.SEARCH.HIDE + + close: => + $(document).off 'keydown.show_search' + + @user_bubble?.hide() + @invite_bubble?.hide() + @show_spinner false + + @selected_people.removeAll() + @search_input '' + @_hide() + + amplify.publish z.event.WebApp.CONVERSATION_LIST.SHOW + amplify.publish z.event.WebApp.LIST.BLUR, 0 + + click_on_group: (conversation_et) => + @conversation_repository.unarchive_conversation conversation_et if conversation_et.is_archived() + @_close() + amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et + + click_on_other: (user_et, e) => + + create_bubble = (element_id) => + @user_profile user_et + @user_bubble_last_id = element_id + @user_bubble = new zeta.webapp.module.Bubble + host_selector: "##{element.attr('id')}" + scroll_selector: '.start-ui-list' + on_hide: => + @user_bubble = undefined + @user_bubble_last_id = undefined + on_show: -> + $('.start-ui-user-bubble .user-profile-connect-message').focus() + + @user_bubble.toggle() + + # we clicked on the same bubble + if @user_bubble and @user_bubble_last_id is e.currentTarget.id + @user_bubble.toggle() + return + + element = $(e.currentTarget).attr + 'id': Date.now() + 'data-bubble': '#start-ui-user-bubble' + 'data-placement': 'right-flex' + + # dismiss old bubble and wait with creating the new one when another bubble is open + if @user_bubble + @user_bubble?.hide() + window.setTimeout -> + create_bubble(element[0].id) + , 550 + else + create_bubble(element[0].id) + + _collapse_item: (search_list_item, callback) -> + search_list_item.find('.search-list-item-connect').remove() + window.requestAnimFrame -> + search_list_item + .addClass 'search-list-item-collapse' + .on z.util.alias.animationend, (event) -> + if event.originalEvent.propertyName is 'height' + search_list_item + .remove() + .off z.util.alias.animationend + callback?() + + + click_on_dismiss: (user_et, event) => + search_list_item = $(event.currentTarget.parentElement.parentElement) + @_collapse_item search_list_item, => + @search_repository.ignore_suggestion user_et.id + .then => + @suggestions.remove user_et + .catch (error) => + @logger.log @logger.levels.ERROR, "Failed to ignore suggestions: '#{error.message}'", error + + click_on_connect: (user_et, event) => + search_list_item = $(event.currentTarget.parentElement.parentElement) + search_list_item + .addClass 'search-list-item-connect-anim' + .one z.util.alias.animationend, => + window.setTimeout => + @_collapse_item search_list_item, => + @user_repository.create_connection user_et + .then => + @suggestions.remove user_et + , 550 + + + ############################################################################### + # USER BUBBLE + ############################################################################### + + on_user_accept: (user_et) => + @_close() + @user_repository.accept_connection_request user_et, true + + on_user_connect: => + @_close() + + on_user_ignore: (user_et) => + @user_repository.ignore_connection_request user_et + .then => + @user_bubble?.hide() + + on_user_open: => + @_close() + + on_user_unblock: (user_et) => + @_close() + @user_repository.unblock_user user_et, true + + on_cancel_request: => + @user_bubble?.hide() + + + ############################################################################### + # INVITE BUBBLE + ############################################################################### + + click_on_contacts_import: => + @invite_bubble?.hide() + @import_contacts z.connect.ConnectSource.ICLOUD, z.connect.ConnectTrigger.SEARCH + + click_on_gmail_import: => + @invite_bubble?.hide() + @import_contacts z.connect.ConnectSource.GMAIL, z.connect.ConnectTrigger.SEARCH + + click_on_import_form: => + @show_invite_form false + + click_on_invite_form: => + @show_invite_form true + @_focus_invite_form() + + show_invite_bubble: => + return if @invite_bubble? + + self = @user_repository.self() + + @invite_message z.localization.Localizer.get_text + id: z.string.invite_message + replace: [ + {placeholder: '%url', content: z.util.Invite.get_invitation_to_connect_url self.id} + ] + + @invite_bubble = new zeta.webapp.module.Bubble + host_selector: '#invite-button' + scroll_selector: '.start-ui-list' + on_hide: => + $('.invite-link-box .bg').removeClass 'bg-animation' + $('.invite-link-box .message').off 'copy blur focus' + @invite_bubble = null + @show_invite_form true + on_show: => @_focus_invite_form() if @show_invite_form() + + @invite_bubble.show() + + _focus_invite_form: => + $('.invite-link-box .message') + .on 'copy', (e) => + $(e.currentTarget).parent().find('.bg') + .addClass 'bg-animation' + .on z.util.alias.animationend, (e) => + return if e.originalEvent.animationName isnt 'message-bg-fadeout' + $(@).off z.util.alias.animationend + @invite_bubble.hide() + .on 'blur', => + @invite_message_selected false + .on 'click', (e) => + @invite_message_selected true + $(e.target).select() + .trigger 'click' + + + ############################################################################### + # USER PROPERTIES + ############################################################################### + + update_properties: => + @has_created_conversation @user_repository.properties.has_created_conversation + @has_uploaded_contacts @user_repository.properties.contact_import.google? or @user_repository.properties.contact_import.osx? + return true + + ############################################################################### + # H + ############################################################################### + + on_submit_search: (callback) => + user_ids = (user_et.id for user_et in @selected_people()) + + if user_ids.length is 0 + return + + if user_ids.length is 1 + conversation_et = @conversation_repository.get_one_to_one_conversation user_ids[0] + @click_on_group conversation_et + callback conversation_et if _.isFunction callback + return + + on_success = (conversation_et) => + @logger.log @logger.levels.INFO, "Created new conversation with ID: #{conversation_et.id}" + @user_repository.save_property_has_created_conversation() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.CREATE_GROUP_CONVERSATION, + {creationContext: 'search', numberOfParticipants: user_ids.length} + @click_on_group conversation_et + callback conversation_et if _.isFunction callback + + # Error: {code: 403, message: "Users are not connected", label: "not-connected"} + on_error = (error) => + @logger.log @logger.levels.WARN, "Unable to create conversation: #{error.message}" + @_close() + + @conversation_repository.create_new_conversation user_ids, null, on_success, on_error + + on_audio_call: => + @on_submit_search (conversation_et) -> + window.setTimeout -> + amplify.publish z.event.WebApp.CALL.STATE.JOIN, conversation_et.id + , 1000 + + on_video_call: => + @on_submit_search (conversation_et) -> + window.setTimeout -> + amplify.publish z.event.WebApp.CALL.STATE.JOIN, conversation_et.id, true + , 1000 + + on_photo: (images) => + @on_submit_search -> + window.setTimeout -> + amplify.publish z.event.WebApp.CONVERSATION.IMAGE.SEND, images + , 1000 + +############################################################################### +# Start UI animations +############################################################################### + _hide: -> + $('#start-ui').removeClass 'start-ui-is-visible' + $('user-input input').blur() + + _show: -> + $('#start-ui').addClass('start-ui-is-visible').find('.start-ui-list').scrollTop 0 + $('user-input input').focus() + @show_people_picker Date.now() diff --git a/app/script/view_model/VideoCallingViewModel.coffee b/app/script/view_model/VideoCallingViewModel.coffee new file mode 100644 index 00000000000..7bc66088724 --- /dev/null +++ b/app/script/view_model/VideoCallingViewModel.coffee @@ -0,0 +1,212 @@ +# +# 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.ViewModel ?= {} + + +class z.ViewModel.VideoCallingViewModel + constructor: (element_id, @call_center, @user_repository, @conversation_repository) -> + @logger = new z.util.Logger 'z.ViewModel.VideoCallingViewModel', z.config.LOGGER.OPTIONS + + @self_user = @user_repository.self + + @available_devices = @call_center.media_devices_handler.available_devices + @current_device_id = @call_center.media_devices_handler.current_device_id + @current_device_index = @call_center.media_devices_handler.current_device_index + + @local_video_stream = @call_center.media_stream_handler.local_media_streams.video + @remote_video_stream = @call_center.media_stream_handler.remote_media_streams.video + + @self_stream_state = @call_center.media_stream_handler.self_stream_state + + @is_choosing_screen = ko.observable false + @is_minimized = ko.observable false + + @minimize_timeout = undefined + + @number_of_screen_devices = ko.observable 0 + @number_of_video_devices = ko.observable 0 + @video_mode = ko.observable z.calling.enum.VideoOrientation.LANDSCAPE + + @joined_call = @call_center.joined_call + + @videod_call = ko.pureComputed => + for call_et in @call_center.calls() + is_active = call_et.state() in z.calling.enum.CallStateGroups.IS_ACTIVE + is_client_videod = call_et.self_client_joined() and @self_stream_state.screen_shared() or @self_stream_state.videod() + is_remote_videod = call_et.is_remote_videod() and not call_et.is_ongoing_on_another_client() + return call_et if is_active and (is_client_videod or is_remote_videod or @is_choosing_screen()) + + @is_ongoing = ko.pureComputed => + return @videod_call()? and @joined_call()?.state() is z.calling.enum.CallState.ONGOING + + @overlay_icon_class = ko.pureComputed => + if @is_ongoing() + if @self_stream_state.muted() + return 'icon-mute' + else if not @self_stream_state.screen_shared() and not @self_stream_state.videod() + return 'icon-video-off' + + @remote_user = ko.pureComputed => + return @joined_call()?.participants()[0]?.user + + @show_local = ko.pureComputed => + return not @is_minimized() and not @is_choosing_screen() + @show_local_video = ko.pureComputed => + is_visible = @self_stream_state.screen_shared() or @self_stream_state.videod() or @videod_call()?.state() isnt z.calling.enum.CallState.ONGOING + return @local_video_stream() and is_visible + + @show_remote = ko.pureComputed => + return @show_remote_video() or @show_remote_participant() or @is_choosing_screen() + @show_remote_participant = ko.pureComputed => + is_visible = @remote_user() and not @is_minimized() and not @joined_call()?.is_remote_videod() or not @remote_video_stream() + return @is_ongoing() and is_visible + @show_remote_video = ko.pureComputed => + is_visible = @joined_call()?.is_remote_videod() and @remote_video_stream() + return @is_ongoing() and is_visible + + @show_switch_camera = ko.pureComputed => + is_visible = @local_video_stream() and @available_devices.video_input().length > 1 and @self_stream_state.videod() + return @is_ongoing() and is_visible + @show_switch_screen = ko.pureComputed => + is_visible = @local_video_stream() and @available_devices.screen_input().length > 1 and @self_stream_state.screen_shared() + return @is_ongoing() and is_visible + + @show_controls = ko.pureComputed => + is_visible = @show_remote_video() or @show_remote_participant() and not @is_minimized() + return @is_ongoing() and is_visible + @show_toggle_screen = ko.pureComputed -> + return z.calling.CallCenter.supports_screen_sharing() + @disable_toggle_screen = ko.pureComputed => + return @joined_call()?.is_remote_screen_shared() + + @videod_call.subscribe (videod_call) => + if videod_call + if @show_local_video() or @show_remote_video() or @is_choosing_screen() or videod_call.state() is z.calling.enum.CallState.INCOMING + @is_minimized false + @logger.log @logger.levels.INFO, "Displaying call '#{videod_call.id}' full-screen", videod_call + else + @is_minimized true + @logger.log @logger.levels.INFO, "Minimizing call '#{videod_call.id}' that is not videod", videod_call + else + @is_minimized false + @logger.log @logger.levels.INFO, 'Resetting full-screen calling to maximize' + + @available_devices.screen_input.subscribe (media_devices) => + if _.isArray media_devices then @number_of_screen_devices media_devices.length else @number_of_screen_devices 0 + @available_devices.video_input.subscribe (media_devices) => + if _.isArray media_devices then @number_of_video_devices media_devices.length else @number_of_video_devices 0 + @show_remote_participant.subscribe (show_remote_participant) => + if @minimize_timeout + window.clearTimeout @minimize_timeout + @minimize_timeout = undefined + + if show_remote_participant and @videod_call() and not @is_choosing_screen() + @logger.log @logger.levels.INFO, "Scheduled minimizing call '#{@videod_call().id}' on timeout as remote user '#{@remote_user()?.name()}' is not videod" + @minimize_timeout = window.setTimeout => + @is_minimized true if not @is_choosing_screen() + @logger.log @logger.levels.INFO, "Minimizing call '#{@videod_call().id}' on timeout as remote user '#{@remote_user()?.name()}' is not videod" + , 5000 + + amplify.subscribe z.event.WebApp.CALL.STATE.TOGGLE_SCREEN, @choose_shared_screen + + ko.applyBindings @, document.getElementById element_id + + choose_shared_screen: (conversation_id) => + return if @disable_toggle_screen() + + if @self_stream_state.screen_shared() or z.util.Environment.browser.firefox + @call_center.state_handler.toggle_screen conversation_id + + else if z.util.Environment.electron + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CALLING.SHARED_SCREEN, + conversation_type: if @joined_call().is_group() then 'group' else 'one_to_one' + kind_of_call_when_sharing: if @joined_call().is_remote_videod() then 'video' else 'audio' + + @call_center.media_devices_handler.get_screen_sources() + .then (screen_sources) => + if screen_sources.length > 1 + @is_minimized false + @is_choosing_screen true + else + @call_center.state_handler.toggle_screen conversation_id + .catch (error) => + @logger.log @logger.levels.ERROR, 'Unable to get screens sources for sharing', error + + clicked_on_cancel_call: => + @call_center.state_handler.leave_call @joined_call()?.id + + clicked_on_cancel_screen: => + @is_choosing_screen false + + clicked_on_mute_audio: => + @call_center.state_handler.toggle_audio @joined_call()?.id + + clicked_on_share_screen: => + @choose_shared_screen @joined_call().id + + clicked_on_choose_screen: (screen_source) => + @current_device_id.screen_input '' + @logger.log @logger.levels.INFO, "Selected '#{screen_source.name}' for screen sharing", screen_source + @is_choosing_screen false + @current_device_id.screen_input screen_source.id + @call_center.state_handler.toggle_screen @joined_call().id + if not @joined_call().is_remote_videod() + @is_minimized true + @logger.log @logger.levels.INFO, "Minimizing call '#{@videod_call().id}' on screen selection as remote user '#{@remote_user()?.name()}' is not videod" + + clicked_on_stop_video: => + @call_center.state_handler.toggle_video @joined_call()?.id + + clicked_on_toggle_camera: => + @call_center.media_devices_handler.toggle_next_camera() + + clicked_on_toggle_screen: => + @call_center.media_devices_handler.toggle_next_screen() + + clicked_on_minimize: => + @is_minimized true + @logger.log @logger.levels.INFO, "Minimizing call '#{@videod_call().id}' on user click" + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CALLING.MINIMIZED_FROM_FULLSCREEN, + conversation_type: if @joined_call().is_group() then 'group' else 'one_to_one' + + clicked_on_maximize: => + @is_minimized false + @logger.log @logger.levels.INFO, "Maximizing call '#{@videod_call().id}' on user click" + + # Detect the aspect ratio of a MediaElement and set the video mode. + on_loadedmetadata: (view_model, event) => + media_element = event.target + if media_element.videoHeight > media_element.videoWidth + detected_video_mode = z.calling.enum.VideoOrientation.PORTRAIT + else + detected_video_mode = z.calling.enum.VideoOrientation.LANDSCAPE + @video_mode detected_video_mode + @logger.log @logger.levels.INFO, "Video is in '#{detected_video_mode}' mode" + + +# http://stackoverflow.com/questions/28762211/unable-to-mute-html5-video-tag-in-firefox +ko.bindingHandlers.mute_media_element = + update: (element, valueAccessor) -> + element.muted = true if valueAccessor() + + +ko.bindingHandlers.source_stream = + update: (element, valueAccessor) -> + element.srcObject = valueAccessor() diff --git a/app/script/view_model/WarningsViewModel.coffee b/app/script/view_model/WarningsViewModel.coffee new file mode 100644 index 00000000000..000a11cbe44 --- /dev/null +++ b/app/script/view_model/WarningsViewModel.coffee @@ -0,0 +1,110 @@ +# +# 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.ViewModel ?= {} + +# Types for warning banners +z.ViewModel.WarningType = + # Permission requests: dimmed screen, warning bar + REQUEST_CAMERA: 'request_camera' + REQUEST_MICROPHONE: 'request_microphone' + REQUEST_NOTIFICATION: 'request_notification' + REQUEST_SCREEN: 'request_screen' +# Permission callbacks: !dimmed screen, warning bar + DENIED_CAMERA: 'camera_access_denied' + DENIED_MICROPHONE: 'mic_access_denied' + DENIED_SCREEN: 'screen_access_denied' + NOT_FOUND_CAMERA: 'not_found_camera' + NOT_FOUND_MICROPHONE: 'not_found_microphone' + UNSUPPORTED_INCOMING_CALL: 'unsupported_incoming_call' + UNSUPPORTED_OUTGOING_CALL: 'unsupported_outgoing_call' + CONNECTIVITY_RECONNECT: 'connectivity_reconnect' + CONNECTIVITY_RECOVERY: 'connectivity_recovery' + NO_INTERNET: 'no_internet' + +class z.ViewModel.WarningsViewModel + constructor: (element_id) -> + @logger = new z.util.Logger 'z.ViewModel.WarningsViewModel', z.config.LOGGER.OPTIONS + + # Array of warning banners + @warnings = ko.observableArray() + @top_warning = ko.computed => + return @warnings()[@warnings().length - 1] + , @, deferEvaluation: true + @warnings.subscribe (warnings) -> + mini_modes = [ + z.ViewModel.WarningType.CONNECTIVITY_RECONNECT + z.ViewModel.WarningType.NO_INTERNET + ] + if warnings.length is 0 + top_margin = '0' + else if warnings[warnings.length - 1] is z.ViewModel.WarningType.CONNECTIVITY_RECOVERY + top_margin = '0' + else if warnings[warnings.length - 1] in mini_modes + top_margin = '32px' + else + top_margin = '64px' + $('#app').css top: top_margin + requestAnimFrame -> $(window).trigger 'resize' + + @first_name = ko.observable() + @call_id = undefined + + @warning_dimmed = ko.computed => + for warning in @warnings() + return true if warning in [ + z.ViewModel.WarningType.REQUEST_CAMERA + z.ViewModel.WarningType.REQUEST_MICROPHONE + z.ViewModel.WarningType.REQUEST_NOTIFICATION + z.ViewModel.WarningType.REQUEST_SCREEN + ] + return false + , @, deferEvaluation: true + @warning_dimmed.extend rateLimit: 200 + + amplify.subscribe z.event.WebApp.WARNINGS.SHOW, @show_warning + amplify.subscribe z.event.WebApp.WARNINGS.DISMISS, @dismiss_warning + + ko.applyBindings @, document.getElementById element_id + + # Function is used to close a warning banner by clicking on it's close (X) button + close_warning: => + warning_to_remove = @top_warning() + @dismiss_warning warning_to_remove + + switch warning_to_remove + when z.ViewModel.WarningType.REQUEST_MICROPHONE + amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALLING, + action: -> (window.open z.localization.Localizer.get_text z.string.url_support_mic_access_denied)?.focus() + when z.ViewModel.WarningType.REQUEST_NOTIFICATION + # We block subsequent permission requests for notifications when the user ignores the request. + amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.REQUEST_PERMISSION, false + + dismiss_warning: (type) => + type = @top_warning() if not type + @logger.log @logger.levels.WARN, "Dismissed warning of type '#{type}'" + @warnings.remove type + + show_warning: (type, info) => + @dismiss_warning() if @top_warning() and type in [z.ViewModel.WarningType.CONNECTIVITY_RECONNECT, z.ViewModel.WarningType.NO_INTERNET] + @logger.log @logger.levels.WARN, "Showing warning of type '#{type}'" + if info? + @first_name info.first_name + @call_id = info.call_id + @warnings.push type diff --git a/app/script/view_model/WelcomeViewModel.coffee b/app/script/view_model/WelcomeViewModel.coffee new file mode 100644 index 00000000000..1ad9d06da45 --- /dev/null +++ b/app/script/view_model/WelcomeViewModel.coffee @@ -0,0 +1,63 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.WelcomeViewModel + constructor: (element_id, @user_repository) -> + @logger = new z.util.Logger 'z.ViewModel.WelcomeViewModel', z.config.LOGGER.OPTIONS + + @user = @user_repository.self + + @show_welcome = ko.observable false + @uploading_image = ko.observable false + @unsplash_image_loaded = ko.observable false + @disable_keep_button = ko.computed => + @uploading_image() or not @unsplash_image_loaded() + + ko.applyBindings @, document.getElementById element_id + + amplify.subscribe z.event.WebApp.WELCOME.SHOW, => @show_welcome true + amplify.subscribe z.event.WebApp.WELCOME.UNSPLASH_LOADED, => @unsplash_image_loaded true + + close_welcome: => + @show_welcome false + amplify.publish z.event.WebApp.APP.FADE_IN + + if @user_repository.connections().length is 0 + setTimeout -> + amplify.publish z.event.WebApp.SEARCH.SHOW + , 550 + + choose_your_own_picture: (files) => + return if @uploading_image() + + @uploading_image true + amplify.publish z.event.WebApp.PROFILE.UPLOAD_PICTURE, files, (response, error) => + @uploading_image false + @close_welcome() if not error + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ONBOARDING.ADDED_PHOTO, + source: 'gallery' + outcome: if error then 'fail' else 'success' + + keep_this_picture: => + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ONBOARDING.ADDED_PHOTO, + source: 'unsplash' + outcome: 'success' + @close_welcome() diff --git a/app/script/view_model/WindowTitleViewModel.coffee b/app/script/view_model/WindowTitleViewModel.coffee new file mode 100644 index 00000000000..8709462d0d3 --- /dev/null +++ b/app/script/view_model/WindowTitleViewModel.coffee @@ -0,0 +1,68 @@ +# +# 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.ViewModel ?= {} + +class z.ViewModel.WindowTitleViewModel + constructor: (@content, @user_repository, @conversation_repository) -> + @logger = new z.util.Logger 'z.ViewModel.WindowTitleViewModel', z.config.LOGGER.OPTIONS + amplify.subscribe z.event.WebApp.LOADED, @initiate_title_updates + + initiate_title_updates: => + @logger.log @logger.levels.INFO, 'Starting to update window title' + ko.computed => + + window_title = '' + badge_count = 0 + number_of_unread_conversations = 0 + number_of_connect_requests = @user_repository.connect_requests().length + + @conversation_repository.conversations_unarchived().forEach (conversation_et) -> + if conversation_et.type() isnt z.conversation.ConversationType.CONNECT + number_of_unread_conversations++ if conversation_et.number_of_unread_messages() > 0 + + badge_count = number_of_connect_requests + number_of_unread_conversations + + if badge_count > 0 + window_title += "(#{badge_count}) " + + amplify.publish z.event.WebApp.CONVERSATION.UNREAD, badge_count + + switch @content.state() + when z.ViewModel.CONTENT_STATE.PENDING + if number_of_connect_requests > 1 + window_title += z.localization.Localizer.get_text { + id: z.string.conversation_list_many_connection_request + replace: {placeholder: '%no', content: number_of_connect_requests} + } + else + window_title += z.localization.Localizer.get_text z.string.conversation_list_one_connection_request + when z.ViewModel.CONTENT_STATE.CONVERSATION + window_title += @conversation_repository.active_conversation()?.display_name() + when z.ViewModel.CONTENT_STATE.PROFILE + window_title += @user_repository.self().name() + + if window_title is '' + window_title = z.localization.Localizer.get_text z.string.wire + else + window_title += " - #{z.localization.Localizer.get_text z.string.wire}" + + window.document.title = window_title + + .extend rateLimit: 750 diff --git a/app/script/view_model/bindings/CommonBindings.coffee b/app/script/view_model/bindings/CommonBindings.coffee new file mode 100644 index 00000000000..c0539b16c46 --- /dev/null +++ b/app/script/view_model/bindings/CommonBindings.coffee @@ -0,0 +1,287 @@ +# +# 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/. +# + +# use it on the drop area +ko.bindingHandlers.drop_file = + init: (element, valueAccessor, allBindings, data, context) -> + fileDragOver = (data, e) -> + e.preventDefault() + e.currentTarget.classList.add 'drag-hover' + + fileDragLeave = (data, e) -> + e.currentTarget.classList.remove 'drag-hover' + + fileSelectHandler = (data, e) -> + e.preventDefault() + e.currentTarget.classList.remove 'drag-hover' + files = e.dataTransfer?.files or e.originalEvent?.dataTransfer.files + valueAccessor().call @, files if files.length > 0 + + ko.applyBindingsToNode element, + event: + dragover: fileDragOver + dragleave: fileDragLeave + drop: fileSelectHandler + , context + +# blockes the default behaviour when dropping a file on the element +# if an element inside that element is listening to drag events, than this will be triggered after +ko.bindingHandlers.ignore_drop_file = + init: (element, valueAccessor, allBindings, data, context) -> + ko.applyBindingsToNode element, + event: + dragover: (data, e) -> e.preventDefault() + drop: (data, e) -> e.preventDefault() + , context + + +# indicate that the current binding loop should not try to bind this element’s children +# http://www.knockmeout.net/2012/05/quick-tip-skip-binding.html +ko.bindingHandlers.stopBinding = + init: -> + controlsDescendantBindings: true + +ko.virtualElements.allowedBindings.stopBinding = true + +# resize textarea according to the containing text +# Link: http://jsfiddle.net/C8e4w/1/ +# Docu: http://knockoutjs.com/documentation/custom-bindings.html +# +# Example: HTML binding +#