diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..ec4f16fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +* Which version of WMail are you using? [e.g. 1.3.1] + +* Which Operating System are you using? [e.g. Windows 10, Mac OSX 10, Ubuntu 14.04] diff --git a/github_images/gdc-create-credentials.png b/.github/gdc-create-credentials.png similarity index 100% rename from github_images/gdc-create-credentials.png rename to .github/gdc-create-credentials.png diff --git a/github_images/gdc-oauth-client-id-creation.png b/.github/gdc-oauth-client-id-creation.png similarity index 100% rename from github_images/gdc-oauth-client-id-creation.png rename to .github/gdc-oauth-client-id-creation.png diff --git a/.github/screenshot.png b/.github/screenshot.png new file mode 100644 index 00000000..f85b5466 Binary files /dev/null and b/.github/screenshot.png differ diff --git a/github_images/setup1.png b/.github/setup1.png similarity index 100% rename from github_images/setup1.png rename to .github/setup1.png diff --git a/github_images/setup2.png b/.github/setup2.png similarity index 100% rename from github_images/setup2.png rename to .github/setup2.png diff --git a/github_images/setup3.png b/.github/setup3.png similarity index 100% rename from github_images/setup3.png rename to .github/setup3.png diff --git a/.github/wmail_wavebox.gif b/.github/wmail_wavebox.gif new file mode 100644 index 00000000..588216b2 Binary files /dev/null and b/.github/wmail_wavebox.gif differ diff --git a/.gitignore b/.gitignore index 55c87b9c..7a0c6158 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ credentials.js node_modules bin +dist WMail-darwin-x64 WMail-linux-ia32/ WMail-linux-x64/ WMail-win32-ia32/ +WMail-win32-x64/ WMail-win32-ia32-Installer/ *.log diff --git a/.travis.yml b/.travis.yml index fb3eb7d2..816900f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,11 @@ sudo: required install: - sudo add-apt-repository ppa:ubuntu-toolchain-r/test -y - - sudo apt-get update -qq + - sudo apt-get update -qq - if [ "$CXX" = "g++" ]; then sudo apt-get install -qq g++-4.8; fi - if [ "$CXX" = "g++" ]; then export CXX="g++-4.8" CC="gcc-4.8"; fi - npm install -g standard language: node_js node_js: - - "5.5.0" + - "6.2.0" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0b1d8552 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Pull Requests + +By submitting a pull request, you represent that you have the right to license your contribution to us and the community, and agree by submitting the patch that your contributions are licensed under the MPL-2.0 license. + +# Raising a new issue & requesting Features + +Thanks for being an A* tester and helping to make WMail better by either reporting a bug or raising an issue! Before you create a new issue here are a few things that might be worth checking first... + +1. Make sure you're using the [latest version and also check the latest pre-releases](https://github.com/Thomas101/wmail/releases). You may find that your bug has been fixed or feature added in a pre-release. You can find the full list of all releases available to download along with the changelog for each release on the [releases page](https://github.com/Thomas101/wmail/releases) + +2. Take a quick look to see if somebody else has already raised an issue on the [issues page](https://github.com/Thomas101/wmail/issues). It makes it much easier for me to track discussion around a single issue rather than dealing with duplicates + +3. Check the [closed issues with the "waiting release" tag](https://github.com/Thomas101/wmail/issues?q=is%3Aissue+label%3AWaiting-release+is%3Aclosed). These are bugs that have been fixed or features that have been added that haven't quite made it into a release just yet. Once an issue is given the waiting release tag it will make it out into a release shortly + +4. Some commonly raised issues have their own section in the [FAQs](https://github.com/Thomas101/wmail/wiki/FAQs). The answer to your question may be there already! + +Once you're happy that your issue / feature hasn't already been raised or fixed feel free to raise it! diff --git a/LICENSE b/LICENSE index e01decff..a612ad98 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,373 @@ -Copyright (c) 2016, Thomas Beverley -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index 8b81ac18..e4605c09 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,5 @@ -# Wmail +# We've moved! -The missing desktop client for Gmail & Google Inbox. Bringing the Gmail & Google Inbox experience to your desktop in a neatly packaged app +Hi! This repository is no longer being used and has been archived for historical purposes. -[Download the latest release](http://thomas101.github.io/wmail/download) - -[View all releases](https://github.com/Thomas101/wmail/releases) - -[Raise an issue or request a feature](https://github.com/Thomas101/wmail/issues) - -[Find out how you can contribute](https://github.com/Thomas101/wmail/wiki/Contributing) - -![Screenshot](https://raw.githubusercontent.com/Thomas101/wmail/master/github_images/screenshot1.png "Screenshot") - - -### Building from source - -[![Travis Build Status](https://img.shields.io/travis/Thomas101/wmail.svg)](http://travis-ci.org/Thomas101/wmail) -[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) - -Feeling brave and want to build from source? Here's what you need to do - -Firstly you need to get an OAuth client ID and secret from Google. -Visit https://console.developers.google.com to get started. -You'll need to [setup your OAuth Client ID](https://console.developers.google.com/apis/credentials) and enable the [Gmail](https://console.developers.google.com/apis/api/gmail/overview), [Google+](https://console.developers.google.com/apis/api/plus/overview) and [Identity Toolkit](https://console.developers.google.com/apis/api/identitytoolkit/overview) APIs. - -To create OAuth client ID & secret, under "API Manager", choose "Create Credentials", then "OAuth client ID". -For "Application type", select "Other", and choose some name for the application, as described in these screenshots: - -![Create credentials](https://raw.githubusercontent.com/Thomas101/wmail/master/github_images/gdc-create-credentials.png "Create Credentials") -![Create OAuth client ID](https://raw.githubusercontent.com/Thomas101/wmail/master/github_images/gdc-oauth-client-id-creation.png "Create OAuth Client ID") - -Next create `src/shared/credentials.js` with your Google client ID and secret like so... - -```js -module.exports = Object.freeze({ - GOOGLE_CLIENT_ID : '', - GOOGLE_CLIENT_SECRET: '' -}) -``` - -Then run the following... - -``` -npm run-script install-all -npm start -``` - - -Made with ♥ by Thomas Beverley. [Buy me a beer](https://www.paypal.me/ThomasBeverley) 🍺 +Find out more at [https://wavebox.io](https://wavebox.io/) diff --git a/assets/fonts/fontawesome/FontAwesome.otf b/assets/fonts/fontawesome/FontAwesome.otf new file mode 100644 index 00000000..401ec0f3 Binary files /dev/null and b/assets/fonts/fontawesome/FontAwesome.otf differ diff --git a/assets/fonts/fontawesome/font-awesome.min.css b/assets/fonts/fontawesome/font-awesome.min.css new file mode 100644 index 00000000..3c8fdbbd --- /dev/null +++ b/assets/fonts/fontawesome/font-awesome.min.css @@ -0,0 +1,4 @@ +/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url('./fontawesome-webfont.eot?v=4.7.0');src:url('./fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('./fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('./fontawesome-webfont.woff?v=4.7.0') format('woff'),url('./fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('./fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} diff --git a/assets/fonts/fontawesome/fontawesome-webfont.eot b/assets/fonts/fontawesome/fontawesome-webfont.eot new file mode 100644 index 00000000..e9f60ca9 Binary files /dev/null and b/assets/fonts/fontawesome/fontawesome-webfont.eot differ diff --git a/assets/fonts/fontawesome/fontawesome-webfont.svg b/assets/fonts/fontawesome/fontawesome-webfont.svg new file mode 100644 index 00000000..855c845e --- /dev/null +++ b/assets/fonts/fontawesome/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/fontawesome/fontawesome-webfont.ttf b/assets/fonts/fontawesome/fontawesome-webfont.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/assets/fonts/fontawesome/fontawesome-webfont.ttf differ diff --git a/assets/fonts/fontawesome/fontawesome-webfont.woff b/assets/fonts/fontawesome/fontawesome-webfont.woff new file mode 100644 index 00000000..400014a4 Binary files /dev/null and b/assets/fonts/fontawesome/fontawesome-webfont.woff differ diff --git a/assets/fonts/fontawesome/fontawesome-webfont.woff2 b/assets/fonts/fontawesome/fontawesome-webfont.woff2 new file mode 100644 index 00000000..4d13fc60 Binary files /dev/null and b/assets/fonts/fontawesome/fontawesome-webfont.woff2 differ diff --git a/assets/icons/app.icns b/assets/icons/app.icns index 422023df..2668faad 100644 Binary files a/assets/icons/app.icns and b/assets/icons/app.icns differ diff --git a/assets/icons/app.ico b/assets/icons/app.ico index 069a2785..6696202b 100644 Binary files a/assets/icons/app.ico and b/assets/icons/app.ico differ diff --git a/assets/icons/app.png b/assets/icons/app.png old mode 100644 new mode 100755 index 0c74bf82..c9f21d60 Binary files a/assets/icons/app.png and b/assets/icons/app.png differ diff --git a/assets/icons/app.svg b/assets/icons/app.svg new file mode 100644 index 00000000..104fc720 --- /dev/null +++ b/assets/icons/app.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/app_128.png b/assets/icons/app_128.png new file mode 100755 index 00000000..356007c3 Binary files /dev/null and b/assets/icons/app_128.png differ diff --git a/assets/icons/app_16.png b/assets/icons/app_16.png new file mode 100755 index 00000000..f40b90a7 Binary files /dev/null and b/assets/icons/app_16.png differ diff --git a/assets/icons/app_24.png b/assets/icons/app_24.png new file mode 100755 index 00000000..60b69003 Binary files /dev/null and b/assets/icons/app_24.png differ diff --git a/assets/icons/app_256.png b/assets/icons/app_256.png new file mode 100755 index 00000000..f2e22515 Binary files /dev/null and b/assets/icons/app_256.png differ diff --git a/assets/icons/app_32.png b/assets/icons/app_32.png new file mode 100755 index 00000000..71a7ae72 Binary files /dev/null and b/assets/icons/app_32.png differ diff --git a/assets/icons/app_48.png b/assets/icons/app_48.png new file mode 100755 index 00000000..f7be1eb7 Binary files /dev/null and b/assets/icons/app_48.png differ diff --git a/assets/icons/app_512.png b/assets/icons/app_512.png new file mode 100755 index 00000000..c9f21d60 Binary files /dev/null and b/assets/icons/app_512.png differ diff --git a/assets/icons/app_64.png b/assets/icons/app_64.png new file mode 100755 index 00000000..48036e43 Binary files /dev/null and b/assets/icons/app_64.png differ diff --git a/assets/icons/app_96.png b/assets/icons/app_96.png new file mode 100755 index 00000000..d02f0642 Binary files /dev/null and b/assets/icons/app_96.png differ diff --git a/assets/images/ginbox_icon_512.png b/assets/images/ginbox_icon_512.png new file mode 100644 index 00000000..686e0441 Binary files /dev/null and b/assets/images/ginbox_icon_512.png differ diff --git a/assets/images/ginbox_mode_full_small.png b/assets/images/ginbox_mode_full_small.png new file mode 100644 index 00000000..cf24ed2f Binary files /dev/null and b/assets/images/ginbox_mode_full_small.png differ diff --git a/assets/images/ginbox_mode_inbox.png b/assets/images/ginbox_mode_inbox.png new file mode 100644 index 00000000..96752668 Binary files /dev/null and b/assets/images/ginbox_mode_inbox.png differ diff --git a/assets/images/ginbox_mode_unreadunbundled.png b/assets/images/ginbox_mode_unreadunbundled.png new file mode 100644 index 00000000..008271a8 Binary files /dev/null and b/assets/images/ginbox_mode_unreadunbundled.png differ diff --git a/assets/images/ginbox_mode_zero_small.png b/assets/images/ginbox_mode_zero_small.png new file mode 100644 index 00000000..6b22eeec Binary files /dev/null and b/assets/images/ginbox_mode_zero_small.png differ diff --git a/assets/images/gmail_icon_512.png b/assets/images/gmail_icon_512.png new file mode 100644 index 00000000..20533e70 Binary files /dev/null and b/assets/images/gmail_icon_512.png differ diff --git a/assets/images/gmail_inbox_categories_small.png b/assets/images/gmail_inbox_categories_small.png new file mode 100644 index 00000000..8280907e Binary files /dev/null and b/assets/images/gmail_inbox_categories_small.png differ diff --git a/assets/images/gmail_inbox_priority_small.png b/assets/images/gmail_inbox_priority_small.png new file mode 100644 index 00000000..496e7efe Binary files /dev/null and b/assets/images/gmail_inbox_priority_small.png differ diff --git a/assets/images/gmail_inbox_unread_small.png b/assets/images/gmail_inbox_unread_small.png new file mode 100644 index 00000000..36d9e49c Binary files /dev/null and b/assets/images/gmail_inbox_unread_small.png differ diff --git a/assets/images/google_services/logo_calendar_128px.png b/assets/images/google_services/logo_calendar_128px.png new file mode 100644 index 00000000..0adf29c1 Binary files /dev/null and b/assets/images/google_services/logo_calendar_128px.png differ diff --git a/assets/images/google_services/logo_calendar_32px.png b/assets/images/google_services/logo_calendar_32px.png new file mode 100644 index 00000000..b9c9819c Binary files /dev/null and b/assets/images/google_services/logo_calendar_32px.png differ diff --git a/assets/images/google_services/logo_calendar_48px.png b/assets/images/google_services/logo_calendar_48px.png new file mode 100644 index 00000000..bb50bb96 Binary files /dev/null and b/assets/images/google_services/logo_calendar_48px.png differ diff --git a/assets/images/google_services/logo_calendar_64px.png b/assets/images/google_services/logo_calendar_64px.png new file mode 100644 index 00000000..2f696e13 Binary files /dev/null and b/assets/images/google_services/logo_calendar_64px.png differ diff --git a/assets/images/google_services/logo_contacts_128px.png b/assets/images/google_services/logo_contacts_128px.png new file mode 100644 index 00000000..62092e87 Binary files /dev/null and b/assets/images/google_services/logo_contacts_128px.png differ diff --git a/assets/images/google_services/logo_contacts_32px.png b/assets/images/google_services/logo_contacts_32px.png new file mode 100755 index 00000000..1afc3031 Binary files /dev/null and b/assets/images/google_services/logo_contacts_32px.png differ diff --git a/assets/images/google_services/logo_contacts_48px.png b/assets/images/google_services/logo_contacts_48px.png new file mode 100755 index 00000000..43bb7563 Binary files /dev/null and b/assets/images/google_services/logo_contacts_48px.png differ diff --git a/assets/images/google_services/logo_contacts_64px.png b/assets/images/google_services/logo_contacts_64px.png new file mode 100755 index 00000000..d2af6967 Binary files /dev/null and b/assets/images/google_services/logo_contacts_64px.png differ diff --git a/assets/images/google_services/logo_drive_128px.png b/assets/images/google_services/logo_drive_128px.png new file mode 100644 index 00000000..0eb48a74 Binary files /dev/null and b/assets/images/google_services/logo_drive_128px.png differ diff --git a/assets/images/google_services/logo_drive_32px.png b/assets/images/google_services/logo_drive_32px.png new file mode 100644 index 00000000..88426400 Binary files /dev/null and b/assets/images/google_services/logo_drive_32px.png differ diff --git a/assets/images/google_services/logo_drive_48px.png b/assets/images/google_services/logo_drive_48px.png new file mode 100644 index 00000000..ff595ca1 Binary files /dev/null and b/assets/images/google_services/logo_drive_48px.png differ diff --git a/assets/images/google_services/logo_drive_64px.png b/assets/images/google_services/logo_drive_64px.png new file mode 100644 index 00000000..9795bad6 Binary files /dev/null and b/assets/images/google_services/logo_drive_64px.png differ diff --git a/assets/images/google_services/logo_hangouts_128px.png b/assets/images/google_services/logo_hangouts_128px.png new file mode 100644 index 00000000..7c510ad2 Binary files /dev/null and b/assets/images/google_services/logo_hangouts_128px.png differ diff --git a/assets/images/google_services/logo_hangouts_16px.png b/assets/images/google_services/logo_hangouts_16px.png new file mode 100644 index 00000000..bae4601c Binary files /dev/null and b/assets/images/google_services/logo_hangouts_16px.png differ diff --git a/assets/images/google_services/logo_hangouts_24px.png b/assets/images/google_services/logo_hangouts_24px.png new file mode 100644 index 00000000..7796d428 Binary files /dev/null and b/assets/images/google_services/logo_hangouts_24px.png differ diff --git a/assets/images/google_services/logo_hangouts_32px.png b/assets/images/google_services/logo_hangouts_32px.png new file mode 100644 index 00000000..30619659 Binary files /dev/null and b/assets/images/google_services/logo_hangouts_32px.png differ diff --git a/assets/images/google_services/logo_hangouts_48px.png b/assets/images/google_services/logo_hangouts_48px.png new file mode 100644 index 00000000..31ee0a8e Binary files /dev/null and b/assets/images/google_services/logo_hangouts_48px.png differ diff --git a/assets/images/google_services/logo_hangouts_64px.png b/assets/images/google_services/logo_hangouts_64px.png new file mode 100644 index 00000000..c67c9a3c Binary files /dev/null and b/assets/images/google_services/logo_hangouts_64px.png differ diff --git a/assets/images/google_services/logo_keep_128px.png b/assets/images/google_services/logo_keep_128px.png new file mode 100644 index 00000000..4e22ce2b Binary files /dev/null and b/assets/images/google_services/logo_keep_128px.png differ diff --git a/assets/images/google_services/logo_keep_32px.png b/assets/images/google_services/logo_keep_32px.png new file mode 100644 index 00000000..f1ebe79b Binary files /dev/null and b/assets/images/google_services/logo_keep_32px.png differ diff --git a/assets/images/google_services/logo_keep_48px.png b/assets/images/google_services/logo_keep_48px.png new file mode 100644 index 00000000..fe61f2b9 Binary files /dev/null and b/assets/images/google_services/logo_keep_48px.png differ diff --git a/assets/images/google_services/logo_keep_64px.png b/assets/images/google_services/logo_keep_64px.png new file mode 100644 index 00000000..87b1c7f8 Binary files /dev/null and b/assets/images/google_services/logo_keep_64px.png differ diff --git a/assets/images/mailbox_right_click_settings.png b/assets/images/mailbox_right_click_settings.png new file mode 100644 index 00000000..2665d9fc Binary files /dev/null and b/assets/images/mailbox_right_click_settings.png differ diff --git a/assets/webpack.config.js b/assets/webpack.config.js index bc470eee..b460bcc8 100644 --- a/assets/webpack.config.js +++ b/assets/webpack.config.js @@ -19,7 +19,8 @@ module.exports = { }), new CopyWebpackPlugin([ { from: path.join(__dirname, 'fonts'), to: 'fonts', force: true }, - { from: path.join(__dirname, 'icons'), to: 'icons', force: true } + { from: path.join(__dirname, 'icons'), to: 'icons', force: true }, + { from: path.join(__dirname, 'images'), to: 'images', force: true } ], { ignore: [ '.DS_Store' ] }) diff --git a/github_images/screenshot1.png b/github_images/screenshot1.png deleted file mode 100644 index aefcea0b..00000000 Binary files a/github_images/screenshot1.png and /dev/null differ diff --git a/github_images/screenshot2.png b/github_images/screenshot2.png deleted file mode 100644 index 4982261d..00000000 Binary files a/github_images/screenshot2.png and /dev/null differ diff --git a/github_images/screenshot3.png b/github_images/screenshot3.png deleted file mode 100644 index 27937fbb..00000000 Binary files a/github_images/screenshot3.png and /dev/null differ diff --git a/package.json b/package.json index 30ee7811..13c3ef02 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,60 @@ { "name": "wmail", - "version": "1.3.1", - "prerelease": false, - "description": "The missing desktop client for Gmail & Google Inbox", + "version": "2.3.1", + "prerelease": true, + "description": "The missing desktop client for Gmail and Google Inbox", "scripts": { "prestart": "webpack", "start": "electron bin/app/index.js", "test": "standard", - "package:darwin": "node packager.js darwin", - "package:linux": "node packager.js linux", - "package:win32": "node packager.js win32", - "package": "node packager.js darwin && node packager.js linux && node packager.js win32", - "postinstall": "electron-rebuild", - "install-all": "echo ':>wmail'; npm install; cd src/app; echo ':>wmail-app'; npm install; cd ../../src/scenes/mailboxes; echo ':>wmail-scenes-mailboxes'; npm install", - "outdated-all": "echo ':>wmail'; npm outdated; cd src/app; echo ':>wmail-app'; npm outdated; cd ../../src/scenes/mailboxes; echo ':>wmail-scenes-mailboxes'; npm outdated" + "install-all": "echo ':wmail' && npm install && cd src/app && echo ':wmail-app' && npm install && cd ../../src/scenes/mailboxes && echo ':wmail-scenes-mailboxes' && npm install", + "outdated-all": "echo ':wmail' && npm outdated && cd src/app && echo ':wmail-app' && npm outdated && cd ../../src/scenes/mailboxes && echo ':wmail-scenes-mailboxes' && npm outdated", + "dev:platform": "webpack --task=platform && electron bin/app/index.js", + "dev:app": "webpack --task=app && electron bin/app/index.js", + "dev:mailboxes": "webpack --task=mailboxes && electron bin/app/index.js", + "dev:assets": "webpack --task=assets && electron bin/app/index.js", + "dev:run": "electron bin/app/index.js" }, "keywords": [], - "author": "Thomas Beverley", - "license": "BSD-2-Clause", + "author": { + "name": "Thomas Beverley", + "email": "tom.beverley.wmail@gmail.com", + "url": "https://github.com/Thomas101/" + }, + "homepage": "https://thomas101.github.io/wmail/", + "license": "MPL-2.0", "repository": "https://github.com/Thomas101/wmail", "main": "bin/app/index.js", "dependencies": { - "babel": "6.5.2", - "babel-core": "6.9.1", - "babel-loader": "6.2.4", - "babel-preset-es2015": "6.9.0", - "babel-preset-react": "6.5.0", - "babel-preset-stage-0": "6.5.0", - "clean-webpack-plugin": "0.1.9", - "copy-webpack-plugin": "3.0.1", - "css-loader": "0.23.1", - "electron-packager": "7.0.2", - "electron-prebuilt": "1.2.0", - "electron-rebuild": "1.1.5", - "electron-winstaller": "2.3.0", + "babel": "6.23.0", + "babel-core": "6.23.1", + "babel-loader": "6.3.2", + "babel-preset-es2015": "6.22.0", + "babel-preset-react": "6.23.0", + "babel-preset-stage-0": "6.22.0", + "clean-webpack-plugin": "0.1.15", + "copy-webpack-plugin": "4.0.1", + "css-loader": "0.26.1", + "electron": "1.6.1", "extract-text-webpack-plugin": "1.0.1", - "file-loader": "0.8.5", + "file-loader": "0.10.0", + "json-loader": "0.5.4", "jsx-loader": "0.13.2", - "less": "2.7.1", + "less": "2.7.2", "less-loader": "2.2.3", - "nlf": "1.4.0", "style-loader": "0.13.1", + "uglify-js": "mishoo/UglifyJS2#3ee46e91e802fb8bf20656bce115375c5f624052", "url-loader": "0.5.7", - "webpack": "1.13.1", + "uuid": "3.0.1", + "webpack": "1.14.0", "webpack-target-electron-renderer": "0.4.0" }, "devDependencies": { - "standard": "^7.1.1" + "standard": "8.6.0" + }, + "standard": { + "ignore": [ + "src/app/lib/" + ] } } diff --git a/packager.js b/packager.js deleted file mode 100644 index 25f10685..00000000 --- a/packager.js +++ /dev/null @@ -1,183 +0,0 @@ -'use strict' -const packager = require('electron-packager') -const pkg = require('./package.json') -const fs = require('fs-extra') -const childProcess = require('child_process') -const path = require('path') -const nlf = require('nlf') -const platform = process.argv[2] || 'darwin' - -class PackageBuilder { - - /* **************************************************************************/ - // Build tasks - /* **************************************************************************/ - - buildWebpack () { - return new Promise((resolve, reject) => { - console.log('[START] Webpack') - const cmd = 'node node_modules/webpack/bin/webpack.js -p' - const args = {maxBuffer: 1024 * 1024} // Give ourselves a meg of buffer. Webpack can be very verbose - childProcess.exec(cmd, args, (error, stdout, stderr) => { - if (error) { console.error(error) } - if (stdout) { console.log(`stdout: ${stdout}`) } - if (stderr) { console.log(`stderr: ${stderr}`) } - - if (error) { - reject() - } else { - console.log('[FINISH] Webpack') - resolve() - } - }) - }) - } - - packageApp () { - return new Promise((resolve, reject) => { - console.log('[START] Package') - packager({ - dir: '.', - name: 'WMail', - platform: platform, - arch: (platform === 'win32' ? 'ia32' : 'all'), - version: pkg.dependencies['electron-prebuilt'], - 'app-bundle-id': 'tombeverley.wmail', - 'app-version': pkg.version, - 'app-copyright': 'Copyright ' + pkg.author + '(' + pkg.license + ' License)', - icon: 'assets/icons/app', - overwrite: true, - asar: true, - prune: false, - 'version-string': { - CompanyName: pkg.author, - FileDescription: pkg.description, - OriginalFilename: pkg.name, - ProductName: 'WMail' - }, - ignore: '^(' + [ - // Folders - '/assets', - '/github_images', - '/node_modules', - '/release', - '/src', - - // Files - '/.editorconfig', - '/.gitignore', - '/.travis.yml', - '/.LICENSE', - '/.npm-debug.log', - '/packager.js', - '/README.md', - '/webpack.config.js', - - // Output folders - '/WMail-linux-ia32', - '/WMail-linux-x64', - '/WMail-win32-ia32', - '/WMail-win32-ia32-Installer' - ] - .join('|') + ')' - }, function (err, appPath) { - if (err) { - reject(err) - } else { - console.log('[FINISH] Package') - resolve() - } - }) - }) - } - - moveLicenses (outputPath) { - return new Promise((resolve, reject) => { - console.log('[START] License Copy') - const J = path.join - - fs.mkdirsSync(J(outputPath, 'vendor-licenses')) - fs.unlinkSync(J(outputPath, 'version')) - fs.move(J(outputPath, 'LICENSES.chromium.html'), J(outputPath, 'vendor-licenses/LICENSES.chromium.html'), () => { - fs.move(J(outputPath, 'LICENSE'), J(outputPath, 'vendor-licenses/LICENSE.electron'), () => { - nlf.find({ directory: '.', production: true }, function (err, data) { - if (err) { - reject(err) - } else { - data.map((item) => { - const name = item.name - if (item.licenseSources.license.sources.length) { - const path = item.licenseSources.license.sources[0].filePath - fs.copySync(path, J(outputPath, 'vendor-licenses/LICENSE.' + name)) - } - }) - - fs.copySync('./LICENSE', J(outputPath, 'LICENSE')) - console.log('[FINISH] License Copy') - resolve() - } - }) - }) - }) - }) - } - - pruneNPM () { - return new Promise((resolve, reject) => { - console.log('[START] Prune NPM') - const cmd = 'cd src/app; npm prune --production' - const args = {maxBuffer: 1024 * 1024} - childProcess.exec(cmd, args, (error, stdout, stderr) => { - if (error) { console.error(error) } - if (stdout) { console.log(`stdout: ${stdout}`) } - if (stderr) { console.log(`stderr: ${stderr}`) } - - if (error) { - reject() - } else { - console.log('[FINISH] Prune NPM') - resolve() - } - }) - }) - } - - /* **************************************************************************/ - // Start stop - /* **************************************************************************/ - - start () { - const start = new Date().getTime() - console.log('[START] Packing for ' + platform) - return Promise.resolve() - .then(this.pruneNPM) - .then(this.buildWebpack) - .then(this.packageApp) - .then(() => { - if (platform === 'darwin') { - fs.copySync('./release/Installing on OSX.html', './WMail-darwin-x64/Installing on OSX.html') - return Promise.resolve() - .then(() => this.moveLicenses('./WMail-darwin-x64/')) - } else if (platform === 'linux') { - return Promise.resolve() - .then(() => this.moveLicenses('./WMail-linux-ia32/')) - .then(() => this.moveLicenses('./WMail-linux-x64/')) - } else if (platform === 'win32') { - return this.moveLicenses('./WMail-win32-ia32/') - } else { - return Promise.reject() - } - }) - .then(() => { - console.log(((new Date().getTime() - start) / 1000) + 's') - console.log('[EXIT] Done') - }, (err) => { - console.log('[EXIT] Error') - console.log(err) - console.log(err.stack) - }) - } -} - -const builder = new PackageBuilder() -builder.start() diff --git a/release/Installing on OSX.html b/release/Installing on OSX.html deleted file mode 100644 index 85dff76a..00000000 --- a/release/Installing on OSX.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - Installing on OSX - - \ No newline at end of file diff --git a/src/app/package.json b/src/app/package.json index 7ff7208d..1142d615 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -2,25 +2,32 @@ "name": "wmail-app", "keywords": [], "author": "Thomas Beverley", - "license": "BSD-2-Clause", + "license": "MPL-2.0", "repository": "https://github.com/Thomas101/wmail", "description": "The mail app code for the WMail app", "dependencies": { "appdirectory": "0.1.0", - "compare-version": "0.1.2", - "dictionary-en-us": "1.0.2", - "dom-storage": "2.0.2", - "fs-extra": "0.30.0", - "googleapis": "7.1.0", + "dictionary-en-us": "1.2.0", + "escape-html": "1.0.3", + "fs-extra": "2.0.0", + "gmail-js": "0.6.8", + "googleapis": "16.1.0", + "home-dir": "1.0.0", "https-proxy-agent": "1.0.0", + "jquery": "3.1.1", "minivents": "2.0.2", "mkdirp": "0.5.1", - "node-fetch": "1.5.3", - "nodehun": "2.0.10", - "os-locale": "1.4.0", - "uuid": "2.0.2" + "node-fetch": "1.6.3", + "os-locale": "2.0.0", + "request": "2.79.0", + "unused-filename": "0.1.0", + "uuid": "3.0.1", + "windows-shortcuts": "Thomas101/windows-shortcuts#0.1.4", + "wmail-spellchecker": "Thomas101/wmail-spellchecker#1.0.5", + "write-file-atomic": "1.3.1", + "yargs": "6.6.0" }, "devDependencies": { - "standard": "7.1.1" + "standard": "8.6.0" } } diff --git a/src/app/src/app/AppAnalytics.js b/src/app/src/app/AppAnalytics.js index a3698733..a7677c82 100644 --- a/src/app/src/app/AppAnalytics.js +++ b/src/app/src/app/AppAnalytics.js @@ -5,6 +5,7 @@ const osLanguage = require('os-locale').sync().replace(/_/g, '-').toLowerCase() const pkg = require('../package.json') const HttpsProxyAgent = require('https-proxy-agent') const settingStore = require('./stores/settingStore') +const mailboxStore = require('./stores/mailboxStore') const appStorage = require('./storage/appStorage') class AppAnalytics { @@ -40,10 +41,12 @@ class AppAnalytics { v: 1, tid: credentials.GOOGLE_ANALYTICS_ID, cid: this.id, + cd1: mailboxStore.index.length, t: 'screenview', vp: windowSize, ul: osLanguage, an: pkg.name, + ua: window.webContents.getUserAgent(), av: process.platform + '-' + pkg.version }, args) diff --git a/src/app/src/app/AppPrimaryMenu.js b/src/app/src/app/AppPrimaryMenu.js index 084a088b..87da8421 100644 --- a/src/app/src/app/AppPrimaryMenu.js +++ b/src/app/src/app/AppPrimaryMenu.js @@ -36,41 +36,48 @@ class AppPrimaryMenu { { type: 'separator' }, process.platform === 'darwin' ? { label: 'Services', role: 'services', submenu: [] } : undefined, process.platform === 'darwin' ? { type: 'separator' } : undefined, - { label: 'Show Window', accelerator: 'Command+N', click: this._selectors.showWindow }, - { label: 'Hide Window', accelerator: 'Command+W', click: this._selectors.closeWindow }, + { label: 'Show Window', accelerator: 'CmdOrCtrl+N', click: this._selectors.showWindow }, + { label: 'Hide Window', accelerator: 'CmdOrCtrl+W', click: this._selectors.closeWindow }, { label: 'Hide', accelerator: 'CmdOrCtrl+H', role: 'hide' }, - { label: 'Hide Others', accelerator: 'Alt+CmdOrCtrl+H', role: 'hideothers' }, + { label: 'Hide Others', accelerator: process.platform === 'darwin' ? 'Command+Alt+H' : 'Ctrl+Shift+H', role: 'hideothers' }, { label: 'Show All', role: 'unhide' }, { type: 'separator' }, - { label: 'Quit', accelerator: 'Command+Q', click: this._selectors.fullQuit } + { label: 'Quit', accelerator: 'CmdOrCtrl+Q', click: this._selectors.fullQuit } ].filter((item) => item !== undefined) }, { label: 'Edit', submenu: [ - { label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' }, - { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' }, + { label: 'Undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' }, + { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' }, { type: 'separator' }, - { label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' }, - { label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, - { label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, - { label: 'Paste and match style', accelerator: 'Command+Shift+V', selector: 'pasteAndMatchStyle:' }, - { label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' } + { label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' }, + { label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' }, + { label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' }, + { label: 'Paste and match style', accelerator: 'CmdOrCtrl+Shift+V', role: 'pasteandmatchstyle' }, + { label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectall' }, + { type: 'separator' }, + { label: 'Find', accelerator: 'CmdOrCtrl+F', click: this._selectors.find }, + { label: 'Find Next', accelerator: 'CmdOrCtrl+G', click: this._selectors.findNext } ] }, { label: 'View', submenu: [ - { label: 'Toggle Full Screen', accelerator: 'Ctrl+Command+F', click: this._selectors.fullscreenToggle }, - { label: 'Toggle Sidebar', accelerator: 'Ctrl+Command+S', click: this._selectors.sidebarToggle }, + { label: 'Toggle Full Screen', accelerator: process.platform === 'darwin' ? 'Ctrl+Command+F' : 'F11', click: this._selectors.fullscreenToggle }, + { label: 'Toggle Sidebar', accelerator: (process.platform === 'darwin' ? 'Ctrl+Command+S' : 'Ctrl+Shift+S'), click: this._selectors.sidebarToggle }, process.platform === 'darwin' ? undefined : { label: 'Toggle Menu', accelerator: 'CmdOrCtrl+\\', click: this._selectors.menuToggle }, { type: 'separator' }, + { label: 'Navigate Back', accelerator: 'CmdOrCtrl+[', click: this._selectors.mailboxNavBack }, + { label: 'Navigate Back', accelerator: 'CmdOrCtrl+Left', click: this._selectors.mailboxNavBack }, + { label: 'Navigate Forward', accelerator: 'CmdOrCtrl+]', click: this._selectors.mailboxNavForward }, + { type: 'separator' }, { label: 'Zoom Mailbox In', accelerator: 'CmdOrCtrl+Plus', click: this._selectors.zoomIn }, { label: 'Zoom Mailbox Out', accelerator: 'CmdOrCtrl+-', click: this._selectors.zoomOut }, - { label: 'Reset Mailbox Zoom', click: this._selectors.zoomReset }, + { label: 'Reset Mailbox Zoom', accelerator: 'CmdOrCtrl+0', click: this._selectors.zoomReset }, { type: 'separator' }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: this._selectors.reload }, - { label: 'Developer Tools', accelerator: 'Alt+CmdOrCtrl+J', click: this._selectors.devTools } + { label: 'Developer Tools', accelerator: process.platform === 'darwin' ? 'Cmd+Alt+J' : 'Ctrl+Shift+J', click: this._selectors.devTools } ].filter((item) => item !== undefined) }, { @@ -80,16 +87,23 @@ class AppPrimaryMenu { { label: 'Minimize', accelerator: 'CmdOrCtrl+M', role: 'minimize' }, { label: 'Cycle Windows', accelerator: 'CmdOrCtrl+`', click: this._selectors.cycleWindows } ] - .concat(mailboxes.length ? [{ type: 'separator' }] : []) - .concat(mailboxes.map((mailbox, index) => { - return { label: mailbox.email || 'Untitled', accelerator: 'CmdOrCtrl+' + (index + 1), click: () => { this._selectors.mailbox(mailbox.id) } } + .concat(mailboxes.length <= 1 ? [] : [ + { type: 'separator' }, + { label: 'Previous Mailbox', accelerator: 'CmdOrCtrl+<', click: this._selectors.prevMailbox }, + { label: 'Next Mailbox', accelerator: 'CmdOrCtrl+>', click: this._selectors.nextMailbox }, + { type: 'separator' } + ]) + .concat(mailboxes.length <= 1 ? [] : mailboxes.map((mailbox, index) => { + return { label: mailbox.email || 'Untitled', accelerator: 'CmdOrCtrl+' + (index + 1), click: () => { this._selectors.changeMailbox(mailbox.id) } } })) }, { label: 'Help', role: 'help', submenu: [ - { label: 'Project Homepage', click: this._selectors.learnMore }, + { label: 'WMail Website', click: this._selectors.learnMore }, + { label: 'Privacy', click: this._selectors.privacy }, + { label: 'WMail on GitHub', click: this._selectors.learnMoreGithub }, { label: 'Report a Bug', click: this._selectors.bugReport } ] } @@ -125,6 +139,18 @@ class AppPrimaryMenu { } } } + + /* ****************************************************************************/ + // Click handlers + /* ****************************************************************************/ + + changeToPrevMailbox () { + + } + + changeToNextMailbox () { + + } } module.exports = AppPrimaryMenu diff --git a/src/app/src/app/AuthGoogle.js b/src/app/src/app/AuthGoogle.js index 67fd02e9..010ca3e0 100644 --- a/src/app/src/app/AuthGoogle.js +++ b/src/app/src/app/AuthGoogle.js @@ -1,9 +1,6 @@ const {ipcMain, BrowserWindow} = require('electron') const googleapis = require('googleapis') -const fetch = require('node-fetch') const credentials = require('../shared/credentials') -const HttpsProxyAgent = require('https-proxy-agent') -const settingStore = require('./stores/settingStore') const APP_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' @@ -88,37 +85,6 @@ class AuthGoogle { }) } - /** - * Gets the permenant access token from an auth code - * @param authCode: the auth code to elevate - * @return promise - */ - getPermenantAccessTokenFromAuthCode (authCode) { - const proxyAgent = settingStore.proxy.enabled ? new HttpsProxyAgent(settingStore.proxy.url) : undefined - const query = { - code: authCode, - client_id: credentials.GOOGLE_CLIENT_ID, - client_secret: credentials.GOOGLE_CLIENT_SECRET, - grant_type: 'authorization_code', - redirect_uri: APP_REDIRECT_URI - } - const payload = Object.keys(query) - .map((key) => `${key}=${encodeURIComponent(query[key])}`) - .join('&') - - return Promise.resolve() - .then(() => fetch('https://accounts.google.com/o/oauth2/token', { - method: 'post', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: payload, - agent: proxyAgent - })) - .then((res) => res.json()) - } - /* ****************************************************************************/ // Request Handlers /* ****************************************************************************/ @@ -131,17 +97,18 @@ class AuthGoogle { handleAuthGoogle (evt, body) { Promise.resolve() .then(() => this.promptUserToGetAuthorizationCode(body.id)) - .then((authCode) => this.getPermenantAccessTokenFromAuthCode(authCode)) - .then((auth) => { + .then((authCode) => { evt.sender.send('auth-google-complete', { id: body.id, type: body.type, - auth: auth + mode: body.mode, + temporaryAuth: authCode }) }, (err) => { evt.sender.send('auth-google-error', { id: body.id, type: body.type, + mode: body.mode, error: err, errorString: (err || {}).toString ? (err || {}).toString() : undefined, errorMessage: (err || {}).message ? (err || {}).message : undefined, diff --git a/src/app/src/app/AuthHTTP.html b/src/app/src/app/AuthHTTP.html new file mode 100644 index 00000000..37deb391 --- /dev/null +++ b/src/app/src/app/AuthHTTP.html @@ -0,0 +1,98 @@ + + + + + WMail Authentication Required + + + + + +

WMail Authentication Required

+

+ requires a username and password +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + diff --git a/src/app/src/app/AuthHTTP.js b/src/app/src/app/AuthHTTP.js new file mode 100644 index 00000000..ac45abc7 --- /dev/null +++ b/src/app/src/app/AuthHTTP.js @@ -0,0 +1,30 @@ +const {BrowserWindow} = require('electron') + +class AuthHTTP { + + /* ****************************************************************************/ + // Lifecycle + /* ****************************************************************************/ + + /** + * @param callback: callback to execute with username and password + */ + constructor (url, callback) { + this.window = new BrowserWindow({ + width: 400, + height: 300, + frame: false, + center: true, + show: true, + resizable: false, + alwaysOnTop: true, + autoHideMenuBar: true + }) + this.window.loadURL(`file://${__dirname}/AuthHTTP.html`) + this.window.webContents.on('did-finish-load', () => { + this.window.webContents.send('requestor', url) + }) + } +} + +module.exports = AuthHTTP diff --git a/src/app/src/app/KeyboardShortcuts.js b/src/app/src/app/KeyboardShortcuts.js new file mode 100644 index 00000000..954f1208 --- /dev/null +++ b/src/app/src/app/KeyboardShortcuts.js @@ -0,0 +1,49 @@ +const {globalShortcut} = require('electron') + +/* + * KeyboardShortcuts registers additional keyboard shortcuts. + * Note that most keyboard shortcuts are configured with the AppPrimaryMenu. + */ +class KeyboardShortcuts { + + /* ****************************************************************************/ + // Lifecycle + /* ****************************************************************************/ + + constructor (selectors) { + this._selectors = selectors + this._shortcuts = [] + } + + /* ****************************************************************************/ + // Creating + /* ****************************************************************************/ + + /** + * Registers global keyboard shortcuts. + */ + register () { + let shortcuts = new Map([ + ['CmdOrCtrl+{', this._selectors.prevMailbox], + ['CmdOrCtrl+}', this._selectors.nextMailbox] + ]) + this.unregister() + shortcuts.forEach((callback, accelerator) => { + globalShortcut.register(accelerator, callback) + this._shortcuts.push(accelerator) + }) + } + + /** + * Unregisters any previously registered global keyboard shortcuts. + */ + unregister () { + this._shortcuts.forEach((accelerator) => { + globalShortcut.unregister(accelerator) + }) + this._shortcuts = [] + } + +} + +module.exports = KeyboardShortcuts diff --git a/src/app/src/app/MailboxesSessionManager.js b/src/app/src/app/MailboxesSessionManager.js deleted file mode 100644 index 908f673d..00000000 --- a/src/app/src/app/MailboxesSessionManager.js +++ /dev/null @@ -1,159 +0,0 @@ -const electron = require('electron') -const uuid = require('uuid') -const fs = require('fs-extra') -const path = require('path') -const settingStore = require('./stores/settingStore') - -class MailboxesSessionManager { - - /* ****************************************************************************/ - // Lifecycle - /* ****************************************************************************/ - - /** - * @param mailboxWindow: the mailbox window instance we're working for - */ - constructor (mailboxWindow) { - this.mailboxWindow = mailboxWindow - this.downloadsInProgress = { } - - this.__managed__ = new Set() - } - - /* ****************************************************************************/ - // Setup - /* ****************************************************************************/ - - /** - * Starts managing a session - * @param parition the name of the partion to manage - */ - startManagingSession (partition) { - if (this.__managed__.has(partition)) { return } - - const ses = electron.session.fromPartition(partition) - ses.setDownloadPath(electron.app.getPath('downloads')) - ses.on('will-download', (evt, item) => this.handleDownload(evt, item)) - ses.setPermissionRequestHandler(this.handlePermissionRequest) - - this.__managed__.add(partition) - } - - /* ****************************************************************************/ - // Permissions - /* ****************************************************************************/ - - /** - * Handles a request for a permission from the client - * @param webContents: the webcontents the request came from - * @param permission: the permission name - * @param callback: execute with response - */ - handlePermissionRequest (webContents, permission, callback) { - if (permission === 'notifications') { - callback(false) - } else { - callback(true) - } - } - - /* ****************************************************************************/ - // Downloads - /* ****************************************************************************/ - - handleDownload (evt, item) { - // If the user has chosen - auto save the item - let savedLocation = null - if (!settingStore.os.alwaysAskDownloadLocation && settingStore.os.defaultDownloadLocation) { - const folderLocation = settingStore.os.defaultDownloadLocation - const fpath = path.parse(item.getFilename() || 'untitled') - - // Check the file exists - fs.ensureDirSync(folderLocation) - - // Keep trying a different variation of the filename until we find one that isn't in use - let iter = 0 - while (true) { - const testName = fpath.name + (iter === 0 ? '' : ' (' + iter + ')') + fpath.ext - const testPath = path.join(folderLocation, testName) - - let exists - try { - fs.statSync(testPath) - exists = true - } catch (ex) { - exists = false - } - - if (exists) { - iter++ - } else { - item.setSavePath(testPath) - savedLocation = { path: folderLocation, name: testName } - break - } - } - } else { - savedLocation = { path: electron.app.getPath('downloads'), name: item.getFilename() } - } - - // Report the progress to the window to display it - const totalBytes = item.getTotalBytes() - const id = uuid.v4() - item.on('updated', () => { - this.updateDownloadProgress(id, item.getReceivedBytes(), totalBytes) - }) - item.on('done', (e, state) => { - this.downloadFinished(id) - if (state === 'completed') { - this.mailboxWindow.downloadCompleted(savedLocation.path, savedLocation.name) - } - }) - } - - /* ****************************************************************************/ - // Download Progress - /* ****************************************************************************/ - - /** - * Updates the progress bar in the dock - */ - updateWindowProgressBar () { - const all = Object.keys(this.downloadsInProgress).reduce((acc, id) => { - acc.received += this.downloadsInProgress[id].received - acc.total += this.downloadsInProgress[id].total - return acc - }, { received: 0, total: 0 }) - - if (all.received === 0 && all.total === 0) { - this.mailboxWindow.setProgressBar(-1) - } else { - this.mailboxWindow.setProgressBar(all.received / all.total) - } - } - - /** - * Updates the progress on a download - * @param id: the download id - * @param received: the bytes received - * @param total: the total bytes to download - */ - updateDownloadProgress (id, received, total) { - this.downloadsInProgress[id] = this.downloadsInProgress[id] || {} - this.downloadsInProgress[id].received = received - this.downloadsInProgress[id].total = total - this.updateWindowProgressBar() - } - - /** - * Indicates that a download has finished - * @param id: the download id - */ - downloadFinished (id) { - delete this.downloadsInProgress[id] - this.updateWindowProgressBar() - } - -} - -module.exports = MailboxesSessionManager diff --git a/src/app/src/app/main.js b/src/app/src/app/main.js index 4191b1d1..b4b036a7 100644 --- a/src/app/src/app/main.js +++ b/src/app/src/app/main.js @@ -3,9 +3,22 @@ let windowManager const quitting = app.makeSingleInstance(function (commandLine, workingDirectory) { + const argv = require('yargs').parse(commandLine) if (windowManager) { - windowManager.mailboxesWindow.show() - windowManager.mailboxesWindow.focus() + if (argv.hidden || argv.hide) { + windowManager.mailboxesWindow.hide() + } else { + if (argv.mailto) { + windowManager.mailboxesWindow.openMailtoLink(argv.mailto) + } + const index = argv._.findIndex((a) => a.indexOf('mailto') === 0) + if (index !== -1) { + windowManager.mailboxesWindow.openMailtoLink(argv._[index]) + argv._.splice(1) + } + windowManager.mailboxesWindow.show() + windowManager.mailboxesWindow.focus() + } } return true }) @@ -14,13 +27,39 @@ return } + const argv = require('yargs').parse(process.argv) const AppAnalytics = require('./AppAnalytics') const MailboxesWindow = require('./windows/MailboxesWindow') const ContentWindow = require('./windows/ContentWindow') const pkg = require('../package.json') const AppPrimaryMenu = require('./AppPrimaryMenu') + const KeyboardShortcuts = require('./KeyboardShortcuts') const WindowManager = require('./windows/WindowManager') const constants = require('../shared/constants') + const storage = require('./storage') + const settingStore = require('./stores/settingStore') + + Object.keys(storage).forEach((k) => storage[k].checkAwake()) + + /* ****************************************************************************/ + // Commandline switches & launch args + /* ****************************************************************************/ + + if (settingStore.app.ignoreGPUBlacklist) { + app.commandLine.appendSwitch('ignore-gpu-blacklist', 'true') + } + if (settingStore.app.disableSmoothScrolling) { + app.commandLine.appendSwitch('disable-smooth-scrolling', 'true') + } + if (!settingStore.app.enableUseZoomForDSF) { + app.commandLine.appendSwitch('enable-use-zoom-for-dsf', 'false') + } + const openHidden = (function () { + if (settingStore.ui.openHidden) { return true } + if (process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAsHidden) { return true } + if (argv.hidden || argv.hide) { return true } + return false + })() /* ****************************************************************************/ // Global objects @@ -29,8 +68,10 @@ const analytics = new AppAnalytics() const mailboxesWindow = new MailboxesWindow(analytics) windowManager = new WindowManager(mailboxesWindow) - const appMenu = new AppPrimaryMenu({ - fullQuit: () => { windowManager.quit() }, + const selectors = { + fullQuit: () => { + windowManager.quit() + }, closeWindow: () => { const focused = windowManager.focused() focused ? focused.close() : undefined @@ -60,16 +101,28 @@ const focused = windowManager.focused() focused ? focused.openDevTools() : undefined }, - learnMore: () => { shell.openExternal(constants.GITHUB_URL) }, + learnMoreGithub: () => { shell.openExternal(constants.GITHUB_URL) }, + learnMore: () => { shell.openExternal(constants.WEB_URL) }, + privacy: () => { shell.openExternal(constants.PRIVACY_URL) }, bugReport: () => { shell.openExternal(constants.GITHUB_ISSUE_URL) }, zoomIn: () => { windowManager.mailboxesWindow.mailboxZoomIn() }, zoomOut: () => { windowManager.mailboxesWindow.mailboxZoomOut() }, zoomReset: () => { windowManager.mailboxesWindow.mailboxZoomReset() }, - mailbox: (mailboxId) => { + changeMailbox: (mailboxId) => { windowManager.mailboxesWindow.show() windowManager.mailboxesWindow.focus() windowManager.mailboxesWindow.switchMailbox(mailboxId) }, + prevMailbox: () => { + windowManager.mailboxesWindow.show() + windowManager.mailboxesWindow.focus() + windowManager.mailboxesWindow.switchPrevMailbox() + }, + nextMailbox: () => { + windowManager.mailboxesWindow.show() + windowManager.mailboxesWindow.focus() + windowManager.mailboxesWindow.switchNextMailbox() + }, cycleWindows: () => { windowManager.focusNextWindow() }, aboutDialog: () => { dialog.showMessageBox({ @@ -85,8 +138,14 @@ shell.openExternal(constants.GITHUB_URL) } }) - } - }) + }, + find: () => { windowManager.mailboxesWindow.findStart() }, + findNext: () => { windowManager.mailboxesWindow.findNext() }, + mailboxNavBack: () => { windowManager.mailboxesWindow.navigateMailboxBack() }, + mailboxNavForward: () => { windowManager.mailboxesWindow.navigateMailboxForward() } + } + const appMenu = new AppPrimaryMenu(selectors) + const keyboardShortcuts = new KeyboardShortcuts(selectors) /* ****************************************************************************/ // IPC Events @@ -97,40 +156,94 @@ }) ipcMain.on('new-window', (evt, body) => { + const mailboxesWindow = windowManager.mailboxesWindow + const copyPosition = !mailboxesWindow.window.isFullScreen() && !mailboxesWindow.window.isMaximized() + const windowOptions = copyPosition ? (() => { + const position = mailboxesWindow.window.getPosition() + const size = mailboxesWindow.window.getSize() + return { + x: position[0] + 20, + y: position[1] + 20, + width: size[0], + height: size[1] + } + })() : undefined const window = new ContentWindow(analytics) windowManager.addContentWindow(window) - window.start(body.url, body.partition) + window.start(body.url, body.partition, windowOptions) }) ipcMain.on('focus-app', (evt, body) => { windowManager.focusMailboxesWindow() }) + ipcMain.on('toggle-mailbox-visibility-from-tray', (evt, body) => { + windowManager.toggleMailboxWindowVisibilityFromTray() + }) + ipcMain.on('quit-app', (evt, body) => { windowManager.quit() }) + ipcMain.on('relaunch-app', (evt, body) => { + app.relaunch() + windowManager.quit() + }) + ipcMain.on('prepare-webview-session', (evt, data) => { mailboxesWindow.sessionManager.startManagingSession(data.partition) }) + ipcMain.on('mailboxes-js-loaded', (evt, data) => { + if (argv.mailto) { + windowManager.mailboxesWindow.openMailtoLink(argv.mailto) + delete argv.mailto + } else { + const index = argv._.findIndex((a) => a.indexOf('mailto') === 0) + if (index !== -1) { + windowManager.mailboxesWindow.openMailtoLink(argv._[index]) + argv._.splice(1) + } + } + }) + /* ****************************************************************************/ // App Events /* ****************************************************************************/ app.on('ready', () => { appMenu.updateApplicationMenu() - windowManager.mailboxesWindow.start() + windowManager.mailboxesWindow.start(openHidden) }) - app.on('window-all-closed', function () { + app.on('window-all-closed', () => { app.quit() }) - app.on('activate', function () { + app.on('activate', () => { windowManager.mailboxesWindow.show() }) + // Keyboard shortcuts in Electron need to be registered and unregistered + // on focus/blur respectively due to the global nature of keyboard shortcuts. + // See https://github.com/electron/electron/issues/1334 + app.on('browser-window-focus', () => { + keyboardShortcuts.register() + }) + app.on('browser-window-blur', () => { + keyboardShortcuts.unregister() + }) + + app.on('before-quit', () => { + keyboardShortcuts.unregister() + windowManager.forceQuit = true + }) + + app.on('open-url', (evt, url) => { // osx only + evt.preventDefault() + windowManager.mailboxesWindow.openMailtoLink(url) + }) + /* ****************************************************************************/ // Exceptions /* ****************************************************************************/ diff --git a/src/app/src/app/storage/StorageBucket.js b/src/app/src/app/storage/StorageBucket.js index 70f9b733..94d69f40 100644 --- a/src/app/src/app/storage/StorageBucket.js +++ b/src/app/src/app/storage/StorageBucket.js @@ -1,9 +1,12 @@ +const {ipcMain} = require('electron') const AppDirectory = require('appdirectory') const pkg = require('../../package.json') const mkdirp = require('mkdirp') -const Storage = require('dom-storage') const path = require('path') const Minivents = require('minivents') +const fs = require('fs') +const writeFileAtomic = require('write-file-atomic') +const { DB_WRITE_DELAY_MS } = require('../../shared/constants') // Setup const appDirectory = new AppDirectory(pkg.name) @@ -17,10 +20,64 @@ class StorageBucket { /* ****************************************************************************/ constructor (bucketName) { - this.__storage__ = new Storage(path.join(dbPath, bucketName + '_db.json')) + this.__path__ = path.join(dbPath, bucketName + '_db.json') + this.__writeHold__ = null + this.__writeLock__ = false + this.__data__ = undefined + this.__ipcReplyChannel__ = `storageBucket:${bucketName}:reply` + + this._loadFromDiskSync() + + ipcMain.on(`storageBucket:${bucketName}:setItem`, this._handleIPCSetItem.bind(this)) + ipcMain.on(`storageBucket:${bucketName}:removeItem`, this._handleIPCRemoveItem.bind(this)) + ipcMain.on(`storageBucket:${bucketName}:getItem`, this._handleIPCGetItem.bind(this)) + ipcMain.on(`storageBucket:${bucketName}:allKeys`, this._handleIPCAllKeys.bind(this)) + ipcMain.on(`storageBucket:${bucketName}:allItems`, this._handleIPCAllItems.bind(this)) + Minivents(this) } + checkAwake () { return true } + + /* ****************************************************************************/ + // Persistence + /* ****************************************************************************/ + + /** + * Loads the database from disk + */ + _loadFromDiskSync () { + let data = '{}' + try { + data = fs.readFileSync(this.__path__, 'utf8') + } catch (ex) { } + + try { + this.__data__ = JSON.parse(data) + } catch (ex) { + this.__data__ = {} + } + } + + /** + * Writes the current data to disk + */ + _writeToDisk () { + clearTimeout(this.__writeHold__) + this.__writeHold__ = setTimeout(() => { + if (this.__writeLock__) { + // Requeue in DB_WRITE_DELAY_MS + this._writeToDisk() + return + } else { + this.__writeLock__ = true + writeFileAtomic(this.__path__, JSON.stringify(this.__data__), () => { + this.__writeLock__ = false + }) + } + }, DB_WRITE_DELAY_MS) + } + /* ****************************************************************************/ // Getters /* ****************************************************************************/ @@ -28,11 +85,11 @@ class StorageBucket { /** * @param k: the key of the item * @param d=undefined: the default value if not exists - * @return the json item or d + * @return the string item or d */ getItem (k, d) { - const json = this.__storage__.getItem(k) - return json ? JSON.parse(json) : d + const json = this.__data__[k] + return json || d } /** @@ -40,20 +97,20 @@ class StorageBucket { * @param d=undefined: the default value if not exists * @return the string item or d */ - getString (k, d) { - const json = this.__storage__.getItem(k) - return json || d + getJSONItem (k, d) { + const item = this.getItem(k) + try { + return item ? JSON.parse(item) : d + } catch (ex) { + return {} + } } /** * @return a list of all keys */ allKeys () { - const keys = [] - for (let i = 0; i < this.__storage__.length; i++) { - keys.push(this.__storage__.key(i)) - } - return keys + return Object.keys(this.__data__) } /** @@ -67,17 +124,17 @@ class StorageBucket { } /** - * @return all the items in an obj + * @return all the items in an obj json parsed */ - allStrings () { + allJSONItems () { return this.allKeys().reduce((acc, key) => { - acc[key] = this.getString(key) + acc[key] = this.getJSONItem(key) return acc }, {}) } /* ****************************************************************************/ - // Setters + // Modifiers /* ****************************************************************************/ /** @@ -85,36 +142,94 @@ class StorageBucket { * @param v: the value to set * @return v */ - setItem (k, v) { - this.__storage__.setItem(k, JSON.stringify(v)) + _setItem (k, v) { + this.__data__[k] = '' + v + this._writeToDisk() this.emit('changed', { type: 'setItem', key: k }) this.emit('changed:' + k, { }) return v } /** - * @param k: the key to set - * @param s: the value to set - * @return s + * @param k: the key to remove */ - setString (k, s) { - this.__storage__.setItem(k, s) - this.emit('changed', { type: 'setString', key: k }) + _removeItem (k) { + delete this.__data__[k] + this._writeToDisk() + this.emit('changed', { type: 'removeItem', key: k }) this.emit('changed:' + k, { }) - return s } /* ****************************************************************************/ - // Removers + // IPC Access /* ****************************************************************************/ /** - * @param k: the key to remove + * Responds to an ipc message + * @param evt: the original event that fired + * @param response: teh response to send + * @param sendSync: set to true to respond synchronously */ - removeItem (k) { - this.__storage__.removeItem(k) - this.emit('changed', { type: 'removeItem', key: k }) - this.emit('changed:' + k, { }) + _sendIPCResponse (evt, response, sendSync = false) { + if (sendSync) { + evt.returnValue = response + } else { + evt.sender.send(this.__ipcReplyChannel__, response) + } + } + + /** + * Sets an item over IPC + * @param evt: the fired event + * @param body: request body + */ + _handleIPCSetItem (evt, body) { + this._setItem(body.key, body.value) + this._sendIPCResponse(evt, { id: body.id, response: null }, body.sync) + } + + /** + * Removes an item over IPC + * @param evt: the fired event + * @param body: request body + */ + _handleIPCRemoveItem (evt, body) { + this._removeItem(body.key) + this._sendIPCResponse(evt, { id: body.id, response: null }, body.sync) + } + + /** + * Gets an item over IPC + * @param evt: the fired event + * @param body: request body + */ + _handleIPCGetItem (evt, body) { + this._sendIPCResponse(evt, { + id: body.id, + response: this.getItem(body.key) + }, body.sync) + } + + /** + * Gets the keys over IPC + * @param body: request body + */ + _handleIPCAllKeys (evt, body) { + this._sendIPCResponse(evt, { + id: body.id, + response: this.allKeys() + }, body.sync) + } + + /** + * Gets all the items over IPC + * @param body: request body + */ + _handleIPCAllItems (evt, body) { + this._sendIPCResponse(evt, { + id: body.id, + response: this.allItems() + }, body.sync) } } diff --git a/src/app/src/app/storage/StorageBucketAppMutable.js b/src/app/src/app/storage/StorageBucketAppMutable.js new file mode 100644 index 00000000..e7386b67 --- /dev/null +++ b/src/app/src/app/storage/StorageBucketAppMutable.js @@ -0,0 +1,24 @@ +const StorageBucket = require('./StorageBucket') + +class StorageBucketAppMutable extends StorageBucket { + /** + * @param k: the key to set + * @param v: the value to set + * @return v + */ + setItem (k, v) { return this._setItem(k, v) } + + /** + * @param k: the key to set + * @param v: the value to set + * @return v + */ + setJSONItem (k, v) { return this._setItem(k, JSON.stringify(v)) } + + /** + * @param k: the key to remove + */ + removeItem (k) { return this._removeItem(k) } +} + +module.exports = StorageBucketAppMutable diff --git a/src/app/src/app/storage/appStorage.js b/src/app/src/app/storage/appStorage.js index 3780df99..59296594 100644 --- a/src/app/src/app/storage/appStorage.js +++ b/src/app/src/app/storage/appStorage.js @@ -1,2 +1,2 @@ -const StorageBucket = require('./StorageBucket') +const StorageBucket = require('./StorageBucketAppMutable') module.exports = new StorageBucket('app') diff --git a/src/app/src/app/storage/index.js b/src/app/src/app/storage/index.js new file mode 100644 index 00000000..0ca649e3 --- /dev/null +++ b/src/app/src/app/storage/index.js @@ -0,0 +1,6 @@ +module.exports = { + appStorage: require('./appStorage'), + avatarStorage: require('./avatarStorage'), + mailboxStorage: require('./mailboxStorage'), + settingStorage: require('./settingStorage') +} diff --git a/src/app/src/app/stores/mailboxStore.js b/src/app/src/app/stores/mailboxStore.js index 4bc0c6d2..e6e22114 100644 --- a/src/app/src/app/stores/mailboxStore.js +++ b/src/app/src/app/stores/mailboxStore.js @@ -4,6 +4,10 @@ const Mailbox = require('../../shared/Models/Mailbox/Mailbox') const { MAILBOX_INDEX_KEY } = require('../../shared/constants') class MailboxStore { + /* ****************************************************************************/ + // Lifecycle + /* ****************************************************************************/ + constructor () { Minivents(this) @@ -11,7 +15,7 @@ class MailboxStore { this.index = [] this.mailboxes = new Map() - const allRawItems = persistence.allItems() + const allRawItems = persistence.allJSONItems() Object.keys(allRawItems).forEach((id) => { if (id === MAILBOX_INDEX_KEY) { this.index = allRawItems[id] @@ -23,10 +27,10 @@ class MailboxStore { // Listen for changes persistence.on('changed', (evt) => { if (evt.key === MAILBOX_INDEX_KEY) { - this.index = persistence.getItem(MAILBOX_INDEX_KEY) + this.index = persistence.getJSONItem(MAILBOX_INDEX_KEY) } else { if (evt.type === 'setItem') { - this.mailboxes.set(evt.key, new Mailbox(evt.key, persistence.getItem(evt.key))) + this.mailboxes.set(evt.key, new Mailbox(evt.key, persistence.getJSONItem(evt.key))) } if (evt.type === 'removeItem') { this.mailboxes.delete(evt.key) @@ -36,11 +40,26 @@ class MailboxStore { }) } + /* ****************************************************************************/ + // Getters + /* ****************************************************************************/ + + /** + * @return the mailboxes in an ordered list + */ orderedMailboxes () { return this.index .map(id => this.mailboxes.get(id)) .filter((mailbox) => !!mailbox) } + + /** + * @param id: the id of the mailbox + * @return the mailbox record + */ + getMailbox (id) { + return this.mailboxes.get(id) + } } module.exports = new MailboxStore() diff --git a/src/app/src/app/stores/settingStore.js b/src/app/src/app/stores/settingStore.js index 79c10efd..e059e408 100644 --- a/src/app/src/app/stores/settingStore.js +++ b/src/app/src/app/stores/settingStore.js @@ -1,7 +1,15 @@ const persistence = require('../storage/settingStorage') const Minivents = require('minivents') const { - Settings: {LanguageSettings, OSSettings, ProxySettings, TraySettings, UISettings} + Settings: { + AppSettings, + LanguageSettings, + NewsSettings, + OSSettings, + ProxySettings, + TraySettings, + UISettings + } } = require('../../shared/Models') class SettingStore { @@ -9,32 +17,56 @@ class SettingStore { Minivents(this) // Build the current data - this.language = new LanguageSettings(persistence.getItem('language', {})) - this.os = new OSSettings(persistence.getItem('os', {})) - this.proxy = new ProxySettings(persistence.getItem('proxy', {})) - this.tray = new TraySettings(persistence.getItem('tray', {})) - this.ui = new UISettings(persistence.getItem('ui', {})) + this.app = new AppSettings(persistence.getJSONItem('app', {})) + this.language = new LanguageSettings(persistence.getJSONItem('language', {})) + this.news = new NewsSettings(persistence.getJSONItem('news', {})) + this.os = new OSSettings(persistence.getJSONItem('os', {})) + this.proxy = new ProxySettings(persistence.getJSONItem('proxy', {})) + this.tray = new TraySettings(persistence.getJSONItem('tray', {})) + this.ui = new UISettings(persistence.getJSONItem('ui', {})) // Listen for changes + persistence.on('changed:app', () => { + const prev = this.language + this.app = new AppSettings(persistence.getJSONItem('app', {})) + this.emit('changed', { }) + this.emit('changed:app', { prev: prev, next: this.app }) + }) persistence.on('changed:language', () => { - this.language = new LanguageSettings(persistence.getItem('language', {})) - this.emit('changed', {}) + const prev = this.language + this.language = new LanguageSettings(persistence.getJSONItem('language', {})) + this.emit('changed', { }) + this.emit('changed:language', { prev: prev, next: this.language }) + }) + persistence.on('changed:news', () => { + const prev = this.news + this.news = new NewsSettings(persistence.getJSONItem('news', {})) + this.emit('changed', { }) + this.emit('changed:news', { prev: prev, next: this.news }) }) persistence.on('changed:os', () => { - this.language = new OSSettings(persistence.getItem('os', {})) - this.emit('changed', {}) + const prev = this.os + this.os = new OSSettings(persistence.getJSONItem('os', {})) + this.emit('changed', { }) + this.emit('changed:os', { prev: prev, next: this.os }) }) persistence.on('changed:proxy', () => { - this.language = new ProxySettings(persistence.getItem('proxy', {})) - this.emit('changed', {}) + const prev = this.proxy + this.proxy = new ProxySettings(persistence.getJSONItem('proxy', {})) + this.emit('changed', { }) + this.emit('changed:proxy', { prev: prev, next: this.proxy }) }) persistence.on('changed:tray', () => { - this.language = new TraySettings(persistence.getItem('tray', {})) - this.emit('changed', {}) + const prev = this.tray + this.tray = new TraySettings(persistence.getJSONItem('tray', {})) + this.emit('changed', { }) + this.emit('changed:tray', { prev: prev, next: this.tray }) }) persistence.on('changed:ui', () => { - this.language = new UISettings(persistence.getItem('ui', {})) - this.emit('changed', {}) + const prev = this.ui + this.ui = new UISettings(persistence.getJSONItem('ui', {})) + this.emit('changed', { }) + this.emit('changed:ui', { prev: prev, next: this.ui }) }) } } diff --git a/src/app/src/app/update.js b/src/app/src/app/update.js deleted file mode 100644 index d30376bf..00000000 --- a/src/app/src/app/update.js +++ /dev/null @@ -1,40 +0,0 @@ -const fetch = require('node-fetch') -const constants = require('../shared/constants') -const pkg = require('../package.json') -const {dialog, shell} = require('electron') -const compareVersion = require('compare-version') - -class Update { - /** - * Checks to see if there is an update - */ - checkNow (window) { - fetch(constants.UPDATE_CHECK_URL).then((res) => { - return res.json() - }).then((json) => { - const newRelease = json.find((release) => { - let tag = release.tag_name - tag = tag.indexOf('v' === 0) ? tag.substr(1) : tag - return (pkg.prerelease === true || release.prerelease === false) && compareVersion(tag, pkg.version) >= 1 - }) - - if (newRelease) { - let tag = newRelease.tag_name - tag = tag.indexOf('v' === 0) ? tag.substr(1) : tag - dialog.showMessageBox(window, { - type: 'question', - title: 'Updates Available', - message: 'Version ' + tag + ' is now available. Do you want to download it now?', - buttons: ['Download Now', 'Download Later'], - defaultId: 1 - }, (response) => { - if (response === 0) { - shell.openExternal(constants.GITHUB_RELEASES_URL) - } - }) - } - }) - } -} - -module.exports = new Update() diff --git a/src/app/src/app/windows/ContentWindow.js b/src/app/src/app/windows/ContentWindow.js index c4ef2f62..3c40623a 100644 --- a/src/app/src/app/windows/ContentWindow.js +++ b/src/app/src/app/windows/ContentWindow.js @@ -7,8 +7,14 @@ class ContentWindow extends WMailWindow { // Creation /* ****************************************************************************/ - defaultWindowPreferences (partition) { - return Object.assign(super.defaultWindowPreferences(), { + /** + * The default window preferences + * @param partition: the partition to set the window to + * @param extraPreferences = undefined: extra preferences to merge into the prefs + * @return the settings + */ + defaultWindowPreferences (partition, extraPreferences = undefined) { + return Object.assign(super.defaultWindowPreferences(extraPreferences), { minWidth: 400, minHeight: 400, webPreferences: { @@ -18,10 +24,20 @@ class ContentWindow extends WMailWindow { }) } - start (url, partition) { - this.createWindow(this.defaultWindowPreferences(partition), url) + /** + * Starts the window + * @param url: the start url + * @param partition: the window partition + * @param windowPreferences=undefined: additional window preferences to supply + */ + start (url, partition, windowPreferences = undefined) { + this.createWindow(this.defaultWindowPreferences(partition, windowPreferences), url) } + /** + * Creates and launches the window + * @arguments: passed through to super() + */ createWindow () { super.createWindow.apply(this, Array.from(arguments)) this.window.webContents.on('new-window', (evt, url) => { diff --git a/src/app/src/app/windows/MailboxesSessionManager.js b/src/app/src/app/windows/MailboxesSessionManager.js new file mode 100644 index 00000000..74ff426b --- /dev/null +++ b/src/app/src/app/windows/MailboxesSessionManager.js @@ -0,0 +1,251 @@ +const {session, dialog, app} = require('electron') +const uuid = require('uuid') +const fs = require('fs-extra') +const path = require('path') +const settingStore = require('../stores/settingStore') +const mailboxStore = require('../stores/mailboxStore') +const unusedFilename = require('unused-filename') + +const COOKIE_PERSIST_WAIT = 1000 * 30 // 30 secs +const COOKIE_PERSIST_PERIOD = 30 * 24 * 60 * 60 * 1000 // 30 days + +class MailboxesSessionManager { + + /* ****************************************************************************/ + // Lifecycle + /* ****************************************************************************/ + + /** + * @param mailboxWindow: the mailbox window instance we're working for + */ + constructor (mailboxWindow) { + this.mailboxWindow = mailboxWindow + this.downloadsInProgress = { } + this.persistCookieThrottle = { } + + this.__managed__ = new Set() + } + + /* ****************************************************************************/ + // Utils + /* ****************************************************************************/ + + /** + * @param partition: the partition id + * @return the mailbox model for the partition + */ + getMailboxFromPartition (partition) { + return mailboxStore.getMailbox(partition.replace('persist:', '')) + } + + /* ****************************************************************************/ + // Setup & Auth + /* ****************************************************************************/ + + /** + * Starts managing a session + * @param parition the name of the partion to manage + */ + startManagingSession (partition) { + if (this.__managed__.has(partition)) { return } + + const ses = session.fromPartition(partition) + ses.setDownloadPath(app.getPath('downloads')) + ses.on('will-download', (evt, item) => this.handleDownload(evt, item)) + ses.setPermissionRequestHandler(this.handlePermissionRequest) + ses.webRequest.onCompleted((evt) => this.handleRequestCompleted(evt, ses, partition)) + + this.__managed__.add(partition) + } + + /* ****************************************************************************/ + // Permissions + /* ****************************************************************************/ + + /** + * Handles a request for a permission from the client + * @param webContents: the webcontents the request came from + * @param permission: the permission name + * @param callback: execute with response + */ + handlePermissionRequest (webContents, permission, callback) { + if (permission === 'notifications') { + callback(false) + } else { + callback(true) + } + } + + /* ****************************************************************************/ + // Downloads + /* ****************************************************************************/ + + handleDownload (evt, item) { + // Find out where to save the file + let savePath + if (!settingStore.os.alwaysAskDownloadLocation && settingStore.os.defaultDownloadLocation) { + const folderLocation = settingStore.os.defaultDownloadLocation + + // Check the containing folder exists + fs.ensureDirSync(folderLocation) + savePath = unusedFilename.sync(path.join(folderLocation, item.getFilename())) + } else { + let pickedSavePath = dialog.showSaveDialog(this.mailboxWindow.window, { + title: 'Download', + defaultPath: path.join(app.getPath('downloads'), item.getFilename()) + }) + + // There's a bit of a pickle here. Whilst asking the user where to save + // they may have omitted the file extension. At the same time they may chosen + // a filename that is already taken. We don't have any in-built ui to handle + // this so the least destructive way is to find a filename that is not + // in use and just save to there. In any case if the user picks a path and + // that file does already exist we should remove it + if (pickedSavePath) { + // Remove existing file - save dialog prompts before allowing user to choose pre-existing name + try { fs.removeSync(pickedSavePath) } catch (ex) { /* no-op */ } + + // User didn't add file extension + if (!path.extname(pickedSavePath)) { + pickedSavePath += path.extname(item.getFilename()) + pickedSavePath = unusedFilename.sync(pickedSavePath) + } + savePath = pickedSavePath + } + } + + // Check we still want to save + if (!savePath) { + item.cancel() + return + } + + // Set the save - will prevent dialog showing up + const downloadPath = unusedFilename.sync(savePath + '.wmaildownload') // just-in-case + item.setSavePath(downloadPath) + + // Report the progress to the window to display it + const totalBytes = item.getTotalBytes() + const id = uuid.v4() + item.on('updated', () => { + this.updateDownloadProgress(id, item.getReceivedBytes(), totalBytes) + }) + item.on('done', (e, state) => { + if (state === 'completed') { + // Download item will get destroyed before move callback completes. If + // you need any info from it grab it before calling fs.move + fs.move(downloadPath, savePath, () => { + this.downloadFinished(id) + const saveName = path.basename(savePath) + this.mailboxWindow.downloadCompleted(savePath, saveName) + }) + } else { + // Tidy-up on failure + try { fs.removeSync(downloadPath) } catch (ex) { /* no-op */ } + this.downloadFinished(id) + } + }) + } + + /* ****************************************************************************/ + // Download Progress + /* ****************************************************************************/ + + /** + * Updates the progress bar in the dock + */ + updateWindowProgressBar () { + const all = Object.keys(this.downloadsInProgress).reduce((acc, id) => { + acc.received += this.downloadsInProgress[id].received + acc.total += this.downloadsInProgress[id].total + return acc + }, { received: 0, total: 0 }) + + if (all.received === 0 && all.total === 0) { + this.mailboxWindow.setProgressBar(-1) + } else { + this.mailboxWindow.setProgressBar(all.received / all.total) + } + } + + /** + * Updates the progress on a download + * @param id: the download id + * @param received: the bytes received + * @param total: the total bytes to download + */ + updateDownloadProgress (id, received, total) { + this.downloadsInProgress[id] = this.downloadsInProgress[id] || {} + this.downloadsInProgress[id].received = received + this.downloadsInProgress[id].total = total + this.updateWindowProgressBar() + } + + /** + * Indicates that a download has finished + * @param id: the download id + */ + downloadFinished (id) { + delete this.downloadsInProgress[id] + this.updateWindowProgressBar() + } + + /* ****************************************************************************/ + // Requests + /* ****************************************************************************/ + + /** + * Handles a request completing + * @param evt: the event that fired + * @param session: the session this request was for + * @param partition: the partition string for this session + */ + handleRequestCompleted (evt, session, partition) { + this.artificiallyPersistCookies(session, partition) + } + + /* ****************************************************************************/ + // Cookies + /* ****************************************************************************/ + + /** + * Forces the cookies to persist artifically. This helps users using saml signin + * @param session: the session this request was for + * @param partition: the partition string for this session + */ + artificiallyPersistCookies (session, partition) { + if (this.persistCookieThrottle[partition] !== undefined) { return } + const mailbox = this.getMailboxFromPartition(partition) + if (!mailbox || !mailbox.artificiallyPersistCookies) { return } + + this.persistCookieThrottle[partition] = setTimeout(() => { + session.cookies.get({ session: true }, (error, cookies) => { + if (error || !cookies.length) { + delete this.persistCookieThrottle[partition] + return + } + cookies.forEach((cookie) => { + const url = (cookie.secure ? 'https://' : 'http://') + cookie.domain + cookie.path + session.cookies.remove(url, cookie.name, (error) => { + if (error) { return } + const expire = new Date().getTime() + COOKIE_PERSIST_PERIOD + const persistentCookie = { + url: url, + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + expirationDate: expire + } + session.cookies.set(persistentCookie, (_) => { }) + }) + }) + delete this.persistCookieThrottle[partition] + }) + }, COOKIE_PERSIST_WAIT) + } +} + +module.exports = MailboxesSessionManager diff --git a/src/app/src/app/windows/MailboxesWindow.js b/src/app/src/app/windows/MailboxesWindow.js index 8488fea9..4f2f0774 100644 --- a/src/app/src/app/windows/MailboxesWindow.js +++ b/src/app/src/app/windows/MailboxesWindow.js @@ -1,10 +1,15 @@ const WMailWindow = require('./WMailWindow') const AuthGoogle = require('../AuthGoogle') -const update = require('../update') const path = require('path') -const MailboxesSessionManager = require('../MailboxesSessionManager') +const MailboxesSessionManager = require('./MailboxesSessionManager') const settingStore = require('../stores/settingStore') +const MAILBOXES_DIR = path.resolve(path.join(__dirname, '/../../../scenes/mailboxes')) +const ALLOWED_URLS = new Set([ + 'file://' + path.join(MAILBOXES_DIR, 'mailboxes.html'), + 'file://' + path.join(MAILBOXES_DIR, 'offline.html') +]) + class MailboxesWindow extends WMailWindow { /* ****************************************************************************/ @@ -23,20 +28,17 @@ class MailboxesWindow extends WMailWindow { this.sessionManager = new MailboxesSessionManager(this) } - start (url) { - super.start('file://' + path.join(__dirname, '/../../../scenes/mailboxes/mailboxes.html')) - } - - /* ****************************************************************************/ - // Creation & Closing - /* ****************************************************************************/ - - defaultWindowPreferences () { - return Object.assign(super.defaultWindowPreferences(), { - minWidth: 955, - minHeight: 400, + /** + * @param url: the url to load + * @param hidden=false: true to start the window hidden + */ + start (hidden = false) { + super.start('file://' + path.join(MAILBOXES_DIR, 'mailboxes.html'), { + show: !hidden, + minWidth: 770, + minHeight: 300, fullscreenable: true, - titleBarStyle: settingStore.ui.hasTitlebar ? 'default' : 'hidden', + titleBarStyle: settingStore.ui.showTitlebar ? 'default' : 'hidden', title: 'WMail', backgroundColor: '#f2f2f2', webPreferences: { @@ -45,15 +47,20 @@ class MailboxesWindow extends WMailWindow { }) } + /* ****************************************************************************/ + // Creation & Closing + /* ****************************************************************************/ + createWindow () { super.createWindow.apply(this, Array.from(arguments)) // We're locking on to our window. This stops file drags redirecting the page - this.window.webContents.on('will-navigate', (evt) => { - evt.preventDefault() + this.window.webContents.on('will-navigate', (evt, url) => { + if (!ALLOWED_URLS.has(url)) { + evt.preventDefault() + } }) - update.checkNow(this.window) this.analytics.appOpened(this.window) this.heartbeatInterval = setInterval(() => { this.analytics.appHeartbeat(this.window) @@ -108,6 +115,20 @@ class MailboxesWindow extends WMailWindow { this.window.webContents.send('switch-mailbox', { mailboxId: mailboxId }) } + /** + * Switches to the previous mailbox + */ + switchPrevMailbox () { + this.window.webContents.send('switch-mailbox', { prev: true }) + } + + /** + * Switches to the next mailbox + */ + switchNextMailbox () { + this.window.webContents.send('switch-mailbox', { next: true }) + } + /** * Launches the preferences modal */ @@ -141,6 +162,42 @@ class MailboxesWindow extends WMailWindow { }) } + /** + * Starts finding in the mailboxes window + */ + findStart () { + this.window.webContents.send('mailbox-window-find-start', { }) + } + + /** + * Finds the next in the mailbox window + */ + findNext () { + this.window.webContents.send('mailbox-window-find-next', { }) + } + + /** + * Tells the active mailbox to navigate back + */ + navigateMailboxBack () { + this.window.webContents.send('mailbox-window-navigate-back', { }) + } + + /** + * Tells the active mailbox to navigate back + */ + navigateMailboxForward () { + this.window.webContents.send('mailbox-window-navigate-forward', { }) + } + + /** + * Opens a mailto link + * @param mailtoLink: the link to open + */ + openMailtoLink (mailtoLink) { + this.window.webContents.send('open-mailto-link', { mailtoLink: mailtoLink }) + } + } module.exports = MailboxesWindow diff --git a/src/app/src/app/windows/WMailWindow.js b/src/app/src/app/windows/WMailWindow.js index 07f50d9e..5d2b9d9d 100644 --- a/src/app/src/app/windows/WMailWindow.js +++ b/src/app/src/app/windows/WMailWindow.js @@ -2,6 +2,7 @@ const {BrowserWindow} = require('electron') const EventEmitter = require('events') const settingStore = require('../stores/settingStore') const appStorage = require('../storage/appStorage') +const path = require('path') class WMailWindow extends EventEmitter { @@ -25,9 +26,10 @@ class WMailWindow extends EventEmitter { /** * Starts the app * @param url: the start url + * @param windowPreferences=undefined: additional window preferences to supply */ - start (url) { - this.createWindow(this.defaultWindowPreferences(), url) + start (url, windowPreferences = undefined) { + this.createWindow(this.defaultWindowPreferences(windowPreferences), url) } /* ****************************************************************************/ @@ -36,12 +38,21 @@ class WMailWindow extends EventEmitter { /** * The default window preferences + * @param extraPreferences = undefined: extra preferences to merge into the prefs * @return the settings */ - defaultWindowPreferences () { - return { - title: 'WMail' + defaultWindowPreferences (extraPreferences = undefined) { + let icon + if (process.platform === 'win32') { + icon = path.join(__dirname, '/../../../icons/app.ico') + } else if (process.platform === 'linux') { + icon = path.join(__dirname, '/../../../icons/app.png') } + + return Object.assign({ + title: 'WMail', + icon: icon + }, extraPreferences) } /** @@ -54,7 +65,7 @@ class WMailWindow extends EventEmitter { // Load up the window location & last state this.window = new BrowserWindow(Object.assign(settings, screenLocation)) - if (screenLocation.maximized) { + if (screenLocation.maximized && settings.show !== false) { this.window.maximize() } if (this.options.screenLocationNS) { @@ -63,7 +74,7 @@ class WMailWindow extends EventEmitter { this.window.on('maximize', (evt) => { this.saveWindowScreenLocation() }) this.window.on('unmaximize', (evt) => { this.saveWindowScreenLocation() }) } - this[settingStore.ui.hasAppMenu ? 'showAppMenu' : 'hideAppMenu']() + this[settingStore.ui.showAppMenu ? 'showAppMenu' : 'hideAppMenu']() // Bind to change events this.window.on('close', (evt) => { this.emit('close', evt) }) @@ -95,20 +106,18 @@ class WMailWindow extends EventEmitter { saveWindowScreenLocation () { clearTimeout(this.windowScreenLocationSaver) this.windowScreenLocationSaver = setTimeout(() => { - const state = { + if (this.window.isMinimized()) { return } + const position = this.window.getPosition() + const size = this.window.getSize() + + appStorage.setJSONItem(this.options.screenLocationNS, { fullscreen: this.window.isFullScreen(), - maximized: this.window.isMaximized() - } - if (!this.window.isMaximized() && !this.window.isMinimized()) { - const position = this.window.getPosition() - const size = this.window.getSize() - state.x = position[0] - state.y = position[1] - state.width = size[0] - state.height = size[1] - } - - appStorage.setItem(this.options.screenLocationNS, state) + maximized: this.window.isMaximized(), + x: position[0], + y: position[1], + width: size[0], + height: size[1] + }) }, 2000) } @@ -118,7 +127,7 @@ class WMailWindow extends EventEmitter { */ loadWindowScreenLocation () { if (this.options.screenLocationNS) { - return appStorage.getItem(this.options.screenLocationNS, {}) + return appStorage.getJSONItem(this.options.screenLocationNS, {}) } return {} @@ -128,7 +137,7 @@ class WMailWindow extends EventEmitter { * Updates the menubar */ updateWindowMenubar (prev, next) { - this[settingStore.ui.hasAppMenu ? 'showAppMenu' : 'hideAppMenu']() + this[settingStore.ui.showAppMenu ? 'showAppMenu' : 'hideAppMenu']() } /* ****************************************************************************/ @@ -188,9 +197,9 @@ class WMailWindow extends EventEmitter { this.window.webContents.openDevTools() } - /** - * Show the app menu - */ + /** + * Show the app menu + */ showAppMenu () { this.window.setMenuBarVisibility(true) } diff --git a/src/app/src/app/windows/WindowManager.js b/src/app/src/app/windows/WindowManager.js index 3b362453..5beb3677 100644 --- a/src/app/src/app/windows/WindowManager.js +++ b/src/app/src/app/windows/WindowManager.js @@ -30,7 +30,7 @@ class WindowManager { * @param evt: the event that occured */ handleClose (evt) { - if (this.focused() && !this.forceQuit) { + if (!this.forceQuit) { this.contentWindows.forEach((w) => w.close()) if (process.platform === 'darwin' || settingStore.tray.show) { this.mailboxesWindow.hide() @@ -89,9 +89,8 @@ class WindowManager { * Focuses the main mailboxes window and shows it if it's hidden */ focusMailboxesWindow () { - if (this.focused()) { - // If there's already a focused window, do nothing - return + if (this.focused() === this.mailboxesWindow) { + return // If there's already a focused window, do nothing } if (!this.mailboxesWindow.isVisible()) { @@ -100,6 +99,33 @@ class WindowManager { this.mailboxesWindow.focus() } + /** + * Toggles the mailboxes window visibility by hiding or showing the mailboxes windoww + */ + toggleMailboxWindowVisibilityFromTray () { + if (process.platform === 'win32') { + // On windows clicking on non-window elements (e.g. tray) causes window + // to lose focus, so the window will never have focus + if (this.mailboxesWindow.isVisible()) { + this.mailboxesWindow.close() + } else { + this.mailboxesWindow.show() + this.mailboxesWindow.focus() + } + } else { + if (this.mailboxesWindow.isVisible()) { + if (this.focused() === this.mailboxesWindow) { + this.mailboxesWindow.hide() + } else { + this.mailboxesWindow.focus() + } + } else { + this.mailboxesWindow.show() + this.mailboxesWindow.focus() + } + } + } + /* ****************************************************************************/ // Querying /* ****************************************************************************/ diff --git a/src/scenes/mailboxes/package.json b/src/scenes/mailboxes/package.json index 53a9534b..c0f1fd61 100644 --- a/src/scenes/mailboxes/package.json +++ b/src/scenes/mailboxes/package.json @@ -1,25 +1,32 @@ { "name": "wmail-scenes-mailboxes", "author": "Thomas Beverley", - "license": "BSD-2-Clause", + "license": "MPL-2.0", "repository": "https://github.com/Thomas101/wmail", "description": "The mailboxes window for the WMail app", "dependencies": { - "alt": "0.18.4", - "fbjs": "0.8.3", - "flexboxgrid": "6.3.0", + "addressparser": "1.0.1", + "alt": "0.18.6", + "bootstrap-grid": "2.0.1", + "camelcase": "4.0.0", + "compare-version": "0.1.2", + "fbjs": "0.8.9", "https-proxy-agent": "1.0.0", - "material-ui": "0.15.0", + "material-ui": "0.17.0", "minivents": "2.0.2", - "react": "15.1.0", - "react-addons-shallow-compare": "15.1.0", - "react-color": "2.1.0", - "react-dom": "15.1.0", - "react-tap-event-plugin": "1.0.0", + "qs": "6.3.1", + "querystring": "0.2.0", + "react": "15.4.2", + "react-addons-shallow-compare": "15.4.2", + "react-color": "2.11.1", + "react-dom": "15.4.2", + "react-tap-event-plugin": "2.0.1", "react-timer-mixin": "0.13.3", - "uuid": "2.0.2" + "react-tooltip": "3.2.7", + "urijs": "1.18.7", + "uuid": "3.0.1" }, "devDependencies": { - "standard": "7.1.1" + "standard": "8.6.0" } } diff --git a/src/scenes/mailboxes/src/Components/ColorPickerButton.js b/src/scenes/mailboxes/src/Components/ColorPickerButton.js index f488d717..5d27678c 100644 --- a/src/scenes/mailboxes/src/Components/ColorPickerButton.js +++ b/src/scenes/mailboxes/src/Components/ColorPickerButton.js @@ -1,6 +1,6 @@ const React = require('react') const { RaisedButton, Popover } = require('material-ui') -const { SwatchesPicker } = require('react-color') +const { ChromePicker } = require('react-color') module.exports = React.createClass({ /* **************************************************************************/ @@ -12,6 +12,9 @@ module.exports = React.createClass({ value: React.PropTypes.string, label: React.PropTypes.string.isRequired, disabled: React.PropTypes.bool.isRequired, + anchorOrigin: React.PropTypes.object.isRequired, + targetOrigin: React.PropTypes.object.isRequired, + icon: React.PropTypes.node, onChange: React.PropTypes.func }, @@ -29,7 +32,9 @@ module.exports = React.createClass({ getDefaultProps () { return { label: 'Pick Colour', - disabled: false + disabled: false, + anchorOrigin: {horizontal: 'left', vertical: 'bottom'}, + targetOrigin: {horizontal: 'left', vertical: 'top'} } }, @@ -38,25 +43,27 @@ module.exports = React.createClass({ /* **************************************************************************/ render () { - const { label, disabled, onChange, ...passProps } = this.props + const { label, disabled, onChange, anchorOrigin, targetOrigin, icon, ...passProps } = this.props return (
this.setState({ open: true, anchor: evt.target })} /> this.setState({open: false})}> - { - this.setState({ open: false }) if (onChange) { - setTimeout(() => { onChange(col) }, 100) + onChange(Object.assign({}, col, { + rgbaStr: `rgba(${col.rgb.r}, ${col.rgb.g}, ${col.rgb.b}, ${col.rgb.a})` + })) } }} /> diff --git a/src/scenes/mailboxes/src/Components/Flexbox/Col.js b/src/scenes/mailboxes/src/Components/Flexbox/Col.js deleted file mode 100644 index fc7b424f..00000000 --- a/src/scenes/mailboxes/src/Components/Flexbox/Col.js +++ /dev/null @@ -1,44 +0,0 @@ -import 'flexboxgrid' - -const React = require('react') - -module.exports = React.createClass({ - displayName: 'FlexboxCol', - - propTypes: { - xs: React.PropTypes.number, - sm: React.PropTypes.number, - md: React.PropTypes.number, - lg: React.PropTypes.number, - className: React.PropTypes.string, - children: React.PropTypes.node - }, - - render () { - let mode = 'xs' - let size = 12 - if (this.props.xs !== undefined) { - mode = 'xs' - size = this.props.xs - } else if (this.props.sm !== undefined) { - mode = 'sm' - size = this.props.sm - } else if (this.props.md !== undefined) { - mode = 'md' - size = this.props.md - } else if (this.props.lg !== undefined) { - mode = 'lg' - size = this.props.lg - } - - const className = ['col', mode, size].join('-') + (this.props.className ? ' ' + this.props.className : '') - - return ( -
- {this.props.children} -
- ) - } -}) diff --git a/src/scenes/mailboxes/src/Components/Grid/Col.js b/src/scenes/mailboxes/src/Components/Grid/Col.js new file mode 100644 index 00000000..e5444a59 --- /dev/null +++ b/src/scenes/mailboxes/src/Components/Grid/Col.js @@ -0,0 +1,49 @@ +import 'bootstrap-grid' + +const React = require('react') + +module.exports = React.createClass({ + displayName: 'GridCol', + + propTypes: { + xs: React.PropTypes.number, + sm: React.PropTypes.number, + md: React.PropTypes.number, + lg: React.PropTypes.number, + offset: React.PropTypes.number, + className: React.PropTypes.string, + children: React.PropTypes.node + }, + + render () { + const {xs, sm, md, lg, offset, className, children, ...passProps} = this.props + + let mode = 'xs' + let size = 12 + if (xs !== undefined) { + mode = 'xs' + size = xs + } else if (sm !== undefined) { + mode = 'sm' + size = sm + } else if (md !== undefined) { + mode = 'md' + size = md + } else if (lg !== undefined) { + mode = 'lg' + size = lg + } + + const classNames = [ + ['col', mode, size].join('-'), + offset !== undefined ? ['col', mode, 'offset', size].join('-') : undefined, + className + ].filter((c) => !!c).join(' ') + + return ( +
+ {children} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/Components/Grid/Container.js b/src/scenes/mailboxes/src/Components/Grid/Container.js new file mode 100644 index 00000000..fb61ad9f --- /dev/null +++ b/src/scenes/mailboxes/src/Components/Grid/Container.js @@ -0,0 +1,28 @@ +import 'bootstrap-grid' + +const React = require('react') + +module.exports = React.createClass({ + displayName: 'GridContainer', + + propTypes: { + className: React.PropTypes.string, + children: React.PropTypes.node, + fluid: React.PropTypes.bool + }, + + render () { + const {fluid, className, ...passProps} = this.props + + const classNames = [ + fluid ? 'container-fluid' : 'conainer', + className + ].filter((c) => !!c).join(' ') + + return ( +
+ {this.props.children} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/Components/Flexbox/Row.js b/src/scenes/mailboxes/src/Components/Grid/Row.js similarity index 67% rename from src/scenes/mailboxes/src/Components/Flexbox/Row.js rename to src/scenes/mailboxes/src/Components/Grid/Row.js index 66641bc2..6fe3e2e6 100644 --- a/src/scenes/mailboxes/src/Components/Flexbox/Row.js +++ b/src/scenes/mailboxes/src/Components/Grid/Row.js @@ -1,9 +1,9 @@ -import 'flexboxgrid' +import 'bootstrap-grid' const React = require('react') module.exports = React.createClass({ - displayName: 'FlexboxRow', + displayName: 'GridRow', propTypes: { className: React.PropTypes.string, @@ -14,7 +14,7 @@ module.exports = React.createClass({ return (
+ className={['row', this.props.className].filter((c) => !!c).join(' ')}> {this.props.children}
) diff --git a/src/scenes/mailboxes/src/Components/Flexbox/index.js b/src/scenes/mailboxes/src/Components/Grid/index.js similarity index 65% rename from src/scenes/mailboxes/src/Components/Flexbox/index.js rename to src/scenes/mailboxes/src/Components/Grid/index.js index 28823bbf..e30fce24 100644 --- a/src/scenes/mailboxes/src/Components/Flexbox/index.js +++ b/src/scenes/mailboxes/src/Components/Grid/index.js @@ -1,4 +1,5 @@ module.exports = { + Container: require('./Container'), Col: require('./Col'), Row: require('./Row') } diff --git a/src/scenes/mailboxes/src/Components/TrayIconEditor.js b/src/scenes/mailboxes/src/Components/TrayIconEditor.js new file mode 100644 index 00000000..77fab2e3 --- /dev/null +++ b/src/scenes/mailboxes/src/Components/TrayIconEditor.js @@ -0,0 +1,118 @@ +const React = require('react') +const { FontIcon } = require('material-ui') +const {Row, Col} = require('./Grid') +const ColorPickerButton = require('./ColorPickerButton') +const TrayPreview = require('./TrayPreview') +const settingsActions = require('../stores/settings/settingsActions') +const shallowCompare = require('react-addons-shallow-compare') + +const styles = { + subheading: { + marginTop: 0, + marginBottom: 10, + color: '#CCC', + fontWeight: '300', + fontSize: 16 + }, + button: { + marginTop: 5, + marginBottom: 5 + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'TrayIconEditor', + propTypes: { + tray: React.PropTypes.object.isRequired, + trayPreviewStyles: React.PropTypes.object + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const {tray, trayPreviewStyles, ...passProps} = this.props + + return ( +
+ + +

All Messages Read

+
+ border_color} + anchorOrigin={{horizontal: 'left', vertical: 'top'}} + targetOrigin={{horizontal: 'left', vertical: 'bottom'}} + disabled={!tray.show} + value={tray.readColor} + onChange={(col) => settingsActions.setTrayReadColor(col.rgbaStr)} /> +
+
+ format_color_fill} + anchorOrigin={{horizontal: 'left', vertical: 'top'}} + targetOrigin={{horizontal: 'left', vertical: 'bottom'}} + disabled={!tray.show} + value={tray.readBackgroundColor} + onChange={(col) => settingsActions.setTrayReadBackgroundColor(col.rgbaStr)} /> +
+ + + +

Unread Messages

+
+ border_color} + anchorOrigin={{horizontal: 'left', vertical: 'top'}} + targetOrigin={{horizontal: 'left', vertical: 'bottom'}} + disabled={!tray.show} + value={tray.unreadColor} + onChange={(col) => settingsActions.setTrayUnreadColor(col.rgbaStr)} /> +
+
+ format_color_fill} + anchorOrigin={{horizontal: 'left', vertical: 'top'}} + targetOrigin={{horizontal: 'left', vertical: 'bottom'}} + disabled={!tray.show} + value={tray.unreadBackgroundColor} + onChange={(col) => settingsActions.setTrayUnreadBackgroundColor(col.rgbaStr)} /> +
+ + +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/Components/TrayPreview.js b/src/scenes/mailboxes/src/Components/TrayPreview.js new file mode 100644 index 00000000..8a174440 --- /dev/null +++ b/src/scenes/mailboxes/src/Components/TrayPreview.js @@ -0,0 +1,66 @@ +const React = require('react') +const TrayRenderer = require('./TrayRenderer') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'TrayPreview', + propTypes: { + config: React.PropTypes.object.isRequired, + size: React.PropTypes.number.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentWillMount () { + TrayRenderer.renderPNGDataImage(this.props.config) + .then((png) => this.setState({ image: png })) + }, + + componentWillReceiveProps (nextProps) { + if (shallowCompare(this, nextProps, this.state)) { + TrayRenderer.renderPNGDataImage(nextProps.config) + .then((png) => this.setState({ image: png })) + } + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { image: null } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { size, style, ...passProps } = this.props + delete passProps.config + + return ( +
+ {!this.state.image ? undefined : ( + + )} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/Components/TrayRenderer.js b/src/scenes/mailboxes/src/Components/TrayRenderer.js new file mode 100644 index 00000000..99c1b1ae --- /dev/null +++ b/src/scenes/mailboxes/src/Components/TrayRenderer.js @@ -0,0 +1,120 @@ +const {nativeImage} = window.nativeRequire('electron').remote +const B64_SVG_PREFIX = 'data:image/svg+xml;base64,' +const MAIL_SVG = window.atob(require('shared/b64Assets').MAIL_SVG.replace(B64_SVG_PREFIX, '')) + +class TrayRenderer { + + /** + * @param config: the config to merge into the default config + * @return the config for rendering the tray icon + */ + static defaultConfig (config) { + if (config.__defaultMerged__) { + return config + } else { + return Object.assign({ + pixelRatio: window.devicePixelRatio, + unreadCount: 0, + showUnreadCount: true, + unreadColor: '#000000', + readColor: '#C82018', + unreadBackgroundColor: '#FFFFFF', + readBackgroundColor: '#FFFFFF', + size: 100, + thick: process.platform === 'win32', + __defaultMerged__: true + }, config) + } + } + + /** + * Renders the tray icon as a canvas + * @param config: the config for rendering + * @return promise with the canvas + */ + static renderCanvas (config) { + return new Promise((resolve, reject) => { + config = TrayRenderer.defaultConfig(config) + + const SIZE = config.size * config.pixelRatio + const PADDING = SIZE * 0.15 + const CENTER = SIZE / 2 + const HAS_COUNT = config.showUnreadCount && config.unreadCount + const color = config.unreadCount ? config.unreadColor : config.readColor + const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor + + const canvas = document.createElement('canvas') + canvas.width = SIZE + canvas.height = SIZE + const ctx = canvas.getContext('2d') + + // Circle + if (!config.thick || config.thick && HAS_COUNT) { + ctx.beginPath() + ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false) + ctx.fillStyle = backgroundColor + ctx.fill() + ctx.lineWidth = SIZE / (config.thick ? 10 : 20) + ctx.strokeStyle = color + ctx.stroke() + } + + // Count or Icon + if (HAS_COUNT) { + ctx.fillStyle = color + ctx.textAlign = 'center' + if (config.unreadCount > 99) { + ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.6}px Helvetica` + ctx.fillText('+', CENTER, CENTER + (SIZE * 0.16)) + } else if (config.unreadCount < 10) { + ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica` + ctx.fillText(config.unreadCount, CENTER, CENTER + (SIZE * 0.20)) + } else { + ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica` + ctx.fillText(config.unreadCount, CENTER, CENTER + (SIZE * 0.15)) + } + + resolve(canvas) + } else { + const image = B64_SVG_PREFIX + window.btoa(MAIL_SVG.replace('fill="#000000"', `fill="${color}"`)) + const loader = new window.Image() + loader.onload = function () { + const ICON_SIZE = SIZE * (config.thick ? 1.0 : 0.5) + const POS = (SIZE - ICON_SIZE) / 2 + ctx.drawImage(loader, POS, POS, ICON_SIZE, ICON_SIZE) + resolve(canvas) + } + loader.src = image + } + }) + } + + /** + * Renders the tray icon as a data64 png image + * @param config: the config for rendering + * @return promise with the native image + */ + static renderPNGDataImage (config) { + config = TrayRenderer.defaultConfig(config) + return Promise.resolve() + .then(() => TrayRenderer.renderCanvas(config)) + .then((canvas) => Promise.resolve(canvas.toDataURL('image/png'))) + } + + /** + * Renders the tray icon as a native image + * @param config: the config for rendering + * @return the native image + */ + static renderNativeImage (config) { + config = TrayRenderer.defaultConfig(config) + return Promise.resolve() + .then(() => TrayRenderer.renderCanvas(config)) + .then((canvas) => { + const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPng() + return Promise.resolve(nativeImage.createFromBuffer(pngData, config.pixelRatio)) + }) + } +} + +module.exports = TrayRenderer diff --git a/src/scenes/mailboxes/src/Components/WebView.js b/src/scenes/mailboxes/src/Components/WebView.js new file mode 100644 index 00000000..75c3b02c --- /dev/null +++ b/src/scenes/mailboxes/src/Components/WebView.js @@ -0,0 +1,239 @@ +const React = require('react') +const ReactDOM = require('react-dom') +const camelCase = require('camelcase') + +const SEND_RESPOND_PREFIX = '__SEND_RESPOND__' +const WEBVIEW_EVENTS = [ + 'load-commit', + 'did-finish-load', + 'did-fail-load', + 'did-frame-finish-load', + 'did-start-loading', + 'did-stop-loading', + 'did-get-response-details', + 'did-get-redirect-request', + 'did-navigate', + 'did-navigate-in-page', + 'dom-ready', + 'page-title-set', + 'page-favicon-updated', + 'enter-html-full-screen', + 'leave-html-full-screen', + 'console-message', + 'new-window', + 'close', + 'ipc-message', + 'crashed', + 'gpu-crashed', + 'plugin-crashed', + 'destroyed', + 'focus', + 'blur', + 'update-target-url' +] +const REACT_WEBVIEW_EVENTS = WEBVIEW_EVENTS.map((name) => camelCase(name)) + +const WEBVIEW_PROPS = { + autosize: React.PropTypes.bool, + blinkfeatures: React.PropTypes.string, + disableblinkfeatures: React.PropTypes.string, + disablewebsecurity: React.PropTypes.bool, + httpreferrer: React.PropTypes.string, + nodeintegration: React.PropTypes.bool, + partition: React.PropTypes.string, + plugins: React.PropTypes.bool, + preload: React.PropTypes.string, + src: React.PropTypes.string +} +const WEBVIEW_ATTRS = Object.keys(WEBVIEW_PROPS) + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + displayName: 'BrowserView', + propTypes: Object.assign({ + className: React.PropTypes.string + }, WEBVIEW_PROPS, REACT_WEBVIEW_EVENTS.reduce((acc, name) => { + acc[name] = React.PropTypes.func + return acc + }, {})), + statics: { + REACT_WEBVIEW_EVENTS: REACT_WEBVIEW_EVENTS + }, + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + componentDidMount () { + this.ipcPromises = {} + + const node = this.getWebviewNode() + WEBVIEW_EVENTS.forEach((name) => { + node.addEventListener(name, (evt) => { + this.dispatchWebViewEvent(name, evt) + }) + }) + }, + + componentWillReceiveProps (nextProps) { + const changed = WEBVIEW_ATTRS.filter((name) => this.props[name] !== nextProps[name]) + if (changed.length) { + const node = this.getWebviewNode() + changed.forEach((name) => { + node.setAttribute(name, nextProps[name] || '') + }) + } + }, + + shouldComponentUpdate () { + return false // we never want to re-render. We will handle this manually + }, + + /* **************************************************************************/ + // Events + /* **************************************************************************/ + + /** + * Dispatches a webview event to the appropriate handler + * @param name: the name of the event + * @param evt: the event that fired + */ + dispatchWebViewEvent (name, evt) { + if (this.props[camelCase(name)]) { + if (name === 'ipc-message') { + const didSiphon = this.siphonIPCMessage(evt) + if (didSiphon) { return } + } + + this.props[camelCase(name)](evt) + } + }, + + /** + * Siphons IPC messages + * @param evt: the event that occured + * @return true if the event was handled in the siphon + */ + siphonIPCMessage (evt) { + if (evt.channel.type.indexOf(SEND_RESPOND_PREFIX) === 0) { + if (this.ipcPromises[evt.channel.type]) { + clearTimeout(this.ipcPromises[evt.channel.type].timeout) + this.ipcPromises[evt.channel.type].resolve(evt.channel.data) + delete this.ipcPromises[evt.channel.type] + } + return true + } else if (evt.channel.type === 'elevated-log') { + console.log.apply(this, ['[ELEVATED LOG]', this.getWebviewNode()].concat(evt.channel.messages)) + return true + } else if (evt.channel.type === 'elevated-error') { + console.error.apply(this, ['[ELEVATED ERROR]', this.getWebviewNode()].concat(evt.channel.messages)) + return true + } else { + return false + } + }, + + /* **************************************************************************/ + // Webview calls + /* **************************************************************************/ + + focus () { + const node = this.getWebviewNode() + if (document.activeElement !== node) { + this.getWebviewNode().focus() + } + }, + + blur () { this.getWebviewNode().blur() }, + + openDevTools () { this.getWebviewNode().openDevTools() }, + + send (name, obj) { this.getWebviewNode().send(name, obj) }, + + findInPage (text, options) { return this.getWebviewNode().findInPage(text, options) }, + + stopFindInPage (action) { this.getWebviewNode().stopFindInPage(action) }, + + navigateBack () { this.getWebviewNode().goBack() }, + + navigateForward () { this.getWebviewNode().goForward() }, + + undo () { this.getWebviewNode().undo() }, + + redo () { this.getWebviewNode().redo() }, + + cut () { this.getWebviewNode().cut() }, + + copy () { this.getWebviewNode().copy() }, + + paste () { this.getWebviewNode().paste() }, + + pasteAndMatchStyle () { this.getWebviewNode().pasteAndMatchStyle() }, + + selectAll () { this.getWebviewNode().selectAll() }, + + setZoomLevel (level) { this.getWebviewNode().setZoomFactor(level) }, + + reload () { this.getWebviewNode().reloadIgnoringCache() }, + + /* **************************************************************************/ + // IPC Utils + /* **************************************************************************/ + + /** + * Calls into the webview to get process memory info + * @return promise + */ + getProcessMemoryInfo () { + return this.sendWithResponse('get-process-memory-info') + }, + + /** + * Calls into the webview to get some data + * @param sendName: the name to send to the webview + * @param obj={}: the object to send into the webview. Note __respond__ is reserved + * @param timeout=5000: the timeout before rejection + * @return promise + */ + sendWithResponse (sendName, obj = {}, timeout = 5000) { + return new Promise((resolve, reject) => { + const id = Math.random().toString() + const respondName = SEND_RESPOND_PREFIX + ':' + sendName + ':' + id + const rejectTimeout = setTimeout(() => { + delete this.ipcPromises[respondName] + reject({ timeout: true }) + }, timeout) + this.ipcPromises[respondName] = { resolve: resolve, timeout: rejectTimeout } + this.getWebviewNode().send(sendName, Object.assign({}, obj, { __respond__: respondName })) + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + /** + * @return the webview node + */ + getWebviewNode () { + return ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] + }, + + render () { + const attrs = WEBVIEW_ATTRS + .filter((k) => this.props[k] !== undefined) + .map((k) => `${k}="${this.props[k]}"`) + .concat([ + 'style="position:absolute; top:0; bottom:0; right:0; left:0;"' + ]) + .join(' ') + + return ( +
`}} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/Components/index.js b/src/scenes/mailboxes/src/Components/index.js index 1dc3da17..b3227f3a 100644 --- a/src/scenes/mailboxes/src/Components/index.js +++ b/src/scenes/mailboxes/src/Components/index.js @@ -1,4 +1,8 @@ module.exports = { ColorPickerButton: require('./ColorPickerButton'), - Flexbox: require('./Flexbox') + Grid: require('./Grid'), + TrayIconEditor: require('./TrayIconEditor'), + TrayPreview: require('./TrayPreview'), + TrayRenderer: require('./TrayRenderer'), + WebView: require('./WebView') } diff --git a/src/scenes/mailboxes/src/Dispatch/index.js b/src/scenes/mailboxes/src/Dispatch/index.js new file mode 100644 index 00000000..46558202 --- /dev/null +++ b/src/scenes/mailboxes/src/Dispatch/index.js @@ -0,0 +1,4 @@ +module.exports = { + mailboxDispatch: require('./mailboxDispatch'), + navigationDispatch: require('./navigationDispatch') +} diff --git a/src/scenes/mailboxes/src/Dispatch/mailboxDispatch.js b/src/scenes/mailboxes/src/Dispatch/mailboxDispatch.js new file mode 100644 index 00000000..445eca8e --- /dev/null +++ b/src/scenes/mailboxes/src/Dispatch/mailboxDispatch.js @@ -0,0 +1,186 @@ +const Minivents = require('minivents') + +class MailboxDispatch { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.__responders__ = {} + Minivents(this) + } + + /* **************************************************************************/ + // Responders + /* **************************************************************************/ + + /** + * Adds a responder + * @param name: the name of the responder + * @param fn: the function to respond with + */ + respond (name, fn) { + if (this.__responders__[name]) { + this.__responders__[name].push(fn) + } else { + this.__responders__[name] = [fn] + } + } + + /** + * Unregisteres a responder + * @param name: the name of the responder + * @param fn: the function to remove + */ + unrespond (name, fn) { + if (this.__responders__[name]) { + this.__responders__[name] = this.__responders__[name].filter((f) => f !== fn) + } + } + + /** + * Makes a fetch to a set of responders + * @param name: the name of the responder to call + * @param args=undefined: arguments to pass to the responders + * @param timeout=undefined: set to a ms to provide a timeout + * @return promise + */ + request (name, args = undefined, timeout = undefined) { + if (!this.__responders__[name] || this.__responders__[name].length === 0) { + return Promise.resolve([]) + } + + const requestPromise = Promise.all(this.__responders__[name].map((fn) => fn(args))) + if (timeout === undefined) { + return requestPromise + } else { + return Promise.race([ + requestPromise, + new Promise((resolve, reject) => { + setTimeout(() => reject({ timeout: true }), timeout) + }) + ]) + } + } + + /* **************************************************************************/ + // Responders : Higher level + /* **************************************************************************/ + + /** + * Fetches the process memory info for all webviews + * @return promise with the array of infos + */ + fetchProcessMemoryInfo () { + return this.request('fetch-process-memory-info') + } + + /** + * Fetches the gmail unread count + * @param mailboxId: the id of the mailbox + * @return promise with the unread count or undefined + */ + fetchGmailUnreadCount (mailboxId) { + return this.request('get-google-unread-count:' + mailboxId, {}, 1000) + .then((responses) => { + return Promise.resolve((responses[0] || {})) + }) + } + + /** + * Fetches the gmail unread count and retries on timeout + * @param mailboxId: the id of the mailbox + * @param maxRetries=30: the number of retries to attempt. A second between each + * @return promise with the unread count or undefined + */ + fetchGmailUnreadCountWithRetry (mailboxId, maxRetries = 30) { + return new Promise((resolve, reject) => { + const tryFetch = (tries) => { + this.fetchGmailUnreadCount(mailboxId).then( + (res) => resolve(res), + (err) => { + if (err.timeout && tries < maxRetries) { + setTimeout(() => tryFetch(tries + 1), 1000) + } else { + reject(err) + } + }) + } + tryFetch(0) + }) + } + + /* **************************************************************************/ + // Event Fires + /* **************************************************************************/ + + /** + * Emits a open dev tools command + * @param mailboxId: the id of the mailbox + * @param service: the service to open for + */ + openDevTools (mailboxId, service) { + this.emit('devtools', { mailboxId: mailboxId, service: service }) + } + + /** + * Emits a focus event for a mailbox + * @param mailboxId=undefined: the id of the mailbox + * @param service=undefined: the service of the mailbox + */ + refocus (mailboxId = undefined, service = undefined) { + this.emit('refocus', { mailboxId: mailboxId, service: service }) + } + + /** + * Reloads a mailbox + * @param mailboxId: the id of mailbox + * @param service: the service of the mailbox + */ + reload (mailboxId, service) { + this.emit('reload', { mailboxId: mailboxId, service: service, allServices: false }) + } + + /** + * Reloads all mailboxes services with the given id + * @param mailboxId: the id of mailbox + */ + reloadAllServices (mailboxId) { + this.emit('reload', { mailboxId: mailboxId, allServices: true }) + } + + /** + * Emis a blurred event for a mailbox + * @param mailboxId: the id of the mailbox + * @param service: the service of the mailbox + */ + blurred (mailboxId, service) { + this.emit('blurred', { mailboxId: mailboxId, service: service }) + } + + /** + * Emis a focused event for a mailbox + * @param mailboxId: the id of the mailbox + * @param service: the service of the mailbox + */ + focused (mailboxId, service) { + this.emit('focused', { mailboxId: mailboxId, service: service }) + } + + /** + * Emits an open message event for a mailbox + * @param mailboxId: the id of the mailbox + * @param threadId: the id of the thread + * @param messageId: the id of the message to open + */ + openMessage (mailboxId, threadId, messageId) { + this.emit('openMessage', { + mailboxId: mailboxId, + threadId: threadId, + messageId: messageId + }) + } +} + +module.exports = new MailboxDispatch() diff --git a/src/scenes/mailboxes/src/ui/Dispatch/navigationDispatch.js b/src/scenes/mailboxes/src/Dispatch/navigationDispatch.js similarity index 66% rename from src/scenes/mailboxes/src/ui/Dispatch/navigationDispatch.js rename to src/scenes/mailboxes/src/Dispatch/navigationDispatch.js index f5e7f177..262f3d29 100644 --- a/src/scenes/mailboxes/src/ui/Dispatch/navigationDispatch.js +++ b/src/scenes/mailboxes/src/Dispatch/navigationDispatch.js @@ -15,7 +15,7 @@ class NavigationDispatch { * Binds the listeners to the ipc renderer */ bindIPCListeners () { - ipcRenderer.on('launch-settings', this.openSettings) + ipcRenderer.on('launch-settings', () => { this.openSettings() }) return this } @@ -30,6 +30,25 @@ class NavigationDispatch { this.emit('opensettings', {}) } + /** + * Opens the settings at a mailbox + * @param mailboxId: the id of the mailbox + */ + openMailboxSettings (mailboxId) { + this.emit('opensettings', { + route: { + tab: 'accounts', + mailboxId: mailboxId + } + }) + } + + /** + * Opens the news + */ + openNews () { + this.emit('opennews', {}) + } } module.exports = new NavigationDispatch() diff --git a/src/scenes/mailboxes/src/Notifications/Notification.js b/src/scenes/mailboxes/src/Notifications/Notification.js new file mode 100644 index 00000000..1419c70a --- /dev/null +++ b/src/scenes/mailboxes/src/Notifications/Notification.js @@ -0,0 +1,42 @@ +const { BrowserWindow } = window.nativeRequire('electron').remote +const path = require('path') + +class Notification { + constructor (text, options) { + this.__options__ = Object.assign({}, options) + this.browserWindow = new BrowserWindow({ + x: 0, + y: 0, + useContentSize: true, + show: false, + autoHideMenuBar: true, + frame: false, + resizable: false, + skipTaskbar: true, + alwaysOnTop: true, + backgroundColor: '#FFF', + webPreferences: { + nodeIntegration: true + } + }) + const htmlPath = 'file://' + path.join(path.dirname(window.location.href.replace('file://', '')), 'notification.html') + this.browserWindow.loadURL(htmlPath) + this.browserWindow.once('ready-to-show', () => { + this.browserWindow.webContents.executeJavaScript(`window.renderNotification.apply(this, ${JSON.stringify([text, options])})`) + this.browserWindow.show() + this.browserWindow.webContents.openDevTools() + }) + + setTimeout(() => { + this.close() + }, 3000) + } + + close () { + if (!this.browserWindow) { return } + this.browserWindow.close() + this.browserWindow = null + } +} + +module.exports = Notification diff --git a/src/scenes/mailboxes/src/daemons/UnreadNotifications.js b/src/scenes/mailboxes/src/Notifications/UnreadNotifications.js similarity index 68% rename from src/scenes/mailboxes/src/daemons/UnreadNotifications.js rename to src/scenes/mailboxes/src/Notifications/UnreadNotifications.js index b02e1080..fc6ae878 100644 --- a/src/scenes/mailboxes/src/daemons/UnreadNotifications.js +++ b/src/scenes/mailboxes/src/Notifications/UnreadNotifications.js @@ -3,7 +3,8 @@ const flux = { settings: require('../stores/settings') } const constants = require('shared/constants') -const mailboxDispatch = require('../ui/Dispatch/mailboxDispatch') +const {mailboxDispatch} = require('../Dispatch') +const {ipcRenderer} = window.nativeRequire('electron') class UnreadNotifications { @@ -15,6 +16,10 @@ class UnreadNotifications { this.__s_mailboxesUpdated = (store) => this.mailboxesUpdated(store) this.__constructTime__ = new Date().getTime() this.__dispatching__ = false + /* window.NNotification = require('./Notification') + window.nn = new window.NNotification('my test title', { + body: 'Test body is here are it is my body and it is about some stuff and stuff and stuff that just seems to go on and on' + }) */ } /** @@ -44,41 +49,27 @@ class UnreadNotifications { mailboxesUpdated (store) { if (this.__dispatching__) { return } if (flux.settings.S.getState().os.notificationsEnabled === false) { return } - const firstRun = new Date().getTime() - this.__constructTime__ < constants.GMAIL_NOTIFICATION_FIRST_RUN_GRACE_MS - const firedList = {} - let fired = false + const now = new Date().getTime() + const firstRun = now - this.__constructTime__ < constants.GMAIL_NOTIFICATION_FIRST_RUN_GRACE_MS store.allMailboxes().forEach((mailbox, k) => { if (!mailbox.showNotifications) { return } - const unread = mailbox.google.unreadUnotifiedMessages - for (var messageId in unread) { - // Fire the notification - if (!firstRun) { - this.showNotification(mailbox, unread[messageId].message) - } + const lastInternalDate = mailbox.google.unnotifiedMessages.reduce((acc, message) => { + const messageDate = parseInt(message.internalDate) + const messageAge = now - messageDate - // Set that we've fired - fired = true - if (!firedList[mailbox.id]) { - firedList[mailbox.id] = [] + if (!firstRun && messageAge < constants.GMAIL_NOTIFICATION_MAX_MESSAGE_AGE_MS) { + this.showNotification(mailbox, message) } - firedList[mailbox.id].push(messageId) + + return messageDate > acc ? messageDate : acc + }, 0) + + if (lastInternalDate !== 0) { + flux.mailbox.A.setGoogleLastNotifiedInternalDate.defer(mailbox.id, lastInternalDate) } }) - - // We're in a dispatch cycle so requeue this in its own context - if (fired) { - this.__dispatching__ = true - const classThis = this - setTimeout(function () { - Object.keys(firedList).forEach((mailboxId) => { - flux.mailbox.A.setGoogleUnreadNotificationsShown(mailboxId, firedList[mailboxId]) - }) - classThis.__dispatching__ = false - classThis.mailboxesUpdated(flux.mailbox.S.getState()) - }) - } } /** @@ -88,8 +79,8 @@ class UnreadNotifications { * @return the notification */ showNotification (mailbox, message) { - const subject = (message.payload.headers.find((h) => h.name === 'Subject') || {}).value || 'No Subject' - const fromEmail = (message.payload.headers.find((h) => h.name === 'From') || {}).value || '' + const subject = (message.payload.headers.find((h) => h.name.toLowerCase() === 'subject') || {}).value || 'No Subject' + const fromEmail = (message.payload.headers.find((h) => h.name.toLowerCase() === 'from') || {}).value || '' // Extract the body let snippet = 'No Body' @@ -116,6 +107,7 @@ class UnreadNotifications { if (evt.target && evt.target.data) { const data = evt.target.data if (data.mailbox) { + ipcRenderer.send('focus-app', { }) flux.mailbox.A.changeActive(data.mailbox) mailboxDispatch.openMessage(data.mailbox, data.threadId, data.messageId) } diff --git a/src/scenes/mailboxes/src/ReactComponents.less b/src/scenes/mailboxes/src/ReactComponents.less new file mode 100644 index 00000000..f7cd94e4 --- /dev/null +++ b/src/scenes/mailboxes/src/ReactComponents.less @@ -0,0 +1,11 @@ +.ReactComponent-MaterialUI-Dialog-Body-Scrollbars { + &::-webkit-scrollbar { + -webkit-appearance: none; + width: 7px; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: rgba(0,0,0,.5); + -webkit-box-shadow: 0 0 1px rgba(255,255,255,.5); + } +} diff --git a/src/scenes/mailboxes/src/index.js b/src/scenes/mailboxes/src/index.js index 4e753aca..fbb1fdcc 100644 --- a/src/scenes/mailboxes/src/index.js +++ b/src/scenes/mailboxes/src/index.js @@ -1,13 +1,23 @@ +import './ReactComponents.less' const React = require('react') const ReactDOM = require('react-dom') const App = require('./ui/App') const mailboxActions = require('./stores/mailbox/mailboxActions') const settingsActions = require('./stores/settings/settingsActions') -const ipc = window.nativeRequire('electron').ipcRenderer +const composeActions = require('./stores/compose/composeActions') +const mailboxWizardActions = require('./stores/mailboxWizard/mailboxWizardActions') +const { ipcRenderer } = window.nativeRequire('electron') + +// See if we're offline and run a re-direct +if (window.navigator.onLine === false) { + window.location.href = 'offline.html' +} // Load what we have in the db mailboxActions.load() +mailboxWizardActions.load() settingsActions.load() +composeActions.load() // Remove loading ;(() => { @@ -17,6 +27,7 @@ settingsActions.load() // Render and prepare for unrender ReactDOM.render(, document.getElementById('app')) -ipc.on('prepare-reload', function () { +ipcRenderer.on('prepare-reload', function () { ReactDOM.unmountComponentAtNode(document.getElementById('app')) }) +ipcRenderer.send('mailboxes-js-loaded', {}) diff --git a/src/scenes/mailboxes/src/mailboxes.html b/src/scenes/mailboxes/src/mailboxes.html index 05a98354..ec294cf9 100644 --- a/src/scenes/mailboxes/src/mailboxes.html +++ b/src/scenes/mailboxes/src/mailboxes.html @@ -5,7 +5,11 @@ WMail + -
-
+
+
diff --git a/src/scenes/mailboxes/src/notification.html b/src/scenes/mailboxes/src/notification.html new file mode 100644 index 00000000..0ac26cc7 --- /dev/null +++ b/src/scenes/mailboxes/src/notification.html @@ -0,0 +1,131 @@ + + + + + + + + +
+
+
+
+
+
+
+
+
+
×
+ + + + diff --git a/src/scenes/mailboxes/src/offline.html b/src/scenes/mailboxes/src/offline.html new file mode 100644 index 00000000..873a47c2 --- /dev/null +++ b/src/scenes/mailboxes/src/offline.html @@ -0,0 +1,84 @@ + + + + + WMail + + + + +
+
+
+

There is no internet connection

+

Try checking your connection

+

+ Reload +

+
+
+ + + + diff --git a/src/scenes/mailboxes/src/stores/StorageBucket.js b/src/scenes/mailboxes/src/stores/StorageBucket.js new file mode 100644 index 00000000..57421e1b --- /dev/null +++ b/src/scenes/mailboxes/src/stores/StorageBucket.js @@ -0,0 +1,235 @@ +const {ipcRenderer} = window.nativeRequire('electron') + +class StorageBucket { + + /* ****************************************************************************/ + // Lifecycle + /* ****************************************************************************/ + + constructor (bucketName) { + this.__bucketName__ = bucketName + this.__lastCallId__ = 0 + this.__responseHandlers__ = new Map() + ipcRenderer.on(`storageBucket:${bucketName}:reply`, this._handleIPCReply.bind(this)) + } + + /* ****************************************************************************/ + // Utils + /* ****************************************************************************/ + + /** + * @return the next call id to use over the ipc + */ + _nextCallId () { + this.__lastCallId__++ + return this.__lastCallId__ + } + + /* ****************************************************************************/ + // IPC Response + /* ****************************************************************************/ + + /** + * Handles the responses coming back over ipc + * @param evt: the event that fired + * @param body: the body of the reply + */ + _handleIPCReply (evt, body) { + if (this.__responseHandlers__.has(body.id)) { + this.__responseHandlers__.get(body.id)(body) + this.__responseHandlers__.delete(body.id) + } + } + + /* ****************************************************************************/ + // Getters + /* ****************************************************************************/ + + /** + * @param k: the key of the item + * @param d=undefined: the default value if not exists + * @return promise with the value + */ + getItem (k, d) { + return new Promise((resolve) => { + const id = this._nextCallId() + this.__responseHandlers__.set(id, (body) => { + resolve(body.response || d) + }) + + ipcRenderer.send(`storageBucket:${this.__bucketName__}:getItem`, { id: id, key: k }) + }) + } + + /** + * @param k: the key of the item + * @param d=undefined: the default value if not exists + * @return the value + */ + getItemSync (k, d) { + const body = ipcRenderer.sendSync(`storageBucket:${this.__bucketName__}:getItem`, { key: k, sync: true }) + return body.response || d + } + + /** + * @param k: the key of the item + * @param d=undefined: the default value if not exists + * @return promise with the value + */ + getJSONItem (k, d) { + return this.getItem(k) + .then((v) => { + try { + return Promise.resolve(v ? JSON.parse(v) : d) + } catch (ex) { + return Promise.resolve({}) + } + }) + } + + /** + * @param k: the key of the item + * @param d=undefined: the default value if not exists + * @return the value + */ + getJSONItemSync (k, d) { + const str = this.getItemSync(k) + return str ? JSON.parse(str) : d + } + + /** + * @return promise with a list of all keys + */ + allKeys () { + return new Promise((resolve) => { + const id = this._nextCallId() + this.__responseHandlers__.set(id, (body) => { + resolve(body.response) + }) + + ipcRenderer.send(`storageBucket:${this.__bucketName__}:allKeys`, { id: id }) + }) + } + + /** + * @return all keys + */ + allKeysSync () { + return ipcRenderer.sendSync(`storageBucket:${this.__bucketName__}:allKeys`, { sync: true }).response + } + + /** + * @return promise with the items in an obj + */ + allItems () { + return new Promise((resolve) => { + const id = this._nextCallId() + this.__responseHandlers__.set(id, (body) => { + resolve(body.response) + }) + + ipcRenderer.send(`storageBucket:${this.__bucketName__}:allItems`, { id: id }) + }) + } + + /** + * @return all the items in an obj + */ + allItemsSync () { + return ipcRenderer.sendSync(`storageBucket:${this.__bucketName__}:allItems`, { sync: true }).response + } + + /** + * @return promise with the items in an obj + */ + allJSONItems () { + return this.allItems().then((items) => { + const jsonItems = Object.keys(items).reduce((acc, key) => { + acc[key] = JSON.parse(items[key]) + return acc + }, {}) + return Promise.resolve(jsonItems) + }) + } + + /** + * @return all the items in an obj + */ + allJSONItemsSync () { + const items = this.allItemsSync() + return Object.keys(items).reduce((acc, key) => { + acc[key] = JSON.parse(items[key]) + return acc + }, {}) + } + + /* ****************************************************************************/ + // Modifiers + /* ****************************************************************************/ + + /** + * @param k: the key of the item + * @param v: the value of the item + * @return promise + */ + setItem (k, v) { + return new Promise((resolve) => { + const id = this._nextCallId() + this.__responseHandlers__.set(id, (body) => { + resolve(body.response) + }) + + ipcRenderer.send(`storageBucket:${this.__bucketName__}:setItem`, { id: id, key: k, value: v }) + }) + } + + /** + * @param k: the key of the item + * @param v: the value of the item + */ + setItemSync (k, v) { + ipcRenderer.sendSync(`storageBucket:${this.__bucketName__}:setItem`, { key: k, value: v, sync: true }) + } + + /** + * @param k: the key of the item + * @param v: the json value of the item + * @return promise + */ + setJSONItem (k, v) { + return this.setItem(k, JSON.stringify(v)) + } + + /** + * @param k: the key of the item + * @param v: the json value of the item + * @return promise + */ + setJSONItemSync (k, v) { + this.setItemSync(k, JSON.stringify(v)) + } + + /** + * @param k: the key of the item + * @return promise + */ + removeItem (k) { + return new Promise((resolve) => { + const id = this._nextCallId() + this.__responseHandlers__.set(id, (body) => { + resolve(body.response) + }) + + ipcRenderer.send(`storageBucket:${this.__bucketName__}:removeItem`, { id: id, key: k }) + }) + } + + /** + * @param k: the key of the item + */ + removeItemSync (k) { + ipcRenderer.sendSync(`storageBucket:${this.__bucketName__}:removeItem`, { key: k, sync: true }) + } +} + +module.exports = StorageBucket diff --git a/src/scenes/mailboxes/src/stores/appWizard/appWizardActions.js b/src/scenes/mailboxes/src/stores/appWizard/appWizardActions.js new file mode 100644 index 00000000..f95934e1 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/appWizard/appWizardActions.js @@ -0,0 +1,30 @@ +const alt = require('../alt') + +class AppWizardActions { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + /** + * Starts the wizard + */ + startWizard () { return {} } + + /** + * Progresses the wizard to the next stage + */ + progressNextStep () { return {} } + + /** + * Cancels the wizard + */ + cancelWizard () { return {} } + + /** + * Cancel and discards the wizard + */ + discardWizard () { return {} } +} + +module.exports = alt.createActions(AppWizardActions) diff --git a/src/scenes/mailboxes/src/stores/appWizard/appWizardStore.js b/src/scenes/mailboxes/src/stores/appWizard/appWizardStore.js new file mode 100644 index 00000000..48253a3c --- /dev/null +++ b/src/scenes/mailboxes/src/stores/appWizard/appWizardStore.js @@ -0,0 +1,85 @@ +const alt = require('../alt') +const actions = require('./appWizardActions') +const settingsActions = require('../settings/settingsActions') + +class AppWizardStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.startOpen = false + this.trayConfiguratorOpen = false + this.mailtoHandlerOpen = false + this.completeOpen = false + + /** + * @return true if any configuration dialogs are open + */ + this.hasAnyItemsOpen = () => { + return this.startOpen || this.mailtoHandlerOpen || this.trayConfiguratorOpen || this.completeOpen + } + + /* ****************************************/ + // Listeners + /* ****************************************/ + + this.bindListeners({ + handleStartWizard: actions.START_WIZARD, + handleProgressNextStep: actions.PROGRESS_NEXT_STEP, + handleCancelWizard: actions.CANCEL_WIZARD, + handleDiscardWizard: actions.DISCARD_WIZARD + }) + } + + /* **************************************************************************/ + // Utils + /* **************************************************************************/ + + clearAll () { + this.startOpen = false + this.trayConfiguratorOpen = false + this.mailtoHandlerOpen = false + this.completeOpen = false + } + + /* **************************************************************************/ + // Handlers + /* **************************************************************************/ + + handleStartWizard () { + this.clearAll() + this.startOpen = true + } + + handleProgressNextStep () { + if (this.startOpen) { + this.clearAll() + this.trayConfiguratorOpen = true + } else if (this.trayConfiguratorOpen) { + this.clearAll() + if (process.platform === 'darwin' || process.platform === 'win32') { + this.mailtoHandlerOpen = true + } else { + this.completeOpen = true + } + } else if (this.mailtoHandlerOpen) { + this.clearAll() + this.completeOpen = true + } else if (this.completeOpen) { + this.clearAll() + settingsActions.setHasSeenAppWizard.defer(true) + } + } + + handleCancelWizard () { + this.clearAll() + } + + handleDiscardWizard () { + this.clearAll() + settingsActions.setHasSeenAppWizard.defer(true) + } +} + +module.exports = alt.createStore(AppWizardStore, 'AppWizardStore') diff --git a/src/scenes/mailboxes/src/stores/appWizard/index.js b/src/scenes/mailboxes/src/stores/appWizard/index.js new file mode 100644 index 00000000..01cae641 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/appWizard/index.js @@ -0,0 +1,6 @@ +module.exports = { + A: require('./appWizardActions'), + appWizardActions: require('./appWizardActions'), + S: require('./appWizardStore'), + appWizardStore: require('./appWizardStore') +} diff --git a/src/scenes/mailboxes/src/stores/compose/composeActions.js b/src/scenes/mailboxes/src/stores/compose/composeActions.js new file mode 100644 index 00000000..af1bc124 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/compose/composeActions.js @@ -0,0 +1,63 @@ +const alt = require('../alt') +const { ipcRenderer } = window.nativeRequire('electron') +const URI = require('urijs') +const addressparser = require('addressparser') + +class ComposeActions { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + load () { + return {} + } + + /* **************************************************************************/ + // New Message + /* **************************************************************************/ + + /** + * Composes a new message + * @param recipient=undefined: the recipient to send to + * @param subject=undefined: the subject of the message + * @param body=undefined: the body of the message + */ + composeNewMessage (recipient = undefined, subject = undefined, body = undefined) { + return { recipient: recipient, subject: subject, body: body } + } + + /** + * Clears the current compose + */ + clearCompose () { + return {} + } + + /** + * Sets the target mailbox + * @param mailboxId: the id of the mailbox + */ + setTargetMailbox (mailboxId) { + return { mailboxId: mailboxId } + } + + /** + * Opens a mailto link + * @param mailtoLink='': the link to try to open + */ + processMailtoLink (mailtoLink = '') { + if (mailtoLink.indexOf('mailto:') === 0) { + const uri = URI(mailtoLink || '') + const recipients = addressparser(decodeURIComponent(uri.pathname())).map((r) => r.address) + const qs = uri.search(true) + return this.composeNewMessage(recipients.join(','), qs.subject || qs.Subject, qs.body || qs.Body) + } else { + return { valid: false } + } + } +} + +const actions = alt.createActions(ComposeActions) +ipcRenderer.on('open-mailto-link', (evt, req) => actions.processMailtoLink(req.mailtoLink)) +module.exports = actions diff --git a/src/scenes/mailboxes/src/stores/compose/composeStore.js b/src/scenes/mailboxes/src/stores/compose/composeStore.js new file mode 100644 index 00000000..67e6cc6c --- /dev/null +++ b/src/scenes/mailboxes/src/stores/compose/composeStore.js @@ -0,0 +1,72 @@ +const alt = require('../alt') +const actions = require('./composeActions') +const uuid = require('uuid') +const { ipcRenderer } = window.nativeRequire('electron') + +class ComposeStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.composing = false + this.composeRef = uuid.v4() + this.recipient = undefined + this.subject = undefined + this.body = undefined + this.targetMailbox = undefined + + /* ****************************************/ + // Message Getters + /* ****************************************/ + + /** + * @return a dictionary containing just the message info + */ + this.getMessageInfo = () => { + return { + recipient: this.recipient, + subject: this.subject, + body: this.body + } + } + + /* ****************************************/ + // Listeners + /* ****************************************/ + this.bindListeners({ + handleComposeNewMessage: actions.COMPOSE_NEW_MESSAGE, + handleClearCompose: actions.CLEAR_COMPOSE, + handleSetTargetMailbox: actions.SET_TARGET_MAILBOX + }) + } + + /* **************************************************************************/ + // New Message + /* **************************************************************************/ + + handleComposeNewMessage ({ recipient, subject, body }) { + ipcRenderer.send('focus-app', { }) + this.composing = true + this.composeRef = uuid.v4() + this.recipient = recipient + this.subject = subject + this.body = body + this.targetMailbox = undefined + } + + handleClearCompose () { + this.composing = false + this.composeRef = uuid.v4() + this.recipient = undefined + this.subject = undefined + this.body = undefined + this.targetMailbox = undefined + } + + handleSetTargetMailbox ({ mailboxId }) { + this.targetMailbox = mailboxId + } +} + +module.exports = alt.createStore(ComposeStore, 'ComposeStore') diff --git a/src/scenes/mailboxes/src/stores/compose/index.js b/src/scenes/mailboxes/src/stores/compose/index.js new file mode 100644 index 00000000..af921309 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/compose/index.js @@ -0,0 +1,6 @@ +module.exports = { + A: require('./composeActions'), + composeActions: require('./composeActions'), + S: require('./composeStore'), + composeStore: require('./composeStore') +} diff --git a/src/scenes/mailboxes/src/stores/dictionaries/dictionariesActions.js b/src/scenes/mailboxes/src/stores/dictionaries/dictionariesActions.js new file mode 100644 index 00000000..20e52faa --- /dev/null +++ b/src/scenes/mailboxes/src/stores/dictionaries/dictionariesActions.js @@ -0,0 +1,43 @@ +const alt = require('../alt') +const uuid = require('uuid') + +class DictionariesActions { + + /* **************************************************************************/ + // Changing + /* **************************************************************************/ + + /** + * Starts the dictionary process + * @return { id } + */ + startDictionaryInstall () { + return { id: uuid.v4() } + } + + /** + * Finishes / cancels the dictionary change + */ + stopDictionaryInstall () { + return { } + } + + /** + * Starts the dictionary process + * @param id: the change id for validation + * @param lang: the lang code to change to + */ + pickDictionaryInstallLanguage (id, lang) { + return { id: id, lang: lang } + } + + /** + * Starts the dictionary install + * @param id: the change id for validation + */ + installDictionary (id) { + return { id: id } + } +} + +module.exports = alt.createActions(DictionariesActions) diff --git a/src/scenes/mailboxes/src/stores/dictionaries/dictionariesStore.js b/src/scenes/mailboxes/src/stores/dictionaries/dictionariesStore.js new file mode 100644 index 00000000..c8034a3b --- /dev/null +++ b/src/scenes/mailboxes/src/stores/dictionaries/dictionariesStore.js @@ -0,0 +1,221 @@ +const alt = require('../alt') +const actions = require('./dictionariesActions') +const dictionaries = require('shared/dictionaries.js') +const LanguageSettings = require('shared/Models/Settings/LanguageSettings') + +const fs = require('fs') +const path = require('path') +const pkg = window.appPackage() +const AppDirectory = window.appNodeModulesRequire('appdirectory') +const mkdirp = window.appNodeModulesRequire('mkdirp') +const appDirectory = new AppDirectory(pkg.name).userData() +const userDictionariesPath = LanguageSettings.userDictionariesPath(appDirectory) + +class DictionariesStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.installedCustomDictionaries = null + this.preinstalledDictionaries = ['en_US'] + this.install = { + id: null, + lang: null, + inflight: false, + success: false, + error: false + } + + /* ****************************************/ + // Installed Dictionaries + /* ****************************************/ + + /** + * @return the list of installed custom dictionaries + */ + this.getInstalledCustomDictionaries = () => { + if (this.installedCustomDictionaries === null) { + this.installedCustomDictionaries = this.crawlCustomDictionariesDirectory() + } + return this.installedCustomDictionaries + } + + /** + * @return the list of preinstalled dictionaries + */ + this.getPreinstalledDictionaries = () => { return Array.from(this.preinstalledDictionaries) } + + /** + * @return the list of all dictionaries + */ + this.getInstalledDictionaries = () => { + return this.getPreinstalledDictionaries().concat(this.getInstalledCustomDictionaries()) + } + + /** + * @return a list of all available dictionaries + */ + this.getAllDictionaries = () => { return Object.keys(dictionaries) } + + /** + * @return a list of not installed dictionaries + */ + this.getUninstalledDictionaries = () => { + const all = new Set(this.getAllDictionaries()) + this.getInstalledDictionaries().forEach((lang) => { + all.delete(lang) + }) + return Array.from(all) + } + + /** + * @param lang: the language to get the info for + * @return the information about this dictionary + */ + this.getDictionaryInfo = (lang) => { return Object.assign({ lang: lang }, dictionaries[lang]) } + + /** + * @return a list of uninstalled dictionary infos, sorted by name + */ + this.sortedUninstalledDictionaryInfos = () => { + return this.getUninstalledDictionaries() + .map((lang) => this.getDictionaryInfo(lang)) + .sort((a, b) => { + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + }) + } + + /** + * @return a list of installed dictionary infos, sorted by name + */ + this.sortedInstalledDictionaryInfos = () => { + return this.getInstalledDictionaries() + .map((lang) => this.getDictionaryInfo(lang)) + .sort((a, b) => { + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + }) + } + + /* ****************************************/ + // Installing + /* ****************************************/ + + this.isInstalling = () => { return this.install.id !== null } + this.installId = () => { return this.install.id } + this.installLanguage = () => { return this.install.lang } + this.installInflight = () => { return this.install.inflight } + + /* ****************************************/ + // Listeners + /* ****************************************/ + + this.bindListeners({ + handleStartDictionaryInstall: actions.START_DICTIONARY_INSTALL, + handleStopDictionaryInstall: actions.STOP_DICTIONARY_INSTALL, + handlePickDictionaryInstallLanguage: actions.PICK_DICTIONARY_INSTALL_LANGUAGE, + handleInstallDictionary: actions.INSTALL_DICTIONARY + }) + } + + /* **************************************************************************/ + // Utils + /* **************************************************************************/ + + /** + * Crawls the custom dictionries directory for installed dictionaries + * @return a list of installed dictionaries + */ + crawlCustomDictionariesDirectory () { + let files + try { + files = fs.readdirSync(userDictionariesPath) + } catch (ex) { + files = [] + } + + const dictionaries = files.reduce((acc, filename) => { + const ext = path.extname(filename).replace('.', '') + const lang = path.basename(filename, '.' + ext) + acc[lang] = acc[lang] || {} + acc[lang][ext] = true + return acc + }, {}) + return Object.keys(dictionaries).filter((lang) => dictionaries[lang].aff && dictionaries[lang].dic) + } + + /** + * @param update=undefined: update to merge in + * @return a blank install + */ + blankInstall (update) { + return Object.assign({ + id: null, + lang: null, + inflight: false, + success: false, + error: false + }, update) + } + + /* **************************************************************************/ + // Handlers: Changing Dict + /* **************************************************************************/ + + handleStartDictionaryInstall ({ id }) { + this.install = this.blankInstall({ id: id }) + } + + handleStopDictionaryInstall () { + this.install = this.blankInstall() + } + + handlePickDictionaryInstallLanguage ({ id, lang }) { + if (this.install.id !== id) { return } + this.install.lang = lang + } + + handleInstallDictionary ({ id }) { + if (this.install.id !== id) { return } + this.install.inflight = true + + const info = dictionaries[this.install.lang] + + Promise.all([ + Promise.resolve() + .then(() => window.fetch(info.aff)) + .then((res) => res.ok ? Promise.resolve(res) : Promise.reject(res)) + .then((res) => res.text()) + .then((aff) => { return { aff: aff } }), + Promise.resolve() + .then(() => window.fetch(info.dic)) + .then((res) => res.ok ? Promise.resolve(res) : Promise.reject(res)) + .then((res) => res.text()) + .then((dic) => { return { dic: dic } }) + ]) + .then((responses) => { + const data = responses.reduce((acc, res) => Object.assign(acc, res)) + const affPath = path.join(userDictionariesPath, this.install.lang + '.aff') + const dicPath = path.join(userDictionariesPath, this.install.lang + '.dic') + + mkdirp.sync(userDictionariesPath) + fs.writeFileSync(affPath, data.aff) + fs.writeFileSync(dicPath, data.dic) + + this.install.inflight = false + this.install.success = true + this.installedCustomDictionaries = null + this.emitChange() + }, (_err) => { + this.install.inflight = false + this.install.error = true + this.emitChange() + }) + } +} + +module.exports = alt.createStore(DictionariesStore, 'DictionariesStore') diff --git a/src/scenes/mailboxes/src/stores/dictionaries/index.js b/src/scenes/mailboxes/src/stores/dictionaries/index.js new file mode 100644 index 00000000..1bbada63 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/dictionaries/index.js @@ -0,0 +1,6 @@ +module.exports = { + A: require('./dictionariesActions'), + dictionariesActions: require('./dictionariesActions'), + S: require('./dictionariesStore'), + dictionariesStore: require('./dictionariesStore') +} diff --git a/src/scenes/mailboxes/src/stores/google/GoogleHTTPTransporter.js b/src/scenes/mailboxes/src/stores/google/GoogleHTTPTransporter.js new file mode 100644 index 00000000..9dc95fda --- /dev/null +++ b/src/scenes/mailboxes/src/stores/google/GoogleHTTPTransporter.js @@ -0,0 +1,116 @@ +const qs = require('qs') +const querystring = require('querystring') + +class GoogleHTTPTransporter { + + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + + get USER_AGENT () { return 'emulated/google-api-nodejs-client/0.10.0' } + + /* **************************************************************************/ + // Requests + /* **************************************************************************/ + + /** + * Makes a window.fetch request to the server whilst accepting arguments for nodejs request + * @param requestOpts: the options provided to the nodejs request lib + * @return promise from window.fetch + */ + fetchWithNodeRequestParams (requestOpts) { + const qsLib = requestOpts.useQuerystring ? querystring : qs + const method = (requestOpts.method || 'GET').toUpperCase() + const url = requestOpts.uri || requestOpts.url + + // Headers + const headers = Object.assign({}, requestOpts.headers) + if (requestOpts.json) { + headers['Accept'] = 'application/json' + } + if (!headers['User-Agent']) { + headers['User-Agent'] = this.USER_AGENT + } else if (headers['User-Agent'].indexOf(this.USER_AGENT) === -1) { + headers['User-Agent'] = headers['User-Agent'] + ' ' + this.USER_AGENT + } + + // Converted options + const fetchOpts = { + headers: headers, + method: method + } + + if (method === 'GET') { + const fullUrl = `${url}${requestOpts.qs ? '?' + qsLib.stringify(requestOpts.qs) : ''}` + return window.fetch(fullUrl, fetchOpts) + } else if (method === 'POST') { + // Body + if (requestOpts.form) { + fetchOpts.body = qsLib.stringify(requestOpts.form) + headers['Content-Type'] = 'application/x-www-form-urlencoded' + headers['Content-Length'] = fetchOpts.body.length + } else if (requestOpts.qs) { + if (requestOpts.json) { + fetchOpts.body = JSON.stringify(requestOpts.qs) + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = fetchOpts.body.length + } + } + + return window.fetch(url, fetchOpts) + } + } + + /** + * Makes a request with given options and invokes callback. + * @param opts={}: the options that are normally provided to nodejs request + * @param callback=undefined: the callback to execute on success or failure + */ + request (opts = {}, callback = undefined) { + this.fetchWithNodeRequestParams(opts) + .then((response) => { + if (callback) { + return response.text().then((body) => { + return { read: true, response: response, body: body } + }) + } else { + return { read: false, response: response } + } + }) + .then(({ read, response, body }) => { + if (!callback) { return } + + let err + try { + body = JSON.parse(body) + } catch (err) { /* no op */ } + + if (body && body.error && response.status !== 200) { + if (typeof body.error === 'string') { + err = new Error(body.error) + err.code = response.status + } else if (Array.isArray(body.error.errors)) { + err = new Error(body.error.errors.map((err) => err.message).join('\n')) + err.code = body.error.code + err.errors = body.error.errors + } else { + err = new Error(body.error.message) + err.code = body.error.code || response.status + } + body = null + } else if (response.status >= 500) { + err = new Error(body) + err.code = response.status + body = null + } + + callback(err, body, response) + }) + .catch((err) => { + if (!callback) { return } + callback(err, undefined, undefined) + }) + } +} + +module.exports = GoogleHTTPTransporter diff --git a/src/scenes/mailboxes/src/stores/google/googleActions.js b/src/scenes/mailboxes/src/stores/google/googleActions.js index 45c882f3..18f25554 100644 --- a/src/scenes/mailboxes/src/stores/google/googleActions.js +++ b/src/scenes/mailboxes/src/stores/google/googleActions.js @@ -1,17 +1,5 @@ const alt = require('../alt') -const constants = require('shared/constants') -const google = window.appNodeModulesRequire('googleapis') -const OAuth2 = google.auth.OAuth2 -const credentials = require('shared/credentials') -const googleHTTP = require('./googleHTTP') const mailboxStore = require('../mailbox/mailboxStore') -const mailboxActions = require('../mailbox/mailboxActions') -const settingsStore = require('../settings/settingsStore') -const Mailbox = require('shared/Models/Mailbox/Mailbox') -const {ipcRenderer} = window.nativeRequire('electron') -const reporter = require('../../reporter') - -const cachedAuths = new Map() class GoogleActions { @@ -19,173 +7,18 @@ class GoogleActions { // Pollers /* **************************************************************************/ - startPollingUpdates () { - return { - profiles: setInterval(() => { - this.syncAllMailboxProfiles() - }, constants.GMAIL_PROFILE_SYNC_MS), - - unread: setInterval(() => { - this.syncAllMailboxUnreadCounts() - }, constants.GMAIL_UNREAD_SYNC_MS), - - notification: setInterval(() => { - this.syncAllMailboxUnreadMessages() - }, constants.GMAIL_NOTIFICATION_SYNC_MS) - } - } - - stopPollingUpdates () { - return {} - } - - /* **************************************************************************/ - // API Auth - /* **************************************************************************/ - - /** - * Sets up the auth for a mailbox - * @param mailboxId: the id of the mailbox to setup for - * @return { auth, mailboxId } the mailbox auth and the mailbox id - */ - getAPIAuth (mailboxId) { - const mailbox = mailboxStore.getState().getMailbox(mailboxId) - let generate = false - if (cachedAuths.has(mailboxId)) { - if (cachedAuths.get(mailboxId).time !== mailbox.google.authTime) { - generate = true - } - } else { - generate = true - } - - if (generate && mailbox.google.hasAuth) { - const auth = new OAuth2(credentials.GOOGLE_CLIENT_ID, credentials.GOOGLE_CLIENT_SECRET) - auth.setCredentials({ - access_token: mailbox.google.accessToken, - refresh_token: mailbox.google.refreshToken, - expiry_date: mailbox.google.authExpiryTime - }) - cachedAuths.set(mailbox.id, { - time: mailbox.google.authTime, - auth: auth - }) - } - - return cachedAuths.get(mailboxId) - } - - /* **************************************************************************/ - // User Auth - /* **************************************************************************/ - - /** - * Starts the auth process for google inbox - */ - authInboxMailbox () { - ipcRenderer.send('auth-google', { id: Mailbox.provisionId(), type: 'ginbox' }) - return { } - } - - /** - * Starts the auth process for gmail - */ - authGmailMailbox () { - ipcRenderer.send('auth-google', { id: Mailbox.provisionId(), type: 'gmail' }) - return { } - } - - /** - * Handles a mailbox authenticating - * @param evt: the event that came over the ipc - * @param data: the data that came across the ipc - */ - authMailboxSuccess (evt, data) { - mailboxActions.create(data.id, { - type: data.type, - googleAuth: data.auth - }) - // Run the first sync - const mailbox = mailboxStore.getState().getMailbox(data.id) - const firstSync = this.syncMailbox(data.id) - return { mailbox: mailbox, firstSync: firstSync } - } - - /** - * Handles a mailbox authenticating error - * @param evt: the ipc event that fired - * @param data: the data that came across the ipc - */ - authMailboxFailure (evt, data) { - if (data.errorMessage.toLowerCase().indexOf('user') === 0) { - return { user: true, data: null } - } else { - // Really log wha we're getting here to try and resolve issue #2 - console.error('[AUTH ERR]', data) - console.error(data.errorString) - console.error(data.errorStack) - reporter.reportError('[AUTH ERR]' + data.errorString) - return { data: data, user: false } - } - } - - /* **************************************************************************/ - // Mailbox - /* **************************************************************************/ - /** - * Syncs all mailboxes + * Starts polling the server for updates on a periodic basis */ - syncAllMailboxes () { - const mailboxIds = mailboxStore.getState().mailboxIds() - if (mailboxIds.length === 0) { return { promises: Promise.resolve() } } - - const promises = mailboxIds.map((mailboxId) => { - return this.syncMailbox(mailboxId).promise - }) - - Promise.all(promises).then( - () => { this.syncAllMailboxesCompeted() }, - (e) => { this.syncAllMailboxesCompeted() }) - return { promises: promises } - } - - /** - * Indicates that all profiles have been synced - */ - syncAllMailboxesCompeted () { + startPollingUpdates () { return {} } /** - * Syncs all aspects of a mailbox - * @param mailboxId: the id of the mailbox to sync - */ - syncMailbox (mailboxId) { - const promise = Promise.resolve() - .then(() => this.syncMailboxProfile(mailboxId).promise) - .then(() => this.syncMailboxUnreadCount(mailboxId).promise) - .then( - () => { this.syncMailboxSuccess(mailboxId) }, - (err) => { this.syncMailboxFailure(mailboxId, err) }) - return { promise: promise } - } - - /** - * Indicates the mailbox synced successfully - * @param mailboxId: the id of the mailbox that synced + * Stops polling the server for updates */ - syncMailboxSuccess (mailboxId) { - return { mailboxId: mailboxId } - } - - /** - * Indicates the mailbox synced successfully - * @param mailboxId: the id of the mailbox that synced - * @param err: the error that occured - */ - syncMailboxFailure (mailboxId, err) { - return { mailboxId: mailboxId, err: err } + stopPollingUpdates () { + return {} } /* **************************************************************************/ @@ -197,22 +30,9 @@ class GoogleActions { */ syncAllMailboxProfiles () { const mailboxIds = mailboxStore.getState().mailboxIds() - if (mailboxIds.length === 0) { return { promises: Promise.resolve() } } - - const promises = mailboxIds.map((mailboxId) => { - return this.syncMailboxProfile(mailboxId).promise - }) + if (mailboxIds.length === 0) { return { promise: Promise.resolve() } } - Promise.all(promises).then( - () => { this.syncAllMailboxProfilesCompeted() }, - () => { this.syncAllMailboxProfilesCompeted() }) - return { promises: promises } - } - - /** - * Indicates that all profiles have been synced - */ - syncAllMailboxProfilesCompeted () { + mailboxIds.forEach((mailboxId) => { this.syncMailboxProfile.defer(mailboxId) }) return {} } @@ -221,28 +41,14 @@ class GoogleActions { * @param mailboxId: the id of the mailbox */ syncMailboxProfile (mailboxId) { - const { auth } = this.getAPIAuth(mailboxId) - - const promise = googleHTTP.fetchMailboxProfile(auth).then((response) => { - this.syncMailboxProfileSuccess(mailboxId, response) - }, (err) => { - this.syncMailboxProfileFailure(mailboxId, err) - }) - - return { mailboxId: mailboxId, promise: promise } + return { mailboxId: mailboxId } } /** * Deals with a mailbox sync completing * @param mailboxId: the id of the mailbox - * @param response: the response from the api */ - syncMailboxProfileSuccess (mailboxId, response) { - mailboxActions.update(mailboxId, { - avatar: response.response.image.url, - email: (response.response.emails.find((a) => a.type === 'account') || {}).value, - name: response.response.displayName - }) + syncMailboxProfileSuccess (mailboxId) { return { mailboxId: mailboxId } } @@ -252,8 +58,7 @@ class GoogleActions { * @param err: the error from the api */ syncMailboxProfileFailure (mailboxId, err) { - console.warn('[SYNC ERR] Mailbox Profile', err) - return { mailboxId: mailboxId } + return { mailboxId: mailboxId, err: err } } /* **************************************************************************/ @@ -262,213 +67,49 @@ class GoogleActions { /** * Syncs all profiles + * @param forceFullSync=false: set to true to avoid the cursory check */ - syncAllMailboxUnreadCounts () { + syncAllMailboxUnreadCounts (forceFullSync = false) { const mailboxIds = mailboxStore.getState().mailboxIds() - if (mailboxIds.length === 0) { return { promises: Promise.resolve() } } - - const promises = mailboxIds.map((mailboxId) => { - return this.syncMailboxUnreadCount(mailboxId).promise - }) - - Promise.all(promises).then( - () => { this.syncAllMailboxUnreadCountsCompleted() }, - () => { this.syncAllMailboxUnreadCountsCompleted() }) - return { promises: promises } - } + if (mailboxIds.length === 0) { return { promise: Promise.resolve() } } - /** - * Indicates that all profiles have been synced - */ - syncAllMailboxUnreadCountsCompleted () { + mailboxIds.forEach((mailboxId) => this.syncMailboxUnreadCount.defer(mailboxId, forceFullSync)) return {} } /** * Syncs the unread count for a set of mailboxes * @param mailboxId: the id of the mailbox + * @param forceFullSync=false: set to true to avoid the cursory check */ - syncMailboxUnreadCount (mailboxId) { - const { auth } = this.getAPIAuth(mailboxId) - const mailbox = mailboxStore.getState().getMailbox(mailboxId) - - const label = mailbox.google.unreadLabel - const labelField = mailbox.google.unreadLabelField - const promise = googleHTTP.fetchMailboxLabel(auth, mailbox.email, label).then((response) => { - this.syncMailboxUnreadCountSuccess(mailboxId, response, label, labelField) - }, (err) => { - this.syncMailboxUnreadCountFailure(mailboxId, err) - }) - - return { mailboxId: mailboxId, promise: promise } + syncMailboxUnreadCount (mailboxId, forceFullSync = false) { + return { mailboxId: mailboxId, forceFullSync: forceFullSync } } /** - * Deals with a mailbox unread count completing - * @param mailboxId: the id of the mailbox - * @param response: the response from the api - * @param label: the label that was searched - * @param labelField: the name of the field that should have the value taken from it - */ - syncMailboxUnreadCountSuccess (mailboxId, response, label, labelField) { - const prevMailbox = mailboxStore.getState().getMailbox(mailboxId) - // Look to see if the unread count has changed. If it has, update it - // then ask to sync the messages to provide info to the user in a timely fasion - if (prevMailbox && prevMailbox.unread !== response.response[labelField]) { - mailboxActions.update(mailboxId, { - unread: response.response[labelField] - }) - this.syncMailboxUnreadMessages(mailboxId) - return { mailboxId: mailboxId, changed: true } - } else { - return { mailboxId: mailboxId, changed: false } - } - } - - /** - * Deals with a mailbox unread count erroring + * Suggests that the store should sync an unread count, but could not be required * @param mailboxId: the id of the mailbox - * @param err: the error from the api */ - syncMailboxUnreadCountFailure (mailboxId, err) { - console.warn('[SYNC ERR] Mailbox Unread Count', err) + suggestSyncMailboxUnreadCount (mailboxId) { return { mailboxId: mailboxId } } - /* **************************************************************************/ - // Unread Messages - /* **************************************************************************/ - - /** - * Syncs all unread messages - */ - syncAllMailboxUnreadMessages () { - const mailboxIds = mailboxStore.getState().mailboxIds() - if (mailboxIds.length === 0) { return { promises: Promise.resolve() } } - - const promises = mailboxIds.map((mailboxId) => { - return this.syncMailboxUnreadMessages(mailboxId).promise - }) - - Promise.all(promises).then( - () => { this.syncAllMailboxUnreadMessagesCompleted() }, - () => { this.syncAllMailboxUnreadMessagesCompleted() }) - return { promises: promises } - } - - /** - * Indicates that all unread messages have been synced - */ - syncAllMailboxUnreadMessagesCompleted () { - return {} - } - /** - * Syncs the unread messages for a mailbox - * @param mailboxId: the id of the mailbox - */ - syncMailboxUnreadMessages (mailboxId) { - // Check not disabled globally - if (settingsStore.getState().os.notificationsEnabled === false) { - this.syncMailboxUnreadMessagesSuccess(mailboxId) - return { mailboxId: mailboxId, promise: Promise.resolve() } - } - - // Check not distabled for inbox / no unread messages - const mailbox = mailboxStore.getState().getMailbox(mailboxId) - if (mailbox.unread === 0 || !mailbox.showNotifications) { - this.syncMailboxUnreadMessagesSuccess(mailboxId) - return { mailboxId: mailboxId, promise: Promise.resolve() } - } - - // Start making calls to google - const { auth } = this.getAPIAuth(mailboxId) - const promise = Promise.resolve() - .then(() => googleHTTP.fetchEmailSummaries(auth, mailbox.email, mailbox.google.unreadQuery)) - .then((response) => { - const mailbox = mailboxStore.getState().getMailbox(mailboxId) - - // Mark the latest set of unread messages - const allMessageIds = (response.response.messages || []).map((data) => data.id) - const messageIds = (response.response.messages || []).reduce((acc, data) => { - // Look to see if we've seen this message already - // Also look to see if this is one of multiple in a thread - if (acc.threads[data.threadId]) { - acc.autoread.push(data.id) - } else { - if (mailbox.google.unreadMessages[data.id]) { - acc.seen.push(data.id) - } else { - acc.unseen.push(data.id) - } - acc.threads[data.threadId] = true - } - return acc - }, { seen: [], unseen: [], threads: {}, autoread: [] }) - - // Report that we've seen previously known messages - mailboxActions.setGoogleUnreadMessageIds(mailboxId, allMessageIds) - - // Mark auto-read thread items as seen and reported - mailboxActions.setGoogleUnreadNotificationsShown(mailboxId, messageIds.autoread) - - return Promise.resolve(messageIds.unseen) - }) - .then((messageIds) => { - if (!messageIds || messageIds.length === 0) { return Promise.resolve([]) } - - const { auth } = this.getAPIAuth(mailboxId) - const mailbox = mailboxStore.getState().getMailbox(mailboxId) - return Promise.all(messageIds.map((messageId) => { - return googleHTTP.fetchEmail(auth, mailbox.email, messageId).then( - (response) => Promise.resolve({ messageId: messageId, response: response }), - (error) => Promise.reject({ messageId: messageId, error: error }) - ) - })) - }) - .then((items) => { - if (items.length === 0) { return Promise.resolve() } - - items.forEach((item) => { - mailboxActions.updateGoogleUnread(mailboxId, item.messageId, { message: item.response.response }) - }) - - return Promise.resolve() - }) - .then(() => { - this.syncMailboxUnreadMessagesSuccess(mailboxId) - return Promise.resolve() - }, (error) => { - this.syncMailboxUnreadMessagesFailure(mailboxId, error) - return Promise.reject() - }) - - return { mailboxId: mailboxId, promise: promise } - } - - /** - * Deals with a mailbox unread messages completing + * Deals with a mailbox unread count completing * @param mailboxId: the id of the mailbox */ - syncMailboxUnreadMessagesSuccess (mailboxId) { + syncMailboxUnreadCountSuccess (mailboxId) { return { mailboxId: mailboxId } } /** - * Deals with a mailbox unread messages erroring + * Deals with a mailbox unread count erroring * @param mailboxId: the id of the mailbox * @param err: the error from the api */ - syncMailboxUnreadMessagesFailure (mailboxId, err) { - console.warn('[SYNC ERR] Mailbox Unread Messages', err) - return { mailboxId: mailboxId } + syncMailboxUnreadCountFailure (mailboxId, err) { + return { mailboxId: mailboxId, err: err } } - } -// Bind the IPC listeners -const actions = alt.createActions(GoogleActions) -ipcRenderer.on('auth-google-complete', actions.authMailboxSuccess) -ipcRenderer.on('auth-google-error', actions.authMailboxFailure) - -module.exports = actions +module.exports = alt.createActions(GoogleActions) diff --git a/src/scenes/mailboxes/src/stores/google/googleHTTP.js b/src/scenes/mailboxes/src/stores/google/googleHTTP.js index 349a3506..7a4c988e 100644 --- a/src/scenes/mailboxes/src/stores/google/googleHTTP.js +++ b/src/scenes/mailboxes/src/stores/google/googleHTTP.js @@ -1,9 +1,10 @@ const google = window.appNodeModulesRequire('googleapis') const gPlus = google.plus('v1') const gmail = google.gmail('v1') -const flux = { - settings: require('../settings') -} +const OAuth2 = google.auth.OAuth2 +const GoogleHTTPTransporter = require('./GoogleHTTPTransporter') +const querystring = require('querystring') +const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } = require('shared/credentials') class GoogleHTTP { @@ -11,18 +12,6 @@ class GoogleHTTP { // Utils /* **************************************************************************/ - /** - * @return the proxy information - */ - proxyInformation () { - const store = flux.settings.S.getState() - if (store.proxy.enabled) { - return store.proxy.url - } else { - return undefined - } - } - /** * Rejects a call because the mailbox has no authentication info * @param info: any information we have @@ -36,7 +25,54 @@ class GoogleHTTP { } /* **************************************************************************/ - // Fetch Profile and overview + // Auth + /* **************************************************************************/ + + /** + * Generates the auth token object to use with Google + * @param accessToken: the access token from the mailbox + * @param refreshToken: the refresh token from the mailbox + * @param expiryTime: the expiry time from the mailbox + * @return the google auth object + */ + generateAuth (accessToken, refreshToken, expiryTime) { + const auth = new OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) + auth.setCredentials({ + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: expiryTime + }) + auth.transporter = new GoogleHTTPTransporter() + return auth + } + + /** + * Upgrades the initial temporary access code to a permenant access code + * @param authCode: the temporary auth code + * @return promise + */ + upgradeAuthCodeToPermenant (authCode) { + return Promise.resolve() + .then(() => window.fetch('https://accounts.google.com/o/oauth2/token', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: querystring.stringify({ + code: authCode, + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + grant_type: 'authorization_code', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob' + }) + })) + .then((res) => res.ok ? Promise.resolve(res) : Promise.reject(res)) + .then((res) => res.json()) + } + + /* **************************************************************************/ + // Fetch Profile /* **************************************************************************/ /** @@ -50,8 +86,7 @@ class GoogleHTTP { return new Promise((resolve, reject) => { gPlus.people.get({ userId: 'me', - auth: auth, - proxy: this.proxyInformation() + auth: auth }, (err, response) => { if (err) { reject({ err: err }) @@ -62,22 +97,25 @@ class GoogleHTTP { }) } + /* **************************************************************************/ + // Label + /* **************************************************************************/ + /** - * Syncs the label for a mailbox + * Syncs the label for a mailbox. The label is a cheap call which can be used + * to decide if the mailbox has changed * @param auth: the auth to access google with - * @param userEmail: the email address of the user * @param labelId: the id of the label to sync * @return promise */ - fetchMailboxLabel (auth, userEmail, labelId) { - if (!auth) { return this.rejectWithNoAuth(userEmail) } + fetchMailboxLabel (auth, labelId) { + if (!auth) { return this.rejectWithNoAuth() } return new Promise((resolve, reject) => { gmail.users.labels.get({ - userId: userEmail, + userId: 'me', id: labelId, - auth: auth, - proxy: this.proxyInformation() + auth: auth }, (err, response) => { if (err) { reject({ err: err }) @@ -95,19 +133,19 @@ class GoogleHTTP { /** * Fetches the unread summaries for a mailbox * @param auth: the auth to access google with - * @param email: the email address to use * @param query: the query to ask the server for + * @param limit=10: the limit on results to fetch * @return promise */ - fetchEmailSummaries (auth, email, query) { - if (!auth) { return this.rejectWithNoAuth(email) } + fetchThreadIds (auth, query, limit = 25) { + if (!auth) { return this.rejectWithNoAuth() } return new Promise((resolve, reject) => { - gmail.users.messages.list({ - userId: email, + gmail.users.threads.list({ + userId: 'me', q: query, - auth: auth, - proxy: this.proxyInformation() + maxResults: limit, + auth: auth }, (err, response) => { if (err) { reject({ err: err }) @@ -121,19 +159,17 @@ class GoogleHTTP { /** * Fetches an email from a given id * @param auth: the auth to access google with - * @param email: the email address of the account - * @param emailId: the id of the email + * @param threadId: the id of the thread * @return promise */ - fetchEmail (auth, email, messageId) { - if (!auth) { return this.rejectWithNoAuth(email) } + fetchThread (auth, threadId) { + if (!auth) { return this.rejectWithNoAuth() } return new Promise((resolve, reject) => { - gmail.users.messages.get({ - userId: email, - id: messageId, - auth: auth, - proxy: this.proxyInformation() + gmail.users.threads.get({ + userId: 'me', + id: threadId, + auth: auth }, (err, response) => { if (err) { reject({ err: err }) diff --git a/src/scenes/mailboxes/src/stores/google/googleStore.js b/src/scenes/mailboxes/src/stores/google/googleStore.js index 9bcadbad..eca164df 100644 --- a/src/scenes/mailboxes/src/stores/google/googleStore.js +++ b/src/scenes/mailboxes/src/stores/google/googleStore.js @@ -1,5 +1,9 @@ const alt = require('../alt') const actions = require('./googleActions') +const { mailboxStore, mailboxActions } = require('../mailbox') +const googleHTTP = require('./googleHTTP') +const { mailboxDispatch } = require('../../Dispatch') +const constants = require('shared/constants') class GoogleStore { /* **************************************************************************/ @@ -7,9 +11,9 @@ class GoogleStore { /* **************************************************************************/ constructor () { + this.cachedAuths = new Map() this.profileSync = null this.unreadSync = null - this.notificationSync = null this.openProfileRequests = new Map() this.openUnreadCountRequests = new Map() @@ -39,13 +43,50 @@ class GoogleStore { handleSyncMailboxProfileFailure: actions.SYNC_MAILBOX_PROFILE_FAILURE, handleSyncMailboxUnreadCount: actions.SYNC_MAILBOX_UNREAD_COUNT, + handleSuggestSyncMailboxUnreadCount: actions.SUGGEST_SYNC_MAILBOX_UNREAD_COUNT, handleSyncMailboxUnreadCountSuccess: actions.SYNC_MAILBOX_UNREAD_COUNT_SUCCESS, handleSyncMailboxUnreadCountFailure: actions.SYNC_MAILBOX_UNREAD_COUNT_FAILURE }) } /* **************************************************************************/ - // Pollers + // Utils + /* **************************************************************************/ + + /** + * Sets up the auth for a mailbox + * @param mailboxId: the id of the mailbox to setup for + * @return { auth } the mailbox auth and the mailbox id + */ + getAPIAuth (mailboxId) { + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + if (!mailbox) { + return { auth: undefined } + } else { + return { auth: googleHTTP.generateAuth(mailbox.google.accessToken, mailbox.google.refreshToken, mailbox.google.authExpiryTime) } + } + } + + /* **************************************************************************/ + // Error Detection + /* **************************************************************************/ + + /** + * Checks if an error is an invalid grant error + * @param err: the error that was thrown + * @return true if this error is invalid grant + */ + isInvalidGrantError (err) { + if (err && typeof (err.message) === 'string') { + if (err.message.indexOf('invalid_grant') !== -1 || err.message.indexOf('Invalid Credentials') !== -1) { + return true + } + } + return false + } + + /* **************************************************************************/ + // Handlers: Pollers /* **************************************************************************/ /** @@ -56,11 +97,26 @@ class GoogleStore { */ handleStartPollSync ({profiles, unread, notification}) { clearInterval(this.profileSync) - this.profileSync = profiles + this.profileSync = setInterval(() => { + actions.syncAllMailboxProfiles() + }, constants.GMAIL_PROFILE_SYNC_MS) + clearInterval(this.unreadSync) - this.unreadSync = unread - clearInterval(this.notificationSync) - this.notificationSync = notification + this.unreadSync = (() => { + let partialCount = 0 + return setInterval(() => { + if (partialCount >= 5) { + actions.syncAllMailboxUnreadCounts(true) + partialCount = 0 + } else { + actions.syncAllMailboxUnreadCounts(false) + partialCount++ + } + }, constants.GMAIL_UNREAD_SYNC_MS) + })() + + actions.syncAllMailboxProfiles.defer() + actions.syncAllMailboxUnreadCounts.defer(true) } /** @@ -71,60 +127,202 @@ class GoogleStore { this.profileSync = null clearInterval(this.unreadSync) this.unreadSync = null - clearInterval(this.notificationSync) - this.notificationSync = null } /* **************************************************************************/ - // Requests + // Handlers: Profiles /* **************************************************************************/ - /** - * Records that a profile sync req is open - * @param mailboxId: the id of the mailbox - */ handleSyncMailboxProfile ({ mailboxId }) { this.openProfileRequests.set((this.openProfileRequests.get(mailboxId) || 0) + 1) + + const { auth } = this.getAPIAuth(mailboxId) + googleHTTP.fetchMailboxProfile(auth) + .then((response) => { + mailboxActions.setBasicProfileInfo( + mailboxId, + (response.response.emails.find((a) => a.type === 'account') || {}).value, + response.response.displayName, + response.response.image.url + ) + }) + .then( + (response) => actions.syncMailboxProfileSuccess(mailboxId), + (err) => actions.syncMailboxProfileFailure(mailboxId, err) + ) } - /** - * Records that a profile sync req completed - * @param mailboxId: the id of the mailbox - */ handleSyncMailboxProfileSuccess ({ mailboxId }) { this.openProfileRequests.set(this.openProfileRequests.get(mailboxId) - 1) + mailboxActions.setGoogleHasGrantError.defer(mailboxId, false) } - /** - * Records that a profile sync req completed - * @param mailboxId: the id of the mailbox - */ - handleSyncMailboxProfileFailure ({ mailboxId }) { + handleSyncMailboxProfileFailure ({ mailboxId, err }) { + if (this.isInvalidGrantError(err.err)) { + mailboxActions.setGoogleHasGrantError.defer(mailboxId, true) + } else { + console.warn('[SYNC ERR] Mailbox Profile', err) + } this.openProfileRequests.set(this.openProfileRequests.get(mailboxId) - 1) } - /** - * Records that a unread count sync req is open - * @param mailboxId: the id of the mailbox - */ - handleSyncMailboxUnreadCount ({ mailboxId }) { + /* **************************************************************************/ + // Handlers: Unread Counts + /* **************************************************************************/ + + handleSyncMailboxUnreadCount ({ mailboxId, forceFullSync }) { this.openUnreadCountRequests.set((this.openUnreadCountRequests.get(mailboxId) || 0) + 1) + const { auth } = this.getAPIAuth(mailboxId) + + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + const label = mailbox.google.unreadLabel + + Promise.resolve() + .then(() => { + // Step 1. Counts: Fetch the mailbox label + return Promise.resolve() + .then(() => { + // Step 1.1: call out to google + return googleHTTP.fetchMailboxLabel(auth, label) + }) + .then(({ response }) => { + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + + // Step 1.2. see if we are configured to grab the unread count from the ui + if (mailbox.google.takeLabelCountFromUI) { + return Promise.resolve() + .then(() => mailboxDispatch.fetchGmailUnreadCountWithRetry(mailboxId, forceFullSync ? 30 : 5)) + .then(({count, available}) => { + if (available) { + return Object.assign(response, { + unreadCountFromUI: true, + threadsUnread: count + }) + } else { + const passResponse = Object.assign(response, { + unreadCountFromUI: true + }) + delete passResponse.threadsUnread + return passResponse + } + }) + } else { + return response + } + }) + .then((response) => { + // Step 1.3: Update the models. Decide if we changed + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + mailboxActions.setGoogleLabelInfo(mailboxId, response) + return Promise.resolve({ + changed: forceFullSync || mailbox.google.messagesTotal !== response.messagesTotal + }) + }) + }) + .then(({changed}) => { + // Step 2. Message info: if we did change run a query to get the unread message count + if (!changed) { return Promise.resolve() } + + return Promise.resolve() + .then(() => { + // Step 2.1: Fetch the unread email ids + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + const unreadQuery = mailbox.google.unreadQuery + return googleHTTP.fetchThreadIds(auth, unreadQuery) + }) + .then(({ response }) => { + // Step 2.3: find the changed threads + const threads = response.threads || [] + + if (threads.length === 0) { return { threads: threads, changedThreads: [], resultSizeEstimate: response.resultSizeEstimate } } + + const mailbox = mailboxStore.getState().getMailbox(mailboxId) + const currentThreadsIndex = mailbox.google.latestUnreadThreads.reduce((acc, thread) => { + acc[thread.id] = thread + return acc + }, {}) + const changedThreads = threads.reduce((acc, thread) => { + if (!currentThreadsIndex[thread.id]) { + acc.push(thread) + } else if (currentThreadsIndex[thread.id].historyId !== thread.historyId) { + acc.push(thread) + } else if ((currentThreadsIndex[thread.id].messages || []).length === 0) { + acc.push(thread) + } + return acc + }, []) + + return { threads: threads, changedThreads: changedThreads, resultSizeEstimate: response.resultSizeEstimate } + }) + .then(({ threads, changedThreads, resultSizeEstimate }) => { + // Step 2.4: Grab the full threads + if (changedThreads.length === 0) { return { threads: threads, changedThreads: [], resultSizeEstimate: resultSizeEstimate } } + + return Promise.all(threads.map((thread) => { + return Promise.resolve() + .then(() => googleHTTP.fetchThread(auth, thread.id)) + .then(({response}) => response) + })) + .then((changedThreads) => { + return { threads: threads, changedThreads: changedThreads, resultSizeEstimate: resultSizeEstimate } + }) + }) + .then(({threads, changedThreads, resultSizeEstimate}) => { + // Step 2.5: Store the grabbed threads + if (changedThreads.length !== 0) { + const changedIndexed = changedThreads.reduce((acc, thread) => { + thread.messages = (thread.messages || []).map((message) => { + return { + id: message.id, + threadId: message.threadId, + historyId: message.historyId, + internalDate: message.internalDate, + snippet: message.snippet, + labelIds: message.labelIds, + payload: { + headers: message.payload.headers.filter((header) => { + const name = header.name.toLowerCase() + return name === 'subject' || name === 'from' || name === 'to' + }) + } + } + }) + acc[thread.id] = thread + return acc + }, {}) + + mailboxActions.setGoogleLatestUnreadThreads(mailboxId, threads, resultSizeEstimate, changedIndexed) + return { threads: threads, changedIndex: changedIndexed } + } else { + mailboxActions.setGoogleLatestUnreadThreads(mailboxId, threads, resultSizeEstimate, {}) + return { threads: threads, changedIndex: {} } + } + }) + }) + .then( + () => actions.syncMailboxUnreadCountSuccess(mailboxId), + (err) => actions.syncMailboxUnreadCountFailure(mailboxId, err) + ) + } + + handleSuggestSyncMailboxUnreadCount ({ mailboxId }) { + if (!this.hasOpenUnreadCountRequest(mailboxId)) { + actions.syncMailboxUnreadCount.defer(mailboxId) + } } - /** - * Records that a unread count sync req completed - * @param mailboxId: the id of the mailbox - */ handleSyncMailboxUnreadCountSuccess ({ mailboxId }) { this.openUnreadCountRequests.set(this.openUnreadCountRequests.get(mailboxId) - 1) + mailboxActions.setGoogleHasGrantError.defer(mailboxId, false) } - /** - * Records that a unread count sync req completed - * @param mailboxId: the id of the mailbox - */ - handleSyncMailboxUnreadCountFailure ({ mailboxId }) { + handleSyncMailboxUnreadCountFailure ({ mailboxId, err }) { this.openUnreadCountRequests.set(this.openUnreadCountRequests.get(mailboxId) - 1) + if (this.isInvalidGrantError(err.err)) { + mailboxActions.setGoogleHasGrantError.defer(mailboxId, true) + } else { + console.warn('[SYNC ERR] Mailbox Unread Count', err) + } } } diff --git a/src/scenes/mailboxes/src/stores/google/index.js b/src/scenes/mailboxes/src/stores/google/index.js index 036227a7..f26cf5c5 100644 --- a/src/scenes/mailboxes/src/stores/google/index.js +++ b/src/scenes/mailboxes/src/stores/google/index.js @@ -1,4 +1,6 @@ module.exports = { A: require('./googleActions'), - S: require('./googleStore') + googleActions: require('./googleActions'), + S: require('./googleStore'), + googleStore: require('./googleStore') } diff --git a/src/scenes/mailboxes/src/stores/http/httpActions.js b/src/scenes/mailboxes/src/stores/http/httpActions.js new file mode 100644 index 00000000..0503ef08 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/http/httpActions.js @@ -0,0 +1,62 @@ +const alt = require('../alt') +const uuid = require('uuid') + +class HttpActions { + + /* **************************************************************************/ + // Requests + /* **************************************************************************/ + + /** + * Fetches text from a remote endpoint + * @param url: the url of the license + * @param config: the config for the fetch request if required + * @return { id, promise, ... } tracking id + */ + fetchText (url, config) { + const id = uuid.v4() + const promise = Promise.resolve() + .then(() => window.fetch(url)) + .then((res) => res.ok ? Promise.resolve(res) : Promise.reject(res)) + .then((res) => res.text()) + .then((res) => { + this.requestSuccess(id, res) + }, (err) => { + this.requestFailure(id, err) + }) + + return { id: uuid.v4(), promise: promise } + } + + /** + * Indicates a request ended in success + * @param id: the id of the request + * @param data: the data that was received + */ + requestSuccess (id, data) { + return { id: id, data: data } + } + + /** + * Indicates a request ended in failure + * @param id: the id of the request + * @param err: the error that occured + */ + requestFailure (id, err) { + return { id: id, error: err } + } + + /* **************************************************************************/ + // Clearup + /* **************************************************************************/ + + /** + * Clears the response + * @param id: the id of the task + */ + clearResponse (id) { + return { id: id } + } +} + +module.exports = alt.createActions(HttpActions) diff --git a/src/scenes/mailboxes/src/stores/http/httpStore.js b/src/scenes/mailboxes/src/stores/http/httpStore.js new file mode 100644 index 00000000..ec980645 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/http/httpStore.js @@ -0,0 +1,73 @@ +const alt = require('../alt') +const actions = require('./httpActions') + +class HttpStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.requests = new Map() + + /* ****************************************/ + // Requests + /* ****************************************/ + + /** + * @param id: the id of the task + * @return true if this task is inflight + */ + this.isInflight = (id) => { + return this.tasks.has(id) ? this.tasks.get(id).inflight : false + } + + /** + * @param id: the id of the task + * @return the completion error, or undefined if none + */ + this.error = (id) => { + return this.tasks.has(id) ? this.tasks.get(id).error : undefined + } + + /** + * @param id: the id of the task + * @return the completion response, or undefined if none + */ + this.response = (id) => { + return this.tasks.has(id) ? this.tasks.get(id).response : undefined + } + + /* ****************************************/ + // Listeners + /* ****************************************/ + + this.bindListeners({ + handleFetchText: actions.FETCH_TEXT, + handleRequestSuccess: actions.REQUEST_SUCCESS, + handleRequestFailure: actions.REQUEST_FAILURE, + handleClearResponse: actions.CLEAR_RESPONSE + }) + } + + /* **************************************************************************/ + // Handlers: Fetch + /* **************************************************************************/ + + handleFetchText ({ id }) { + this.tasks.set(id, { inflight: true }) + } + + handleRequestSuccess ({ id, data }) { + this.tasks.set(id, { inflight: false, response: data }) + } + + handleRequestFailure ({ id, error }) { + this.tasks.set(id, { inflight: false, error: error }) + } + + handleClearResponse ({ id }) { + this.tasks.delete(id) + } +} + +module.exports = alt.createStore(HttpStore, 'HttpStore') diff --git a/src/scenes/mailboxes/src/stores/http/index.js b/src/scenes/mailboxes/src/stores/http/index.js new file mode 100644 index 00000000..74f50da8 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/http/index.js @@ -0,0 +1,6 @@ +module.exports = { + A: require('./httpActions'), + httpActions: require('./httpActions'), + S: require('./httpStore'), + httpStore: require('./httpStore') +} diff --git a/src/scenes/mailboxes/src/stores/mailbox/avatarPersistence.js b/src/scenes/mailboxes/src/stores/mailbox/avatarPersistence.js new file mode 100644 index 00000000..2e994424 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailbox/avatarPersistence.js @@ -0,0 +1,2 @@ +const StorageBucket = require('../StorageBucket') +module.exports = new StorageBucket('avatar') diff --git a/src/scenes/mailboxes/src/stores/mailbox/index.js b/src/scenes/mailboxes/src/stores/mailbox/index.js index afa9620a..19d487db 100644 --- a/src/scenes/mailboxes/src/stores/mailbox/index.js +++ b/src/scenes/mailboxes/src/stores/mailbox/index.js @@ -1,4 +1,6 @@ module.exports = { A: require('./mailboxActions'), - S: require('./mailboxStore') + mailboxActions: require('./mailboxActions'), + S: require('./mailboxStore'), + mailboxStore: require('./mailboxStore') } diff --git a/src/scenes/mailboxes/src/stores/mailbox/mailboxActions.js b/src/scenes/mailboxes/src/stores/mailbox/mailboxActions.js index 6e6ecde2..e8f98df6 100644 --- a/src/scenes/mailboxes/src/stores/mailbox/mailboxActions.js +++ b/src/scenes/mailboxes/src/stores/mailbox/mailboxActions.js @@ -1,5 +1,8 @@ const alt = require('../alt') -const {ipcRenderer} = window.nativeRequire('electron') +const { ipcRenderer, remote } = window.nativeRequire('electron') +const { session } = remote +const mailboxDispatch = require('../../Dispatch/mailboxDispatch') +const Mailbox = require('shared/Models/Mailbox/Mailbox') class MailboxActions { @@ -36,9 +39,16 @@ class MailboxActions { /** * Updates a mailbox * @param id: the id of the mailbox - * @param updates: the updates to apply + * @param updatesOrPath: an object indicating the updates to apply or the path string to apply to + * @param valueOrUndef: if path is set, the value to set */ - update (id, updates) { return { id: id, updates: updates } } + update (id, updatesOrPath, valueOrUndef) { + if (typeof (updatesOrPath) === 'string') { + return { id: id, updates: undefined, path: updatesOrPath, value: valueOrUndef } + } else { + return { id: id, updates: updatesOrPath, path: undefined, value: undefined } + } + } /** * Sets a custom avatar @@ -82,8 +92,109 @@ class MailboxActions { return this.update(id, { color: col }) } + /** + * Sets the basic profile info + * @param id: the mailbox id + * @param email: the users email address + * @param name: the accounts display name + * @param avatar: the accounts avatar + */ + setBasicProfileInfo (id, email, name, avatar) { + return this.update(id, { + avatar: avatar, + email: email, + name: name + }) + } + + /** + * Sets the custom css + * @param id: the mailbox id + * @param css: the css code + */ + setCustomCSS (id, css) { + return this.update(id, { customCSS: css }) + } + + /** + * Sets the custom js + * @param id: the mailbox id + * @param js: the js code + */ + setCustomJS (id, js) { + return this.update(id, { customJS: js }) + } + + /** + * Artificially persist the cookies for this mailbox + * @param id: the mailbox id + * @param persist: whether to persist the cookies + */ + artificiallyPersistCookies (id, persist) { + return this.update(id, { artificiallyPersistCookies: persist }) + } + /* **************************************************************************/ - // Updating: Active + // Updating: Services + /* **************************************************************************/ + + /** + * Adds a service + * @param id: the id of the mailbox + * @Param service: the service type + */ + addService (id, service) { + return { id: id, service: service } + } + + /** + * Removes a service + * @param id: the id of the mailbox + * @Param service: the service type + */ + removeService (id, service) { + return { id: id, service: service } + } + + /** + * Moves a service up + * @param id: the id of the mailbox + * @Param service: the service type + */ + moveServiceUp (id, service) { + return { id: id, service: service } + } + + /** + * Moves a service down + * @param id: the id of the mailbox + * @Param service: the service type + */ + moveServiceDown (id, service) { + return { id: id, service: service } + } + + /** + * Toggles the service sleepable state + * @param id: the id of the mailbox + * @param service: service type + * @param sleepable: true if the service is sleepable, false otherwise + */ + toggleServiceSleepable (id, service, sleepable) { + return { id: id, service: service, sleepable: sleepable } + } + + /** + * Sets the services to be compact + * @param id: the id of the mailbox + * @param compact: true to make them ompact + */ + setCompactServicesUI (id, compact) { + return this.update(id, { compactServicesUI: compact }) + } + + /* **************************************************************************/ + // Updating: Zoom /* **************************************************************************/ /** @@ -113,34 +224,43 @@ class MailboxActions { updateGoogleConfig (id, updates) { return { id: id, updates: updates } } /** - * Updates the google unread threads + * Sets the google unread count info * @param id: the id of the mailbox - * @param messageIdsOrMessageId: the ids of the messages or a single id - * @param updates: the updates to merge in + * @param countInfo: the info provided by google */ - updateGoogleUnread (id, messageIdsOrMessageId, updates) { - if (Array.isArray(messageIdsOrMessageId)) { - return { id: id, messageIds: messageIdsOrMessageId, updates: updates } - } else { - return { id: id, messageIds: [messageIdsOrMessageId], updates: updates } - } + setGoogleLabelInfo (id, info) { + return this.update(id, Object.keys(info).reduce((acc, key) => { + acc['googleLabelInfo_v2.' + key] = info[key] + return acc + }, {})) } /** - * Sets the current list of unread messages. Also marks the list as seen + * Sets the latest unread thread list * @param id: the id of the mailbox - * @param messageIds: the ids of the messages that are currently unread + * @param threadList: the list of threads as an array + * @param resultSizeEstimate: the size of the results + * @param fetchedThreads: the full threads that have been fetched sent as an object keyed by id */ - setGoogleUnreadMessageIds (id, messageIds) { - return { id: id, messageIds: messageIds } + setGoogleLatestUnreadThreads (id, threadList, resultSizeEstimate, fetchedThreads) { + return { id: id, threadList: threadList, fetchedThreads: fetchedThreads, resultSizeEstimate: resultSizeEstimate } } /** - * Sets that a thread has sent a notification + * Sets the last fired history id * @param id: the id of the mailbox - * @param messageIds: the ids of the messages + * @param historyId: the last historyId */ - setGoogleUnreadNotificationsShown (id, messageIds) { return { id: id, messageIds: messageIds } } + setGoogleLastNotifiedInternalDate (id, internalDate) { + return this.update(id, 'googleUnreadMessageInfo_v2.lastNotifiedInternalDate', parseInt(internalDate)) + } + + /** + * Sets the google auth info + */ + setGoogleAuth (id, auth) { + return this.update(id, 'googleAuth', auth) + } /* **************************************************************************/ // Active @@ -148,8 +268,53 @@ class MailboxActions { /** * Changes the active mailbox + * @param id: the id of the mailbox + * @param service=default: the service to change to + */ + changeActive (id, service = Mailbox.SERVICES.DEFAULT) { + return { id: id, service: service } + } + + /** + * Changes the active mailbox to the previous in the list + */ + changeActiveToPrev () { return {} } + + /** + * Changes the active mailbox to the next in the list + */ + changeActiveToNext () { return {} } + + /** + * Sets if the google config has a grant error + * @param id: the mailbox id + * @param hasError: true if there is an error, false otherwise */ - changeActive (id) { return { id: id } } + setGoogleHasGrantError (id, hasError) { + return { id: id, hasError: hasError } + } + + /* **************************************************************************/ + // Search + /* **************************************************************************/ + + /** + * Starts searching the mailbox + * @param id: the mailbox id + * @param service: the type of service to search for + */ + startSearchingMailbox (id, service) { + return {id: id, service: service} + } + + /** + * Stops searching the mailbox + * @param id: the mailbox id + * @param service: the type of service to stop search for + */ + stopSearchingMailbox (id, service) { + return {id: id, service: service} + } /* **************************************************************************/ // Ordering @@ -167,12 +332,50 @@ class MailboxActions { */ moveDown (id) { return { id: id } } + /* **************************************************************************/ + // Auth + /* **************************************************************************/ + + /** + * Reauthenticates the user by logging them out of the webview + * @param id: the id of the mailbox + */ + reauthenticateBrowserSession (id) { + const ses = session.fromPartition('persist:' + id) + const promise = Promise.resolve() + .then(() => { + return new Promise((resolve) => { + ses.clearStorageData(resolve) + }) + }) + .then(() => { + return new Promise((resolve) => { + ses.clearCache(resolve) + }) + }) + .then(() => { + mailboxDispatch.reloadAllServices(id) + return Promise.resolve() + }) + + return { promise: promise } + } + } const actions = alt.createActions(MailboxActions) ipcRenderer.on('mailbox-zoom-in', actions.increaseActiveZoom) ipcRenderer.on('mailbox-zoom-out', actions.decreaseActiveZoom) ipcRenderer.on('mailbox-zoom-reset', actions.resetActiveZoom) -ipcRenderer.on('switch-mailbox', (evt, req) => actions.changeActive(req.mailboxId)) +ipcRenderer.on('mailbox-window-find-start', () => actions.startSearchingMailbox()) +ipcRenderer.on('switch-mailbox', (evt, req) => { + if (req.mailboxId) { + actions.changeActive(req.mailboxId) + } else if (req.prev) { + actions.changeActiveToPrev() + } else if (req.next) { + actions.changeActiveToNext() + } +}) module.exports = actions diff --git a/src/scenes/mailboxes/src/stores/mailbox/mailboxPersistence.js b/src/scenes/mailboxes/src/stores/mailbox/mailboxPersistence.js new file mode 100644 index 00000000..e5919ad9 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailbox/mailboxPersistence.js @@ -0,0 +1,2 @@ +const StorageBucket = require('../StorageBucket') +module.exports = new StorageBucket('mailboxes') diff --git a/src/scenes/mailboxes/src/stores/mailbox/mailboxStore.js b/src/scenes/mailboxes/src/stores/mailbox/mailboxStore.js index 1332c4e5..76b861ab 100644 --- a/src/scenes/mailboxes/src/stores/mailbox/mailboxStore.js +++ b/src/scenes/mailboxes/src/stores/mailbox/mailboxStore.js @@ -3,15 +3,13 @@ const actions = require('./mailboxActions') const Mailbox = require('shared/Models/Mailbox/Mailbox') const uuid = require('uuid') const persistence = { - mailbox: window.remoteRequire('storage/mailboxStorage'), - avatar: window.remoteRequire('storage/avatarStorage') + mailbox: require('./mailboxPersistence'), + avatar: require('./avatarPersistence') } -const { - GMAIL_NOTIFICATION_MESSAGE_CLEANUP_AGE_MS, - MAILBOX_INDEX_KEY -} = require('shared/constants') +const { MAILBOX_INDEX_KEY } = require('shared/constants') const { BLANK_PNG } = require('shared/b64Assets') const migration = require('./migration') +const { ipcRenderer } = window.nativeRequire('electron') class MailboxStore { /* **************************************************************************/ @@ -23,6 +21,8 @@ class MailboxStore { this.mailboxes = new Map() this.avatars = new Map() this.active = null + this.activeService = Mailbox.SERVICES.DEFAULT + this.search = new Map() /* ****************************************/ // Fetching Mailboxes @@ -43,6 +43,11 @@ class MailboxStore { */ this.getMailbox = (id) => { return this.mailboxes.get(id) || null } + /** + * @return the count of mailboxes + */ + this.mailboxCount = () => { return this.mailboxes.size } + /* ****************************************/ // Avatar /* ****************************************/ @@ -58,11 +63,46 @@ class MailboxStore { */ this.activeMailboxId = () => { return this.active } + /** + * @return the service type of the active mailbox + */ + this.activeMailboxService = () => { + if (this.activeService === Mailbox.SERVICES.DEFAULT) { + return Mailbox.SERVICES.DEFAULT + } else { + const mailbox = this.activeMailbox() + const valid = mailbox.enabledServies.findIndex((s) => s === this.activeService) !== -1 + return valid ? this.activeService : Mailbox.SERVICES.DEFAULT + } + } + /** * @return the active mailbox */ this.activeMailbox = () => { return this.mailboxes.get(this.active) } + /** + * @param mailboxId: the id of the mailbox + * @param service: the type of service + * @return true if this mailbox is active, false otherwise + */ + this.isActive = (mailboxId, service) => { + return this.activeMailboxId() === mailboxId && this.activeMailboxService() === service + } + + /* ****************************************/ + // Search + /* ****************************************/ + + /** + * @param mailboxId: the id of the mailbox + * @param service: the service of the mailbox + * @return true if the mailbox is searching, false otherwise + */ + this.isSearchingMailbox = (mailboxId, service) => { + return this.search.get(`${mailboxId}:${service}`) === true + } + /* ****************************************/ // Aggregated queries /* ****************************************/ @@ -121,24 +161,50 @@ class MailboxStore { handleUpdate: actions.UPDATE, handleSetCustomAvatar: actions.SET_CUSTOM_AVATAR, + // Update: Services + handleAddService: actions.ADD_SERVICE, + handleRemoveService: actions.REMOVE_SERVICE, + handleMoveServiceUp: actions.MOVE_SERVICE_UP, + handleMoveServiceDown: actions.MOVE_SERVICE_DOWN, + handleToggleServiceSleepable: actions.TOGGLE_SERVICE_SLEEPABLE, + // Active Update handleIncreaseActiveZoom: actions.INCREASE_ACTIVE_ZOOM, handleDecreaseActiveZoom: actions.DECREASE_ACTIVE_ZOOM, handleResetActiveZoom: actions.RESET_ACTIVE_ZOOM, + // Search + handleStartSearchingMailbox: actions.START_SEARCHING_MAILBOX, + handleStopSearchingMailbox: actions.STOP_SEARCHING_MAILBOX, + // Google handleUpdateGoogleConfig: actions.UPDATE_GOOGLE_CONFIG, - handleSetGoogleUnreadMessageIds: actions.SET_GOOGLE_UNREAD_MESSAGE_IDS, - handleUpdateGoogleUnread: actions.UPDATE_GOOGLE_UNREAD, - handleSetGoogleUnreadNotificationsShown: actions.SET_GOOGLE_UNREAD_NOTIFICATIONS_SHOWN, + handleSetGoogleLatestUnreadThreads: actions.SET_GOOGLE_LATEST_UNREAD_THREADS, + handleSetGoogleHasGrantError: actions.SET_GOOGLE_HAS_GRANT_ERROR, // Active & Ordering handleChangeActive: actions.CHANGE_ACTIVE, + handleChangeActivePrev: actions.CHANGE_ACTIVE_TO_PREV, + handleChangeActiveNext: actions.CHANGE_ACTIVE_TO_NEXT, handleMoveUp: actions.MOVE_UP, handleMoveDown: actions.MOVE_DOWN }) } + /* **************************************************************************/ + // Utils + /* **************************************************************************/ + + /** + * Saves a mailbox + * @param mailboxId: the id of the mailbox + * @param mailboxJS: the js of the mailbox + */ + saveMailbox (mailboxId, mailboxJS) { + persistence.mailbox.setJSONItem(mailboxId, mailboxJS) + this.mailboxes.set(mailboxId, new Mailbox(mailboxId, mailboxJS)) + } + /* **************************************************************************/ // Handlers Load /* **************************************************************************/ @@ -151,8 +217,8 @@ class MailboxStore { migration.from_1_3_1() // Load - const allAvatars = persistence.avatar.allStrings() - const allMailboxes = persistence.mailbox.allItems() + const allAvatars = persistence.avatar.allItemsSync() + const allMailboxes = persistence.mailbox.allJSONItemsSync() this.index = [] // Mailboxes @@ -161,6 +227,7 @@ class MailboxStore { this.index = allMailboxes[MAILBOX_INDEX_KEY] } else { this.mailboxes.set(id, new Mailbox(id, allMailboxes[id])) + ipcRenderer.send('prepare-webview-session', { partition: 'persist:' + id }) } }) this.active = this.index[0] || null @@ -181,10 +248,10 @@ class MailboxStore { * @param data: the data to seed the mailbox with */ handleCreate ({id, data}) { - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) + this.saveMailbox(id, data) + ipcRenderer.send('prepare-webview-session', { partition: 'persist:' + id }) this.index.push(id) - persistence.mailbox.setItem(MAILBOX_INDEX_KEY, this.index) + persistence.mailbox.setJSONItem(MAILBOX_INDEX_KEY, this.index) this.active = id } @@ -195,7 +262,7 @@ class MailboxStore { handleRemove ({id}) { const wasActive = this.active === id this.index = this.index.filter((i) => i !== id) - persistence.mailbox.setItem(MAILBOX_INDEX_KEY, this.index) + persistence.mailbox.setJSONItem(MAILBOX_INDEX_KEY, this.index) this.mailboxes.delete(id) persistence.mailbox.removeItem(id) @@ -208,15 +275,43 @@ class MailboxStore { // Handlers Update /* **************************************************************************/ + /** + * Updates a mailboxJS object by taking the path and value + * @param mailboxJS: the mailbox js object to update in situ + * @param path: the path to update + * @param value: the value to update with + * @return the updated mailboxJS although this item has been updated in situ + */ + _updateMailboxJSWithPath_ (mailboxJS, path, value) { + let pointer = mailboxJS + path.split('.').forEach((fragment, index, fragments) => { + if (index === fragments.length - 1) { + pointer[fragment] = value + } else { + if (!pointer[fragment]) { + pointer[fragment] = {} + } + pointer = pointer[fragment] + } + }) + return mailboxJS + } + /** * Handles a mailbox updating * @param id: the id of the tem * @param updates: the updates to merge in */ - handleUpdate ({id, updates}) { - const mailboxJS = this.mailboxes.get(id).changeData(updates) - persistence.mailbox.setItem(id, mailboxJS) - this.mailboxes.set(id, new Mailbox(id, mailboxJS)) + handleUpdate ({id, updates, path, value}) { + const mailboxJS = this.mailboxes.get(id).cloneData() + if (updates !== undefined) { + Object.keys(updates).forEach((path) => { + this._updateMailboxJSWithPath_(mailboxJS, path, updates[path]) + }) + } else { + this._updateMailboxJSWithPath_(mailboxJS, path, value) + } + this.saveMailbox(id, mailboxJS) } /** @@ -230,7 +325,7 @@ class MailboxStore { if (b64Image) { const imageId = uuid.v4() data.customAvatar = imageId - persistence.avatar.setString(imageId, b64Image) + persistence.avatar.setItem(imageId, b64Image) this.avatars.set(imageId, b64Image) } else { if (data.customAvatar) { @@ -239,8 +334,65 @@ class MailboxStore { delete data.customAvatar } } - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) + this.saveMailbox(id, data) + } + + /* **************************************************************************/ + // Handlers Update Service + /* **************************************************************************/ + + handleAddService ({ id, service }) { + const mailbox = this.mailboxes.get(id) + + const supportedIndex = new Set(mailbox.supportedServices) + if (!supportedIndex.has(service)) { return } + + const enabledIndex = new Set(mailbox.enabledServies) + if (enabledIndex.has(service)) { return } + + this.saveMailbox(id, mailbox.changeData({ + services: Array.from(mailbox.enabledServies).concat(service) + })) + } + + handleRemoveService ({ id, service }) { + const mailbox = this.mailboxes.get(id) + this.saveMailbox(id, mailbox.changeData({ + services: Array.from(mailbox.enabledServies).filter((s) => s !== service) + })) + } + + handleMoveServiceUp ({ id, service }) { + const mailbox = this.mailboxes.get(id) + const services = Array.from(mailbox.enabledServies) + const serviceIndex = services.findIndex((s) => s === service) + if (serviceIndex !== -1 && serviceIndex !== 0) { + services.splice(serviceIndex - 1, 0, services.splice(serviceIndex, 1)[0]) + this.saveMailbox(id, mailbox.changeData({ + services: services + })) + } + } + + handleMoveServiceDown ({ id, service }) { + const mailbox = this.mailboxes.get(id) + const services = Array.from(mailbox.enabledServies) + const serviceIndex = services.findIndex((s) => s === service) + if (serviceIndex !== -1 && serviceIndex < services.length) { + services.splice(serviceIndex + 1, 0, services.splice(serviceIndex, 1)[0]) + this.saveMailbox(id, mailbox.changeData({ + services: services + })) + } + } + + handleToggleServiceSleepable ({ id, service, sleepable }) { + const mailbox = this.mailboxes.get(id) + const services = new Set(mailbox.sleepableServices) + services[sleepable ? 'add' : 'delete'](service) + this.saveMailbox(id, mailbox.changeData({ + sleepableServices: Array.from(services) + })) } /* **************************************************************************/ @@ -253,8 +405,7 @@ class MailboxStore { const mailboxJS = mailbox.changeData({ zoomFactor: Math.min(1.5, mailbox.zoomFactor + 0.1) }) - persistence.mailbox.setItem(mailbox.id, mailboxJS) - this.mailboxes.set(mailbox.id, new Mailbox(mailbox.id, mailboxJS)) + this.saveMailbox(mailbox.id, mailboxJS) } } @@ -264,8 +415,7 @@ class MailboxStore { const mailboxJS = mailbox.changeData({ zoomFactor: Math.min(1.5, mailbox.zoomFactor - 0.1) }) - persistence.mailbox.setItem(mailbox.id, mailboxJS) - this.mailboxes.set(mailbox.id, new Mailbox(mailbox.id, mailboxJS)) + this.saveMailbox(mailbox.id, mailboxJS) } } @@ -273,8 +423,7 @@ class MailboxStore { const mailbox = this.activeMailbox() if (mailbox) { const mailboxJS = mailbox.changeData({ zoomFactor: 1.0 }) - persistence.mailbox.setItem(mailbox.id, mailboxJS) - this.mailboxes.set(mailbox.id, new Mailbox(mailbox.id, mailboxJS)) + this.saveMailbox(mailbox.id, mailboxJS) } } @@ -284,122 +433,56 @@ class MailboxStore { /** * Handles the google config updating - * @param id: the id of the tem + * @param id: the id of the mailbox * @param updates: the updates to merge in */ handleUpdateGoogleConfig ({id, updates}) { const data = this.mailboxes.get(id).cloneData() data.googleConf = Object.assign(data.googleConf || {}, updates) - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) - } - - /** - * Marks the unread messages as seen & also marks any un-included messages - * as read - * @param id: the id of mailbox - * @param messageIds: the complete lis of unread message ids - */ - handleSetGoogleUnreadMessageIds ({id, messageIds}) { - const data = this.mailboxes.get(id).cloneData() - data.googleUnreadMessages = data.googleUnreadMessages || {} - - // Run through all the messages google has given us and mark them as unread - // and also mark them as seen - const now = new Date().getTime() - const messageIdIndex = {} - messageIds.forEach((messageId) => { - messageIdIndex[messageId] = true - if (data.googleUnreadMessages[messageId]) { - data.googleUnreadMessages[messageId] = Object.assign( - data.googleUnreadMessages[messageId], - { seen: now, unread: true } - ) - } else { - data.googleUnreadMessages[messageId] = { - recordCreated: now, seen: now, unread: true - } - } - }) - - // If we haven't seen a message from google, then it must be read, but - // we might want to keep the record around to prevent duplicate notificiations - Object.keys(data.googleUnreadMessages).forEach((messageId) => { - if (!messageIdIndex[messageId]) { - data.googleUnreadMessages[messageId].unread = false - } - }) - - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) + this.saveMailbox(id, data) } /** - * Merges the google unread items and removes any flags for updated ites + * Updates the google unread threads * @param id: the id of the mailbox - * @param messageIds: the ids of the messages - * @param updates: the updates to apply + * @param threadList: the complete thread list as an array + * @param fetchedThreads: the threads that were fetched in an object by id + * @param resultSizeEstimate: the size estimate */ - handleUpdateGoogleUnread ({id, messageIds, updates}) { - const data = this.mailboxes.get(id).cloneData() - data.googleUnreadMessages = data.googleUnreadMessages || {} - - // Add the update - const now = new Date().getTime() - messageIds.forEach((messageId) => { - if (data.googleUnreadMessages[messageId]) { - data.googleUnreadMessages[messageId] = Object.assign( - data.googleUnreadMessages[messageId], - { seen: now }, - updates) - } else { - data.googleUnreadMessages[messageId] = Object.assign({ - recordCreated: now, seen: now - }, updates) - } - }) - - // Clean up old records - data.googleUnreadMessages = Object.keys(data.googleUnreadMessages).reduce((acc, messageId) => { - const rec = data.googleUnreadMessages[messageId] - if (now - rec.seen < GMAIL_NOTIFICATION_MESSAGE_CLEANUP_AGE_MS) { - acc[messageId] = rec - } + handleSetGoogleLatestUnreadThreads ({ id, threadList, fetchedThreads, resultSizeEstimate }) { + const prevThreads = this.mailboxes.get(id).google.latestUnreadThreads.reduce((acc, thread) => { + acc[thread.id] = thread return acc }, {}) - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) - } + // Merge changes + const nextThreads = threadList.map((threadHead) => { + if (fetchedThreads[threadHead.id]) { + return fetchedThreads[threadHead.id] + } else if (prevThreads[threadHead.id]) { + return prevThreads[threadHead.id] + } else { + return undefined + } + }).filter((thread) => !!thread) - /** - * Sets that the given thread ids have sent notifications - * @param id: the id of the mailbox - * @param messageId: the id of the message to mark - */ - handleSetGoogleUnreadNotificationsShown ({id, messageIds}) { + // Write it const data = this.mailboxes.get(id).cloneData() - data.googleUnreadMessages = data.googleUnreadMessages || {} - - const now = new Date().getTime() - messageIds.forEach((messageId) => { - if (data.googleUnreadMessages[messageId]) { - data.googleUnreadMessages[messageId].notified = now - data.googleUnreadMessages[messageId].seen = now - } - }) + data.googleUnreadMessageInfo_v2 = data.googleUnreadMessageInfo_v2 || {} + data.googleUnreadMessageInfo_v2.latestUnreadThreads = nextThreads + data.googleUnreadMessageInfo_v2.resultSizeEstimate = resultSizeEstimate + this.saveMailbox(id, data) + } - // Clean up old records - data.googleUnreadMessages = Object.keys(data.googleUnreadMessages).reduce((acc, messageId) => { - const rec = data.googleUnreadMessages[messageId] - if (now - rec.seen < GMAIL_NOTIFICATION_MESSAGE_CLEANUP_AGE_MS) { - acc[messageId] = rec - } - return acc - }, {}) + handleSetGoogleHasGrantError ({ id, hasError }) { + const data = this.mailboxes.get(id).cloneData() - persistence.mailbox.setItem(id, data) - this.mailboxes.set(id, new Mailbox(id, data)) + if (data.googleAuth.invalidGrant !== hasError) { + data.googleAuth.invalidGrant = hasError + this.saveMailbox(id, data) + } else { + this.preventDefault() + } } /* **************************************************************************/ @@ -409,9 +492,29 @@ class MailboxStore { /** * Handles the active mailbox changing * @param id: the id of the mailbox + * @param service: the service type */ - handleChangeActive ({id}) { + handleChangeActive ({id, service}) { this.active = id + this.activeService = service + } + + /** + * Handles the active mailbox changing to the prev in the index + */ + handleChangeActivePrev () { + const activeIndex = this.index.findIndex((id) => id === this.active) + this.active = this.index[Math.max(0, activeIndex - 1)] || null + this.activeService = Mailbox.SERVICES.DEFAULT + } + + /** + * Handles the active mailbox changing to the next in the index + */ + handleChangeActiveNext () { + const activeIndex = this.index.findIndex((id) => id === this.active) + this.active = this.index[Math.min(this.index.length - 1, activeIndex + 1)] || null + this.activeService = Mailbox.SERVICES.DEFAULT } /** @@ -421,7 +524,7 @@ class MailboxStore { const mailboxIndex = this.index.findIndex((i) => i === id) if (mailboxIndex !== -1 && mailboxIndex !== 0) { this.index.splice(mailboxIndex - 1, 0, this.index.splice(mailboxIndex, 1)[0]) - persistence.mailbox.setItem(MAILBOX_INDEX_KEY, this.index) + persistence.mailbox.setJSONItem(MAILBOX_INDEX_KEY, this.index) } } @@ -432,7 +535,33 @@ class MailboxStore { const mailboxIndex = this.index.findIndex((i) => i === id) if (mailboxIndex !== -1 && mailboxIndex < this.index.length) { this.index.splice(mailboxIndex + 1, 0, this.index.splice(mailboxIndex, 1)[0]) - persistence.mailbox.setItem(MAILBOX_INDEX_KEY, this.index) + persistence.mailbox.setJSONItem(MAILBOX_INDEX_KEY, this.index) + } + } + + /* **************************************************************************/ + // Handlers : Search + /* **************************************************************************/ + + /** + * Indicates the mailbox is searching + */ + handleStartSearchingMailbox ({ id, service }) { + if (id && service) { + this.search.set(`${id}:${service}`, true) + } else { + this.search.set(`${this.active}:${this.activeService}`, true) + } + } + + /** + * Indicates the mailbox is no longer searching + */ + handleStopSearchingMailbox ({id, service}) { + if (id && service) { + this.search.delete(`${id}:${service}`) + } else { + this.search.delete(`${this.active}:${this.activeService}`) } } diff --git a/src/scenes/mailboxes/src/stores/mailbox/migration.js b/src/scenes/mailboxes/src/stores/mailbox/migration.js index 79e3aa36..6e812b2d 100644 --- a/src/scenes/mailboxes/src/stores/mailbox/migration.js +++ b/src/scenes/mailboxes/src/stores/mailbox/migration.js @@ -1,4 +1,4 @@ -const persistence = window.remoteRequire('storage/mailboxStorage') +const persistence = require('./mailboxPersistence') const {MAILBOX_INDEX_KEY} = require('shared/constants') module.exports = { @@ -17,13 +17,13 @@ module.exports = { // Write the new values Object.keys(mailboxes).forEach((mailboxId) => { - persistence.setItem(mailboxId, mailboxes[mailboxId]) + persistence.setJSONItemSync(mailboxId, mailboxes[mailboxId]) }) - persistence.setItem(MAILBOX_INDEX_KEY, index) + persistence.setJSONItemSync(MAILBOX_INDEX_KEY, index) // Write the completion - window.localStorage.setItem('pre_1_3_1:MailboxIndex', JSON.stringify(index)) - window.localStorage.removeItem('MailboxIndex') + window.localStorage.setItem('pre_1_3_1:Mailbox_index', JSON.stringify(index)) + window.localStorage.removeItem('Mailbox_index') Object.keys(mailboxes).forEach((mailboxId) => { window.localStorage.setItem('pre_1_3_1:Mailbox_' + mailboxId, JSON.stringify(mailboxes[mailboxId])) window.localStorage.removeItem('Mailbox_' + mailboxId) diff --git a/src/scenes/mailboxes/src/stores/mailboxWizard/Configurations.js b/src/scenes/mailboxes/src/stores/mailboxWizard/Configurations.js new file mode 100644 index 00000000..69b90bdc --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailboxWizard/Configurations.js @@ -0,0 +1,38 @@ +const { Mailbox, Google } = require('shared/Models/Mailbox') +const configurations = {} +configurations[Mailbox.TYPE_GMAIL] = { + DEFAULT_INBOX: { // Unread Messages in primary category + googleConf: { + takeLabelCountFromUI: false, + unreadMode: Google.UNREAD_MODES.PRIMARY_INBOX_UNREAD// + } + }, + PRIORIY_INBOX: { // Unread Important Messages + googleConf: { + takeLabelCountFromUI: false, + unreadMode: Google.UNREAD_MODES.INBOX_UNREAD_IMPORTANT + } + }, + UNREAD_INBOX: { // All Unread Messages + googleConf: { + takeLabelCountFromUI: false, + unreadMode: Google.UNREAD_MODES.INBOX_UNREAD + } + } +} +configurations[Mailbox.TYPE_GINBOX] = { + UNREAD_INBOX: { + googleConf: { + takeLabelCountFromUI: false, + unreadMode: Google.UNREAD_MODES.INBOX_UNREAD + } + }, + DEFAULT_INBOX: { + googleConf: { + takeLabelCountFromUI: false, + unreadMode: Google.UNREAD_MODES.GINBOX_DEFAULT + } + } +} + +module.exports = configurations diff --git a/src/scenes/mailboxes/src/stores/mailboxWizard/index.js b/src/scenes/mailboxes/src/stores/mailboxWizard/index.js new file mode 100644 index 00000000..4331c39b --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailboxWizard/index.js @@ -0,0 +1,7 @@ +module.exports = { + A: require('./mailboxWizardActions'), + mailboxWizardActions: require('./mailboxWizardActions'), + S: require('./mailboxWizardStore'), + mailboxWizardStore: require('./mailboxWizardStore'), + Configurations: require('./Configurations') +} diff --git a/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardActions.js b/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardActions.js new file mode 100644 index 00000000..3851a545 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardActions.js @@ -0,0 +1,105 @@ +const alt = require('../alt') +const { ipcRenderer } = window.nativeRequire('electron') +const { Mailbox } = require('shared/Models/Mailbox') + +class MailboxWizardActions { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + /** + * Loads any start off services + */ + load () { return {} } + + /* **************************************************************************/ + // Adding + /* **************************************************************************/ + + /** + * Opens the add mailbox picker + */ + openAddMailbox () { return {} } + + /** + * Dismisses the add mailbox picker + */ + cancelAddMailbox () { return {} } + + /** + * Starts the auth process for google inbox + */ + authenticateGinboxMailbox () { + return { provisionalId: Mailbox.provisionId() } + } + + /** + * Starts the auth process for gmail + */ + authenticateGmailMailbox () { + return { provisionalId: Mailbox.provisionId() } + } + + /** + * Reauthetnicates a google mailbox + * @param mailboxId: the id of the mailbox + */ + reauthenticateGoogleMailbox (mailboxId) { + return { mailboxId: mailboxId } + } + + /* **************************************************************************/ + // Authentication callbacks + /* **************************************************************************/ + + /** + * Handles a mailbox authenticating + * @param evt: the event that came over the ipc + * @param data: the data that came across the ipc + */ + authGoogleMailboxSuccess (evt, data) { + return { provisionalId: data.id, type: data.type, temporaryAuth: data.temporaryAuth, mode: data.mode } + } + + /** + * Handles a mailbox authenticating error + * @param evt: the ipc event that fired + * @param data: the data that came across the ipc + */ + authGoogleMailboxFailure (evt, data) { + return { evt: evt, data: data } + } + + /* **************************************************************************/ + // Config + /* **************************************************************************/ + + /** + * Configures an account + * @param configuration: the additional configuration to provide + */ + configureMailbox (configuration) { + return { configuration: configuration } + } + + /** + * Configures the enabled services + * @param enabledServices: the enabled servies + * @param compact: whether they should be compact or not + */ + configureMailboxServices (enabledServices, compact) { + return { enabledServices: enabledServices, compact: compact } + } + + /** + * Completes mailbox configuration + */ + configurationComplete () { return {} } +} + +const actions = alt.createActions(MailboxWizardActions) +ipcRenderer.on('auth-google-complete', actions.authGoogleMailboxSuccess) +ipcRenderer.on('auth-google-error', actions.authGoogleMailboxFailure) + +module.exports = actions diff --git a/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardStore.js b/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardStore.js new file mode 100644 index 00000000..8a6c1060 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/mailboxWizard/mailboxWizardStore.js @@ -0,0 +1,220 @@ +const alt = require('../alt') +const actions = require('./mailboxWizardActions') +const { Mailbox, Google } = require('shared/Models/Mailbox') +const { ipcRenderer } = window.nativeRequire('electron') +const reporter = require('../../reporter') +const mailboxActions = require('../mailbox/mailboxActions') +const googleActions = require('../google/googleActions') +const googleHTTP = require('../google/googleHTTP') +const pkg = window.appPackage() + +class MailboxWizardStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.addMailboxOpen = false + this.configurationOpen = false + this.configureServicesOpen = false + this.configurationCompleteOpen = false + + this.provisionalId = null + this.provisionalJS = null + + /* ****************************************/ + // Query + /* ****************************************/ + + /** + * @return true if any configuration dialogs are open + */ + this.hasAnyItemsOpen = () => { + return this.addMailboxOpen || this.configurationOpen || this.configureServicesOpen || this.configurationCompleteOpen + } + + /** + * @return the type of the provisional mailbox or undefined + */ + this.provisonaMailboxType = () => { + return (this.provisionalJS || {}).type + } + + /** + * @return the type of provisional services for the mailbox + */ + this.provisionalMailboxSupportedServices = () => { + const type = this.provisonaMailboxType() + if (type === Mailbox.TYPE_GINBOX || type === Mailbox.TYPE_GMAIL) { + return Google.SUPPORTED_SERVICES.filter((s) => s !== Mailbox.SERVICES.DEFAULT) + } else { + return [] + } + } + + /** + * @return the default mailbox services for the mailbox + */ + this.provisionalDefaultMailboxServices = () => { + const type = this.provisonaMailboxType() + if (type === Mailbox.TYPE_GINBOX || type === Mailbox.TYPE_GMAIL) { + return Google.DEFAULT_SERVICES + } else { + return [] + } + } + + /* ****************************************/ + // Listeners + /* ****************************************/ + + this.bindListeners({ + handleOpenAddMailbox: actions.OPEN_ADD_MAILBOX, + handleCancelAddMailbox: actions.CANCEL_ADD_MAILBOX, + + handleAuthenticateGinboxMailbox: actions.AUTHENTICATE_GINBOX_MAILBOX, + handleAuthenticateGmailMailbox: actions.AUTHENTICATE_GMAIL_MAILBOX, + handleReauthenticateGoogleMailbox: actions.REAUTHENTICATE_GOOGLE_MAILBOX, + + handleAuthGoogleMailboxSuccess: actions.AUTH_GOOGLE_MAILBOX_SUCCESS, + handleAuthGoogleMailboxFailure: actions.AUTH_GOOGLE_MAILBOX_FAILURE, + + handleConfigureMailbox: actions.CONFIGURE_MAILBOX, + handleConfigureServices: actions.CONFIGURE_MAILBOX_SERVICES, + handleConfigurationComplete: actions.CONFIGURATION_COMPLETE + }) + } + + /* **************************************************************************/ + // Utils + /* **************************************************************************/ + + /** + * Resets everything to the original values + */ + completeClear () { + this.addMailboxOpen = false + this.configurationOpen = false + this.configureServicesOpen = false + this.configurationCompleteOpen = false + this.provisionalId = null + this.provisionalJS = null + } + + /** + * Creates the mailbox from the provisional js + */ + createMailbox () { + const provisionalType = this.provisonaMailboxType() + mailboxActions.create.defer(this.provisionalId, this.provisionalJS) + if (provisionalType === Mailbox.TYPE_GMAIL || provisionalType === Mailbox.TYPE_GINBOX) { + googleActions.syncMailboxProfile.defer(this.provisionalId) + googleActions.syncMailboxUnreadCount.defer(this.provisionalId) + } + } + + /* **************************************************************************/ + // Wizard lifecycle + /* **************************************************************************/ + + handleOpenAddMailbox () { + this.addMailboxOpen = true + } + + handleCancelAddMailbox () { + this.completeClear() + } + + /* **************************************************************************/ + // Starting Authentication + /* **************************************************************************/ + + handleAuthenticateGinboxMailbox ({ provisionalId }) { + this.addMailboxOpen = false + ipcRenderer.send('auth-google', { id: provisionalId, type: Mailbox.TYPE_GINBOX }) + } + + handleAuthenticateGmailMailbox ({ provisionalId }) { + this.addMailboxOpen = false + ipcRenderer.send('auth-google', { id: provisionalId, type: Mailbox.TYPE_GMAIL }) + } + + handleReauthenticateGoogleMailbox ({ mailboxId }) { + this.addMailboxOpen = false + ipcRenderer.send('auth-google', { id: mailboxId, mode: 'reauthenticate' }) + } + + /* **************************************************************************/ + // Authentication Callbacks + /* **************************************************************************/ + + handleAuthGoogleMailboxSuccess ({ provisionalId, type, temporaryAuth, mode }) { + googleHTTP.upgradeAuthCodeToPermenant(temporaryAuth).then((auth) => { + if (mode === 'reauthenticate') { + mailboxActions.setGoogleAuth.defer(provisionalId, auth) + googleActions.syncMailboxProfile.defer(provisionalId) + googleActions.syncMailboxUnreadCount.defer(provisionalId) + this.completeClear() + } else { + this.provisionalId = provisionalId + this.provisionalJS = { + type: type, + googleAuth: auth + } + + this.configurationOpen = true + this.emitChange() + } + }).catch((err) => { + console.error('[AUTH ERR]', err) + console.error(err.errorString) + console.error(err.errorStack) + reporter.reportError('[AUTH ERR]' + err.errorString) + this.completeClear() + }) + } + + handleAuthGoogleMailboxFailure ({ evt, data }) { + if (data.errorMessage.toLowerCase().indexOf('user') === 0) { + // User cancelled + } else { + console.error('[AUTH ERR]', data) + console.error(data.errorString) + console.error(data.errorStack) + reporter.reportError('[AUTH ERR]' + data.errorString) + } + this.completeClear() + } + + /* **************************************************************************/ + // Config + /* **************************************************************************/ + + handleConfigureMailbox ({ configuration }) { + this.provisionalJS = Object.assign(this.provisionalJS, configuration) + if (pkg.prerelease) { + this.configureServicesOpen = true + } else { + this.createMailbox() + this.completeClear() + this.configurationCompleteOpen = true + } + } + + handleConfigureServices ({ enabledServices, compact }) { + this.provisionalJS = Object.assign(this.provisionalJS, { + services: enabledServices, + compactServicesUI: compact + }) + + this.createMailbox() + this.completeClear() + this.configurationCompleteOpen = true + } + + handleConfigurationComplete () { + this.completeClear() + } +} + +module.exports = alt.createStore(MailboxWizardStore, 'MailboxWizardStore') diff --git a/src/scenes/mailboxes/src/stores/platform/index.js b/src/scenes/mailboxes/src/stores/platform/index.js new file mode 100644 index 00000000..29a8e2b1 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/platform/index.js @@ -0,0 +1,6 @@ +module.exports = { + A: require('./platformActions'), + platformActions: require('./platformActions'), + S: require('./platformStore'), + platformStore: require('./platformStore') +} diff --git a/src/scenes/mailboxes/src/stores/platform/platformActions.js b/src/scenes/mailboxes/src/stores/platform/platformActions.js new file mode 100644 index 00000000..4fc87aec --- /dev/null +++ b/src/scenes/mailboxes/src/stores/platform/platformActions.js @@ -0,0 +1,30 @@ +const alt = require('../alt') + +class PlatformActions { + + /* **************************************************************************/ + // Login + /* **************************************************************************/ + + /** + * @param openAtLogin: true to open at login + * @param openAsHidden: true to open as hidden + */ + changeLoginPref (openAtLogin, openAsHidden) { + return { openAtLogin: openAtLogin, openAsHidden: openAsHidden } + } + + /* **************************************************************************/ + // Mailto + /* **************************************************************************/ + + /** + * Sets if the app is the default mailto link handler + * @param isCurrentApp: true if this is the handler + */ + changeMailtoLinkHandler (isCurrentApp) { + return { isCurrentApp: isCurrentApp } + } +} + +module.exports = alt.createActions(PlatformActions) diff --git a/src/scenes/mailboxes/src/stores/platform/platformStore.js b/src/scenes/mailboxes/src/stores/platform/platformStore.js new file mode 100644 index 00000000..8d19c7f6 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/platform/platformStore.js @@ -0,0 +1,177 @@ +const alt = require('../alt') +const actions = require('./platformActions') +const { remote } = window.nativeRequire('electron') +const path = require('path') +const fs = require('fs') +const windowsShortcuts = process.platform === 'win32' ? window.appNodeModulesRequire('windows-shortcuts') : null + +const WIN32_LOGIN_PREF_MAX_AGE = 1000 * 30 // 30 secs +const WIN32_SHORTCUT_PATH = (() => { + if (process.platform === 'win32') { + const appdata = remote.getGlobal('process').env.APPDATA + if (appdata) { + return path.join(appdata, 'Microsoft\\Windows\\Start Menu\\Programs\\Startup\\WMail.lnk') + } else { + return undefined + } + } else { + return undefined + } +})() + +class PlatformStore { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.win32LoginPrefs = { + lastSynced: 0, + openAtLogin: false, + openAsHidden: false + } + + /* ****************************************/ + // Open at login + /* ****************************************/ + + /** + * @return true if login preferences are supported on this platform + */ + this.loginPrefSupported = () => { return process.platform === 'darwin' || process.platform === 'win32' } + + /** + * @return { openAtLogin, openAsHidden } or null if not supported / unknown + */ + this.loginPref = () => { + if (process.platform === 'darwin') { + const settings = remote.app.getLoginItemSettings() + return { + openAtLogin: settings.openAtLogin, + openAsHidden: settings.openAsHidden + } + } else if (process.platform === 'win32') { + this.resyncWindowsLoginPref() + return { + openAtLogin: this.win32LoginPrefs.openAtLogin, + openAsHidden: this.win32LoginPrefs.openAsHidden + } + } else { + return null + } + } + + /** + * @return { openAtLogin, openAsHidden }. If state is unknown assumes false for both + */ + this.loginPrefAssumed = () => { + const pref = this.loginPref() + return pref === null ? { openAtLogin: false, openAsHidden: false } : pref + } + + /* ****************************************/ + // Default Mail handler + /* ****************************************/ + + /** + * @return true if the platform supports mailto + */ + this.mailtoLinkHandlerSupported = () => { return process.platform === 'darwin' || process.platform === 'win32' } + + /** + * @return true if this app is the default mailto link handler + */ + this.isMailtoLinkHandler = () => { + if (process.platform === 'darwin' || process.platform === 'win32') { + return remote.app.isDefaultProtocolClient('mailto') + } else { + return false + } + } + + /* ****************************************/ + // Listeners + /* ****************************************/ + this.bindListeners({ + handleChangeLoginPref: actions.CHANGE_LOGIN_PREF, + handleChangeMailtoLinkHandler: actions.CHANGE_MAILTO_LINK_HANDLER + }) + } + + /* **************************************************************************/ + // Login utils + /* **************************************************************************/ + + /** + * Resyncs the windows login pref if enough time has elapsed + */ + resyncWindowsLoginPref () { + const now = new Date().getTime() + if (now - this.win32LoginPrefs.lastSynced < WIN32_LOGIN_PREF_MAX_AGE) { return } + + this.win32LoginPrefs.lastSynced = now + windowsShortcuts.query(WIN32_SHORTCUT_PATH, (err, info) => { + if (err) { + this.win32LoginPrefs.openAtLogin = false + this.win32LoginPrefs.openAsHidden = false + } else { + this.win32LoginPrefs.openAtLogin = true + this.win32LoginPrefs.openAsHidden = (info.args || '').indexOf('--hidden') !== -1 + } + this.emitChange() + }) + } + + /* **************************************************************************/ + // Handlers: Login + /* **************************************************************************/ + + handleChangeLoginPref ({ openAtLogin, openAsHidden }) { + if (process.platform === 'darwin') { + remote.app.setLoginItemSettings({ + openAtLogin: openAtLogin, + openAsHidden: openAsHidden + }) + } else if (process.platform === 'win32') { + if (openAtLogin) { + windowsShortcuts.query(WIN32_SHORTCUT_PATH, (err) => { + const func = err ? windowsShortcuts.create : windowsShortcuts.edit + func(WIN32_SHORTCUT_PATH, { + target: process.argv[0], + args: openAsHidden ? '--hidden' : '' + }, (err) => { + if (!err) { + this.win32LoginPrefs.lastSynced = new Date().getTime() + this.win32LoginPrefs.openAtLogin = true + this.win32LoginPrefs.openAsHidden = openAsHidden + this.emitChange() + } + }) + }) + } else { + fs.unlink(WIN32_SHORTCUT_PATH, (err) => { + if (!err) { + this.win32LoginPrefs.lastSynced = new Date().getTime() + this.win32LoginPrefs.openAtLogin = false + this.win32LoginPrefs.openAsHidden = false + this.emitChange() + } + }) + } + } + } + + /* **************************************************************************/ + // Handlers: Mailto + /* **************************************************************************/ + + handleChangeMailtoLinkHandler ({ isCurrentApp }) { + if (isCurrentApp) { + remote.app.setAsDefaultProtocolClient('mailto') + } else { + remote.app.removeAsDefaultProtocolClient('mailto') + } + } +} + +module.exports = alt.createStore(PlatformStore, 'PlatformStore') diff --git a/src/scenes/mailboxes/src/stores/settings/index.js b/src/scenes/mailboxes/src/stores/settings/index.js index 38fb3df3..e3cdac16 100644 --- a/src/scenes/mailboxes/src/stores/settings/index.js +++ b/src/scenes/mailboxes/src/stores/settings/index.js @@ -1,4 +1,6 @@ module.exports = { S: require('./settingsStore'), - A: require('./settingsActions') + settingsStore: require('./settingsStore'), + A: require('./settingsActions'), + settingsActions: require('./settingsActions') } diff --git a/src/scenes/mailboxes/src/stores/settings/migration.js b/src/scenes/mailboxes/src/stores/settings/migration.js index a86df94e..80afb260 100644 --- a/src/scenes/mailboxes/src/stores/settings/migration.js +++ b/src/scenes/mailboxes/src/stores/settings/migration.js @@ -1,4 +1,4 @@ -const persistence = window.remoteRequire('storage/settingStorage') +const persistence = require('./settingsPersistence') module.exports = { /** @@ -43,11 +43,11 @@ module.exports = { transfer('sidebarEnabled', 'ui', 'sidebarEnabled') // Save - persistence.setItem('language', next.language) - persistence.setItem('os', next.os) - persistence.setItem('proxy', next.proxy) - persistence.setItem('tray', next.tray) - persistence.setItem('ui', next.ui) + persistence.setJSONItemSync('language', next.language) + persistence.setJSONItemSync('os', next.os) + persistence.setJSONItemSync('proxy', next.proxy) + persistence.setJSONItemSync('tray', next.tray) + persistence.setJSONItemSync('ui', next.ui) // Save window.localStorage.setItem('pre_1_3_1:App_settings', JSON.stringify(prev)) diff --git a/src/scenes/mailboxes/src/stores/settings/settingsActions.js b/src/scenes/mailboxes/src/stores/settings/settingsActions.js index d44190dc..4a1a3649 100644 --- a/src/scenes/mailboxes/src/stores/settings/settingsActions.js +++ b/src/scenes/mailboxes/src/stores/settings/settingsActions.js @@ -54,6 +54,16 @@ class SettingsActions { return this.update(SEGMENTS.LANGUAGE, 'spellcheckerEnabled', enabled) } + /** + * @param lang: the language to set to + */ + setSpellcheckerLanguage (lang) { return {lang: lang} } + + /** + * @param lang: the language to set to + */ + setSecondarySpellcheckerLanguage (lang) { return {lang: lang} } + /* **************************************************************************/ // OS /* **************************************************************************/ @@ -93,6 +103,13 @@ class SettingsActions { return this.update(SEGMENTS.OS, 'openLinksInBackground', background) } + /** + * @param mode: the login open mode + */ + setLoginOpenMode (mode) { + return this.update(SEGMENTS.OS, 'loginOpenMode', mode) + } + /* **************************************************************************/ // Proxy Server /* **************************************************************************/ @@ -139,6 +156,13 @@ class SettingsActions { return this.update(SEGMENTS.UI, 'showAppBadge', show) } + /** + * @param show: true to show the unread count in the titlebar + */ + setShowTitlebarUnreadCount (show) { + return this.update(SEGMENTS.UI, 'showTitlebarCount', show) + } + /** * @param show: true to show the app menu, false otherwise */ @@ -167,6 +191,52 @@ class SettingsActions { return this.toggle(SEGMENTS.UI, 'sidebarEnabled') } + /** + * Opens the app hidden by default + */ + setOpenHidden (toggled) { + return this.update(SEGMENTS.UI, 'openHidden', toggled) + } + + /* **************************************************************************/ + // App + /* **************************************************************************/ + + /** + * @param ignore: true to ignore the gpu blacklist + */ + ignoreGPUBlacklist (ignore) { + return this.update(SEGMENTS.APP, 'ignoreGPUBlacklist', ignore) + } + + /** + * @param enable: true to enable using zoom for dsf + */ + enableUseZoomForDSF (enable) { + return this.update(SEGMENTS.APP, 'enableUseZoomForDSF', enable) + } + + /** + * @param disable: true to disable smooth scrolling + */ + disableSmoothScrolling (disable) { + return this.update(SEGMENTS.APP, 'disableSmoothScrolling', disable) + } + + /** + * @param toggled: true to check for updates + */ + checkForUpdates (toggled) { + return this.update(SEGMENTS.APP, 'checkForUpdates', toggled) + } + + /** + * @param hasSeen: true if the user has seen the app wizard + */ + setHasSeenAppWizard (hasSeen) { + return this.update(SEGMENTS.APP, 'hasSeenAppWizard', hasSeen) + } + /* **************************************************************************/ // Tray /* **************************************************************************/ @@ -192,12 +262,64 @@ class SettingsActions { return this.update(SEGMENTS.TRAY, 'readColor', col) } + /** + * @param col: the hex colour to make the tray icon background + */ + setTrayReadBackgroundColor (col) { + return this.update(SEGMENTS.TRAY, 'readBackgroundColor', col) + } + /** * @param col: the hex colour to make the tray icon */ setTrayUnreadColor (col) { return this.update(SEGMENTS.TRAY, 'unreadColor', col) } + + /** + * @param col: the hex colour to make the tray icon background + */ + setTrayUnreadBackgroundColor (col) { + return this.update(SEGMENTS.TRAY, 'unreadBackgroundColor', col) + } + + /** + * @param val: the multiplier to apply to the tray icon + */ + setDpiMultiplier (val) { + return this.update(SEGMENTS.TRAY, 'dpiMultiplier', parseInt(val)) + } + + /* **************************************************************************/ + // News + /* **************************************************************************/ + + /** + * @param serverResponse: the response received from the update server + */ + updateLatestNews (serverResponse) { + return this.update(SEGMENTS.NEWS, { + newsId: serverResponse.id, + newsLevel: serverResponse.level, + newsFeed: serverResponse.feed + }) + } + + /** + * Marks a news item as opened + * @param newsId: the id of the news item + */ + openNewsItem (newsId) { + return this.update(SEGMENTS.NEWS, 'openedNewsId', newsId) + } + + /** + * Sets whether to show news in the sidebar + * @param show: true to show, false otherwise + */ + setShowNewsInSidebar (show) { + return this.update(SEGMENTS.NEWS, 'showNewsInSidebar', show) + } } const actions = alt.createActions(SettingsActions) diff --git a/src/scenes/mailboxes/src/stores/settings/settingsPersistence.js b/src/scenes/mailboxes/src/stores/settings/settingsPersistence.js new file mode 100644 index 00000000..093d4070 --- /dev/null +++ b/src/scenes/mailboxes/src/stores/settings/settingsPersistence.js @@ -0,0 +1,2 @@ +const StorageBucket = require('../StorageBucket') +module.exports = new StorageBucket('settings') diff --git a/src/scenes/mailboxes/src/stores/settings/settingsStore.js b/src/scenes/mailboxes/src/stores/settings/settingsStore.js index a55d13ff..b9b4dfd0 100644 --- a/src/scenes/mailboxes/src/stores/settings/settingsStore.js +++ b/src/scenes/mailboxes/src/stores/settings/settingsStore.js @@ -1,18 +1,84 @@ const alt = require('../alt') const actions = require('./settingsActions') -const persistence = window.remoteRequire('storage/settingStorage') +const persistence = require('./settingsPersistence') +const dictionaries = require('shared/dictionaries.js') const { - Settings: {LanguageSettings, OSSettings, ProxySettings, TraySettings, UISettings, SettingsIdent} + Settings: { + AppSettings, + LanguageSettings, + NewsSettings, + OSSettings, + ProxySettings, + TraySettings, + UISettings, + SettingsIdent + } } = require('shared/Models') const migration = require('./migration') +const homeDir = window.appNodeModulesRequire('home-dir') // pull this from main thread +const {systemPreferences} = window.nativeRequire('electron').remote +const fs = require('fs') class SettingsStore { + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + /** + * Generates the themed defaults for the tray + * @return the defaults + */ + static generateTrayThemedDefaults () { + if (process.platform === 'darwin') { + return { + readColor: systemPreferences.isDarkMode() ? '#FFFFFF' : '#000000', + readBackgroundColor: 'transparent', + unreadColor: '#C82018', + unreadBackgroundColor: 'transparent' + } + } else if (process.platform === 'win32') { + // Windows is predominantely dark themed, but with no way to check assume it is + return { + readColor: '#FFFFFF', + readBackgroundColor: 'transparent', + unreadColor: '#C82018', + unreadBackgroundColor: 'transparent' + } + } else if (process.platform === 'linux') { + let isDark = false + // GTK + try { + const gtkConf = fs.readFileSync(homeDir('.config/gtk-3.0/settings.ini'), 'utf8') + if (gtkConf.indexOf('gtk-application-prefer-dark-theme=1') !== -1) { + isDark = true + } + } catch (ex) { } + + return { + readColor: isDark ? '#FFFFFF' : '#000000', + readBackgroundColor: 'transparent', + unreadColor: '#C82018', + unreadBackgroundColor: 'transparent' + } + } + + // Catch all + return { + readColor: '#000000', + readBackgroundColor: 'transparent', + unreadColor: '#C82018', + unreadBackgroundColor: 'transparent' + } + } + /* **************************************************************************/ // Lifecycle /* **************************************************************************/ constructor () { + this.app = null this.language = null + this.news = null this.os = null this.proxy = null this.tray = null @@ -21,7 +87,10 @@ class SettingsStore { this.bindListeners({ handleLoad: actions.LOAD, handleUpdate: actions.UPDATE, - handleToggleBool: actions.TOGGLE + handleToggleBool: actions.TOGGLE, + + handleSetSpellcheckerLanguage: actions.SET_SPELLCHECKER_LANGUAGE, + handleSetSecondarySpellcheckerLanguage: actions.SET_SECONDARY_SPELLCHECKER_LANGUAGE }) } @@ -32,13 +101,16 @@ class SettingsStore { handleLoad () { // Migrate migration.from_1_3_1() + this.trayDefaults = SettingsStore.generateTrayThemedDefaults() // Load everything - this.language = new LanguageSettings(persistence.getItem('language', {})) - this.os = new OSSettings(persistence.getItem('os', {})) - this.proxy = new ProxySettings(persistence.getItem('proxy', {})) - this.tray = new TraySettings(persistence.getItem('tray', {})) - this.ui = new UISettings(persistence.getItem('ui', {})) + this.app = new AppSettings(persistence.getJSONItemSync('app', {})) + this.language = new LanguageSettings(persistence.getJSONItemSync('language', {})) + this.news = new NewsSettings(persistence.getJSONItemSync('news', {})) + this.os = new OSSettings(persistence.getJSONItemSync('os', {})) + this.proxy = new ProxySettings(persistence.getJSONItemSync('proxy', {})) + this.tray = new TraySettings(persistence.getJSONItemSync('tray', {}), this.trayDefaults) + this.ui = new UISettings(persistence.getJSONItemSync('ui', {})) } /* **************************************************************************/ @@ -51,7 +123,9 @@ class SettingsStore { */ storeKeyFromSegment (segment) { switch (segment) { + case SettingsIdent.SEGMENTS.APP: return 'app' case SettingsIdent.SEGMENTS.LANGUAGE: return 'language' + case SettingsIdent.SEGMENTS.NEWS: return 'news' case SettingsIdent.SEGMENTS.OS: return 'os' case SettingsIdent.SEGMENTS.PROXY: return 'proxy' case SettingsIdent.SEGMENTS.TRAY: return 'tray' @@ -65,7 +139,9 @@ class SettingsStore { */ storeClassFromSegment (segment) { switch (segment) { + case SettingsIdent.SEGMENTS.APP: return AppSettings case SettingsIdent.SEGMENTS.LANGUAGE: return LanguageSettings + case SettingsIdent.SEGMENTS.NEWS: return NewsSettings case SettingsIdent.SEGMENTS.OS: return OSSettings case SettingsIdent.SEGMENTS.PROXY: return ProxySettings case SettingsIdent.SEGMENTS.TRAY: return TraySettings @@ -79,7 +155,9 @@ class SettingsStore { */ persistenceKeyFromSegment (segment) { switch (segment) { + case SettingsIdent.SEGMENTS.APP: return 'app' case SettingsIdent.SEGMENTS.LANGUAGE: return 'language' + case SettingsIdent.SEGMENTS.NEWS: return 'news' case SettingsIdent.SEGMENTS.OS: return 'os' case SettingsIdent.SEGMENTS.PROXY: return 'proxy' case SettingsIdent.SEGMENTS.TRAY: return 'tray' @@ -98,8 +176,12 @@ class SettingsStore { const persistenceKey = this.persistenceKeyFromSegment(segment) const js = this[storeKey].changeData(updates) - persistence.setItem(persistenceKey, js) - this[storeKey] = new StoreClass(js) + persistence.setJSONItem(persistenceKey, js) + if (segment === SettingsIdent.SEGMENTS.TRAY) { + this[storeKey] = new StoreClass(js, this.trayDefaults) + } else { + this[storeKey] = new StoreClass(js) + } } /** @@ -114,8 +196,54 @@ class SettingsStore { const js = this[storeKey].cloneData() js[key] = !js[key] - persistence.setItem(persistenceKey, js) - this[storeKey] = new StoreClass(js) + persistence.setJSONItem(persistenceKey, js) + if (segment === SettingsIdent.SEGMENTS.TRAY) { + this[storeKey] = new StoreClass(js, this.trayDefaults) + } else { + this[storeKey] = new StoreClass(js) + } + } + + /* **************************************************************************/ + // Changing : Spellchecker + /* **************************************************************************/ + + handleSetSpellcheckerLanguage ({ lang }) { + const primaryInfo = dictionaries[lang] + const secondaryInfo = (dictionaries[this.language.secondarySpellcheckerLanguage] || {}) + + if (primaryInfo.charset !== secondaryInfo.charset) { + this.handleUpdate({ + segment: SettingsIdent.SEGMENTS.LANGUAGE, + updates: { + spellcheckerLanguage: lang, + secondarySpellcheckerLanguage: null + } + }) + } else { + this.handleUpdate({ + segment: SettingsIdent.SEGMENTS.LANGUAGE, + updates: { spellcheckerLanguage: lang } + }) + } + } + + handleSetSecondarySpellcheckerLanguage ({ lang }) { + if (!lang) { + this.handleUpdate({ + segment: SettingsIdent.SEGMENTS.LANGUAGE, + updates: { secondarySpellcheckerLanguage: null } + }) + } else { + const primaryInfo = (dictionaries[this.language.spellcheckerLanguage] || {}) + const secondaryInfo = (dictionaries[lang] || {}) + if (primaryInfo.charset === secondaryInfo.charset) { + this.handleUpdate({ + segment: SettingsIdent.SEGMENTS.LANGUAGE, + updates: { secondarySpellcheckerLanguage: lang } + }) + } + } } } diff --git a/src/scenes/mailboxes/src/ui/App.js b/src/scenes/mailboxes/src/ui/App.js index d85ea212..ce92e189 100644 --- a/src/scenes/mailboxes/src/ui/App.js +++ b/src/scenes/mailboxes/src/ui/App.js @@ -1,5 +1,3 @@ -'use strict' - const React = require('react') const flux = { mailbox: require('../stores/mailbox'), @@ -7,22 +5,24 @@ const flux = { settings: require('../stores/settings') } const { - ipcRenderer, remote: {app, shell} + ipcRenderer, remote: {shell} } = window.nativeRequire('electron') +const { + mailboxDispatch, navigationDispatch +} = require('../Dispatch') const AppContent = require('./AppContent') -const mailboxDispatch = require('./Dispatch/mailboxDispatch') const TimerMixin = require('react-timer-mixin') const constants = require('shared/constants') -const UnreadNotifications = require('../daemons/UnreadNotifications') +const UnreadNotifications = require('../Notifications/UnreadNotifications') const shallowCompare = require('react-addons-shallow-compare') const Tray = require('./Tray') +const AppBadge = require('./AppBadge') const appTheme = require('./appTheme') const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default const injectTapEventPlugin = require('react-tap-event-plugin') injectTapEventPlugin() -const navigationDispatch = require('./Dispatch/navigationDispatch') navigationDispatch.bindIPCListeners() module.exports = React.createClass({ @@ -42,8 +42,6 @@ module.exports = React.createClass({ flux.mailbox.S.listen(this.mailboxesChanged) flux.settings.S.listen(this.settingsChanged) flux.google.A.startPollingUpdates() - flux.google.A.syncAllMailboxes() - flux.google.A.syncAllMailboxUnreadMessages() mailboxDispatch.on('blurred', this.mailboxBlurred) @@ -70,8 +68,8 @@ module.exports = React.createClass({ const settingsStore = flux.settings.S.getState() const mailboxStore = flux.mailbox.S.getState() return { + activeMailboxId: mailboxStore.activeMailboxId(), messagesUnreadCount: mailboxStore.totalUnreadCountForAppBadge(), - unreadMessages: mailboxStore.unreadMessagesForAppBadge(), uiSettings: settingsStore.ui, traySettings: settingsStore.tray } @@ -79,8 +77,8 @@ module.exports = React.createClass({ mailboxesChanged (store) { this.setState({ - messagesUnreadCount: store.totalUnreadCountForAppBadge(), - unreadMessages: store.unreadMessagesForAppBadge() + activeMailboxId: store.activeMailboxId(), + messagesUnreadCount: store.totalUnreadCountForAppBadge() }) ipcRenderer.send('mailboxes-changed', { mailboxes: store.allMailboxes().map((mailbox) => { @@ -96,10 +94,6 @@ module.exports = React.createClass({ }) }, - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - }, - /* **************************************************************************/ // IPC Events /* **************************************************************************/ @@ -114,7 +108,7 @@ module.exports = React.createClass({ body: req.filename }) notification.onclick = function () { - shell.showItemInFolder(req.path) + shell.openItem(req.path) || shell.showItemInFolder(req.path) } }, @@ -153,17 +147,26 @@ module.exports = React.createClass({ // Rendering /* **************************************************************************/ + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + render () { const { traySettings, uiSettings, - unreadMessages, messagesUnreadCount } = this.state - if (process.platform === 'darwin') { - const badgeString = uiSettings.showAppBadge && messagesUnreadCount ? messagesUnreadCount.toString() : '' - app.dock.setBadge(badgeString) + // Update the app title + if (uiSettings.showTitlebarCount) { + if (messagesUnreadCount === 0) { + document.title = 'WMail' + } else { + document.title = `WMail (${messagesUnreadCount})` + } + } else { + document.title = 'WMail' } return ( @@ -173,12 +176,12 @@ module.exports = React.createClass({ {!traySettings.show ? undefined : ( - )} + traySettings={traySettings} /> + )} + {!uiSettings.showAppBadge ? undefined : ( + + )}
) } diff --git a/src/scenes/mailboxes/src/ui/AppBadge.js b/src/scenes/mailboxes/src/ui/AppBadge.js new file mode 100644 index 00000000..471eeb0c --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppBadge.js @@ -0,0 +1,98 @@ +const React = require('react') +const shallowCompare = require('react-addons-shallow-compare') +const { remote } = window.nativeRequire('electron') +const {nativeImage, app} = remote + +const AppBadge = React.createClass({ + displayName: 'AppBadge', + + propTypes: { + unreadCount: React.PropTypes.number.isRequired + }, + statics: { + /** + * @return true if the current platform supports app badges + */ + supportsAppBadge () { + if (process.platform === 'darwin') { + return true + } else if (process.platform === 'linux' && app.isUnityRunning()) { + return true + } else { + return false + } + }, + /** + * @return true if this platform supports overlay icons + */ + supportsAppOverlayIcon () { + return process.platform === 'win32' + } + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentWillUnmount () { + if (AppBadge.supportsAppBadge()) { + app.setBadgeCount(0) + } else if (AppBadge.supportsAppOverlayIcon()) { + const win = remote.getCurrentWindow() + win.setOverlayIcon(null, '') + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { unreadCount } = this.props + + if (AppBadge.supportsAppBadge()) { + app.setBadgeCount(unreadCount) + } else if (AppBadge.supportsAppOverlayIcon()) { + const win = remote.getCurrentWindow() + if (unreadCount === 0) { + win.setOverlayIcon(null, '') + } else { + const text = unreadCount.toString().length > 3 ? '+' : unreadCount.toString() + const canvas = document.createElement('canvas') + canvas.height = 140 + canvas.width = 140 + + const ctx = canvas.getContext('2d') + ctx.fillStyle = 'red' + ctx.beginPath() + ctx.ellipse(70, 70, 65, 65, 0, 0, 2 * Math.PI) + ctx.fill() + ctx.textAlign = 'center' + ctx.fillStyle = 'white' + + if (text.length > 2) { + ctx.font = '65px sans-serif' + ctx.fillText(text, 70, 90) + } else if (text.length > 1) { + ctx.font = 'bold 80px sans-serif' + ctx.fillText(text, 70, 97) + } else { + ctx.font = 'bold 100px sans-serif' + ctx.fillText(text, 70, 106) + } + + const badgeDataURL = canvas.toDataURL() + const img = nativeImage.createFromDataURL(badgeDataURL) + win.setOverlayIcon(img, text) + } + } + + return (
) + } +}) + +module.exports = AppBadge diff --git a/src/scenes/mailboxes/src/ui/AppContent.js b/src/scenes/mailboxes/src/ui/AppContent.js index d0694369..4bded031 100644 --- a/src/scenes/mailboxes/src/ui/AppContent.js +++ b/src/scenes/mailboxes/src/ui/AppContent.js @@ -1,16 +1,19 @@ -'use strict' - +import './layout.less' import './appContent.less' const React = require('react') const MailboxWindows = require('./Mailbox/MailboxWindows') +const MailboxComposePicker = require('./Mailbox/MailboxComposePicker') const Sidelist = require('./Sidelist') const shallowCompare = require('react-addons-shallow-compare') const SettingsDialog = require('./Settings/SettingsDialog') -const navigationDispatch = require('./Dispatch/navigationDispatch') -const flux = { - settings: require('../stores/settings') -} +const DictionaryInstallHandler = require('./DictionaryInstaller/DictionaryInstallHandler') +const {navigationDispatch} = require('../Dispatch') +const UpdateCheckDialog = require('./UpdateCheckDialog') +const { settingsStore } = require('../stores/settings') +const MailboxWizard = require('./MailboxWizard') +const AppWizard = require('./AppWizard') +const NewsDialog = require('./NewsDialog') module.exports = React.createClass({ displayName: 'AppContent', @@ -20,12 +23,12 @@ module.exports = React.createClass({ /* **************************************************************************/ componentDidMount () { - flux.settings.S.listen(this.settingsDidUpdate) + settingsStore.listen(this.settingsDidUpdate) navigationDispatch.on('opensettings', this.handleOpenSettings) }, componentWillUnmount () { - flux.settings.S.unlisten(this.settingsDidUpdate) + settingsStore.unlisten(this.settingsDidUpdate) navigationDispatch.off('opensettings', this.handleOpenSettings) }, @@ -34,54 +37,69 @@ module.exports = React.createClass({ /* **************************************************************************/ getInitialState () { + const settingsState = settingsStore.getState() return { - sidebar: flux.settings.S.getState().ui.sidebarEnabled, - settingsDialog: false + sidebar: settingsState.ui.sidebarEnabled, + titlebar: settingsState.ui.showTitlebar, + settingsDialog: false, + settingsRoute: null } }, - settingsDidUpdate (store) { + settingsDidUpdate (settingsStatee) { this.setState({ - sidebar: store.ui.sidebarEnabled + sidebar: settingsStatee.ui.sidebarEnabled, + titlebar: settingsStatee.ui.showTitlebar }) }, - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - }, - /* **************************************************************************/ // Settings Interaction /* **************************************************************************/ /** * Opens the settings dialog + * @param evt: the event that fired if any */ - handleOpenSettings () { - this.setState({ settingsDialog: true }) + handleOpenSettings (evt) { + this.setState({ + settingsDialog: true, + settingsRoute: evt && evt.route ? evt.route : null + }) }, handleCloseSettings () { - this.setState({ settingsDialog: false }) + this.setState({ settingsDialog: false, settingsRoute: null }) }, /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + render () { return (
+ {!this.state.titlebar ? (
) : undefined}
- + + + + + + +
) } diff --git a/src/scenes/mailboxes/src/ui/AppWizard/AppWizard.js b/src/scenes/mailboxes/src/ui/AppWizard/AppWizard.js new file mode 100644 index 00000000..d9205b79 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/AppWizard.js @@ -0,0 +1,95 @@ +const React = require('react') +const { appWizardStore } = require('../../stores/appWizard') +const shallowCompare = require('react-addons-shallow-compare') +const AppWizardStart = require('./AppWizardStart') +const AppWizardComplete = require('./AppWizardComplete') +const AppWizardMailto = require('./AppWizardMailto') +const AppWizardTray = require('./AppWizardTray') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AppWizard', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + this.renderTO = null + appWizardStore.listen(this.wizardChanged) + }, + + componentWillUnmount () { + clearTimeout(this.renderTO) + appWizardStore.unlisten(this.wizardChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const wizardState = appWizardStore.getState() + const itemsOpen = wizardState.hasAnyItemsOpen() + return { + itemsOpen: itemsOpen, + render: itemsOpen, + trayConfiguratorOpen: wizardState.trayConfiguratorOpen, + mailtoHandlerOpen: wizardState.mailtoHandlerOpen, + completeOpen: wizardState.completeOpen, + startOpen: wizardState.startOpen + } + }, + + wizardChanged (wizardState) { + this.setState((prevState) => { + const itemsOpen = wizardState.hasAnyItemsOpen() + const update = { + itemsOpen: itemsOpen, + trayConfiguratorOpen: wizardState.trayConfiguratorOpen, + mailtoHandlerOpen: wizardState.mailtoHandlerOpen, + completeOpen: wizardState.completeOpen, + startOpen: wizardState.startOpen + } + + if (prevState.itemsOpen !== itemsOpen) { + clearTimeout(this.renderTO) + if (prevState.itemsOpen && !itemsOpen) { + this.renderTO = setTimeout(() => { + this.setState({ render: false }) + }, 1000) + } else if (!prevState.itemsOpen && itemsOpen) { + update.render = true + } + } + return update + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { render, startOpen, trayConfiguratorOpen, mailtoHandlerOpen, completeOpen } = this.state + if (render) { + return ( +
+ + + + +
+ ) + } else { + return null + } + } +}) diff --git a/src/scenes/mailboxes/src/ui/AppWizard/AppWizardComplete.js b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardComplete.js new file mode 100644 index 00000000..4f7ecb9d --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardComplete.js @@ -0,0 +1,108 @@ +const React = require('react') +const { appWizardActions } = require('../../stores/appWizard') +const { mailboxStore } = require('../../stores/mailbox') +const { mailboxWizardActions } = require('../../stores/mailboxWizard') +const shallowCompare = require('react-addons-shallow-compare') +const { Dialog, RaisedButton, FontIcon } = require('material-ui') +const Colors = require('material-ui/styles/colors') + +const styles = { + container: { + textAlign: 'center' + }, + tick: { + color: Colors.green600, + fontSize: '80px' + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AppWizardComplete', + propTypes: { + isOpen: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxStore.listen(this.mailboxesUpdated) + }, + + componentWillUnmount () { + mailboxStore.unlisten(this.mailboxesUpdated) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + mailboxCount: mailboxStore.getState().mailboxCount() + } + }, + + mailboxesUpdated (mailboxState) { + this.setState({ + mailboxCount: mailboxState.mailboxCount() + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isOpen } = this.props + const { mailboxCount } = this.state + const actions = ( +
+ appWizardActions.cancelWizard()} /> + appWizardActions.progressNextStep()} /> + {mailboxCount === 0 ? ( + { + appWizardActions.progressNextStep() + mailboxWizardActions.openAddMailbox() + }} /> + ) : undefined} +
+ ) + + return ( + appWizardActions.cancelWizard()}> +
+ check_circle +

All Done!

+

+ You can go to settings at any time to update your preferences +

+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/AppWizard/AppWizardMailto.js b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardMailto.js new file mode 100644 index 00000000..51b9f523 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardMailto.js @@ -0,0 +1,65 @@ +const React = require('react') +const { appWizardActions } = require('../../stores/appWizard') +const { platformActions } = require('../../stores/platform') +const shallowCompare = require('react-addons-shallow-compare') +const { Dialog, RaisedButton } = require('material-ui') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AppWizardMailto', + propTypes: { + isOpen: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isOpen } = this.props + const actions = ( +
+ appWizardActions.cancelWizard()} /> + appWizardActions.progressNextStep()} /> + { + platformActions.changeMailtoLinkHandler(true) + appWizardActions.progressNextStep() + }} /> +
+ ) + + return ( + appWizardActions.cancelWizard()}> +
+

+ Would you like to make WMail your default mail client? +
+ You can always change this later +

+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/AppWizard/AppWizardStart.js b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardStart.js new file mode 100644 index 00000000..1fcbc937 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardStart.js @@ -0,0 +1,68 @@ +const React = require('react') +const { appWizardActions } = require('../../stores/appWizard') +const shallowCompare = require('react-addons-shallow-compare') +const { Dialog, RaisedButton, FontIcon, Avatar } = require('material-ui') +const Colors = require('material-ui/styles/colors') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AppWizardStart', + propTypes: { + isOpen: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isOpen } = this.props + const actions = ( +
+ appWizardActions.discardWizard()} /> + appWizardActions.cancelWizard()} /> + appWizardActions.progressNextStep()} /> +
+ ) + + return ( + appWizardActions.cancelWizard()}> +
+ )} + size={80} /> +

WMail Setup

+

+ Customise WMail to work best for you by configuring a few common settings +

+

+ Would you like to start WMail setup now? +

+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/AppWizard/AppWizardTray.js b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardTray.js new file mode 100644 index 00000000..fdae4795 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/AppWizardTray.js @@ -0,0 +1,89 @@ +const React = require('react') +const { appWizardActions } = require('../../stores/appWizard') +const { settingsStore } = require('../../stores/settings') +const shallowCompare = require('react-addons-shallow-compare') +const { Dialog, RaisedButton } = require('material-ui') +const { TrayIconEditor } = require('../../Components') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AppWizardTray', + propTypes: { + isOpen: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsUpdated) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsUpdated) + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + tray: settingsStore.getState().tray + } + }, + + settingsUpdated (settingsState) { + this.setState({ tray: settingsState.tray }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isOpen } = this.props + const { tray } = this.state + + const actions = ( +
+ appWizardActions.cancelWizard()} /> + appWizardActions.progressNextStep()} /> +
+ ) + + return ( + appWizardActions.cancelWizard()}> +

+ Customise the tray icon so that it fits in with the other icons in + your taskbar. You can change the way the icon appears when you have unread + mail and when you have no unread mail +

+ +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/AppWizard/index.js b/src/scenes/mailboxes/src/ui/AppWizard/index.js new file mode 100644 index 00000000..c3bcf299 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/AppWizard/index.js @@ -0,0 +1 @@ +module.exports = require('./AppWizard') diff --git a/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallHandler.js b/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallHandler.js new file mode 100644 index 00000000..7c3b6c45 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallHandler.js @@ -0,0 +1,60 @@ +const React = require('react') +const { Dialog } = require('material-ui') +const dictionariesStore = require('../../stores/dictionaries/dictionariesStore') +const DictionaryInstallStepper = require('./DictionaryInstallStepper') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'DictionaryInstallHandler', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentWillMount () { + dictionariesStore.listen(this.dictionariesChanged) + }, + + componentWillUnmount () { + dictionariesStore.unlisten(this.dictionariesChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const store = dictionariesStore.getState() + return { + isInstalling: store.isInstalling(), + installId: store.installId() + } + }, + + dictionariesChanged (store) { + this.setState({ + isInstalling: store.isInstalling(), + installId: store.installId() + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + return ( + + {!this.state.isInstalling ? undefined : ( + + )} + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallStepper.js b/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallStepper.js new file mode 100644 index 00000000..f08eda9f --- /dev/null +++ b/src/scenes/mailboxes/src/ui/DictionaryInstaller/DictionaryInstallStepper.js @@ -0,0 +1,185 @@ +const React = require('react') +const { + Stepper, Step, StepLabel, StepContent, + RaisedButton, FlatButton, LinearProgress, + SelectField, MenuItem +} = require('material-ui') +const dictionariesStore = require('../../stores/dictionaries/dictionariesStore') +const dictionariesActions = require('../../stores/dictionaries/dictionariesActions') +const { + remote: {shell} +} = window.nativeRequire('electron') + +const STEPS = { + PICK: 0, + LICENSE: 1, + DOWNLOAD: 2, + FINISH: 3 +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'DictionaryInstallStepper', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + dictionariesStore.listen(this.dictionariesChanged) + }, + + componentWillUnmount () { + dictionariesStore.unlisten(this.dictionariesChanged) + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + const store = dictionariesStore.getState() + return { + stepIndex: STEPS.PICK, + installLanguage: store.installLanguage(), + installLanguageInfo: null, + installId: store.installId(), + installInflight: store.installInflight(), + uninstallDictionaries: store.sortedUninstalledDictionaryInfos() + } + }, + + dictionariesChanged (store) { + if (store.installId() !== this.state.installId) { + this.setState(this.getInitialState()) + } else { + if (!this.state.installLanguage && store.installLanguage()) { + this.setState({ + installLanguage: store.installLanguage(), + installLanguageInfo: store.getDictionaryInfo(store.installLanguage()), + stepIndex: STEPS.LICENSE + }) + } else if (!this.state.installInflight && store.installInflight()) { + this.setState({ + installInflight: store.installInflight(), + stepIndex: STEPS.DOWNLOAD + }) + } else if (this.state.installInflight && !store.installInflight()) { + this.setState({ + installInflight: store.installInflight(), + stepIndex: STEPS.FINISH + }) + } + } + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + /** + * Progress the user when they pick their language + */ + handlePickLanguage (evt, index, value) { + if (value !== null) { + dictionariesActions.pickDictionaryInstallLanguage(this.state.installId, value) + } + }, + + /** + * Handles the user agreeing to the license + */ + handleAgreeLicense () { + dictionariesActions.installDictionary(this.state.installId) + }, + + /** + * Handles cancelling the install + */ + handleCancel () { + dictionariesActions.stopDictionaryInstall() + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { stepIndex, installLanguageInfo, uninstallDictionaries } = this.state + + return ( + + + Pick Language + + + {[null].concat(uninstallDictionaries).map((info) => { + if (info === null) { + return () + } else { + return () + } + })} + + + + + + Licensing + +

+ Check you're happy with the + { evt.preventDefault(); shell.openExternal(installLanguageInfo.license) }}>license + of the {(installLanguageInfo || {}).name} dictionary +

+ + +
+
+ + Download + +

Downloading {(installLanguageInfo || {}).name}

+ +
+
+ + Finish + +

+ The + {(installLanguageInfo || {}).name} + dictionary has been downloaded and installed. +

+ +
+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Dispatch/mailboxDispatch.js b/src/scenes/mailboxes/src/ui/Dispatch/mailboxDispatch.js deleted file mode 100644 index e04d9782..00000000 --- a/src/scenes/mailboxes/src/ui/Dispatch/mailboxDispatch.js +++ /dev/null @@ -1,62 +0,0 @@ -const Minivents = require('minivents') - -class MailboxDispatch { - /** - * Emits a reload command - * @param mailboxId: the id of the mailbox - */ - reload (mailboxId) { - this.emit('reload', { mailboxId: mailboxId }) - } - - /** - * Emits a open dev tools command - * @param mailboxId: the id of the mailbox - */ - openDevTools (mailboxId) { - this.emit('devtools', { mailboxId: mailboxId }) - } - - /** - * Emits a focus event for a mailbox - * @param mailboxId=undefined: the id of the mailbox - */ - refocus (mailboxId = undefined) { - this.emit('refocus', { mailboxId: mailboxId }) - } - - /** - * Emis a blurred event for a mailbox - * @param mailboxId: the id of the mailbox - */ - blurred (mailboxId) { - this.emit('blurred', { mailboxId: mailboxId }) - } - - /** - * Emis a focused event for a mailbox - * @param mailboxId: the id of the mailbox - */ - focused (mailboxId) { - this.emit('focused', { mailboxId: mailboxId }) - } - - /** - * Emits an open message event for a mailbox - * @param mailboxId: the id of the mailbox - * @param threadId: the id of the thread - * @param messageId: the id of the message to open - */ - openMessage (mailboxId, threadId, messageId) { - this.emit('openMessage', { - mailboxId: mailboxId, - threadId: threadId, - messageId: messageId - }) - } - -} - -const mailboxDispatch = new MailboxDispatch() -Minivents(mailboxDispatch) -module.exports = mailboxDispatch diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCalendarTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCalendarTab.js new file mode 100644 index 00000000..dd15066b --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCalendarTab.js @@ -0,0 +1,76 @@ +const React = require('react') +const MailboxTabSleepable = require('../MailboxTabSleepable') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { settingsStore } = require('../../../stores/settings') +const { + remote: {shell} +} = window.nativeRequire('electron') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxCalendarTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + os: settingsState.os + } + }, + + settingsChanged (settingsState) { + this.setState({ os: settingsState.os }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + shell.openExternal(url, { activate: !this.state.os.openLinksInBackground }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { mailboxId } = this.props + + return ( + { this.handleOpenNewWindow(evt.url) }} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCommunicationTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCommunicationTab.js new file mode 100644 index 00000000..dc086a64 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxCommunicationTab.js @@ -0,0 +1,79 @@ +const React = require('react') +const MailboxTabSleepable = require('../MailboxTabSleepable') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { settingsStore } = require('../../../stores/settings') +const URL = window.nativeRequire('url') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxCommunicationTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + os: settingsState.os + } + }, + + settingsChanged (settingsState) { + this.setState({ os: settingsState.os }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + const purl = URL.parse(url, true) + + if (purl.host === 'hangouts.google.com') { + this.setState({ browserSrc: url }) + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { mailboxId } = this.props + + return ( + { this.handleOpenNewWindow(evt.url) }} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxContactsTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxContactsTab.js new file mode 100644 index 00000000..7bf60053 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxContactsTab.js @@ -0,0 +1,76 @@ +const React = require('react') +const MailboxTabSleepable = require('../MailboxTabSleepable') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { settingsStore } = require('../../../stores/settings') +const { + remote: {shell} +} = window.nativeRequire('electron') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxContactsTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + os: settingsState.os + } + }, + + settingsChanged (settingsState) { + this.setState({ os: settingsState.os }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + shell.openExternal(url, { activate: !this.state.os.openLinksInBackground }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { mailboxId } = this.props + + return ( + { this.handleOpenNewWindow(evt.url) }} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxMailTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxMailTab.js new file mode 100644 index 00000000..1f82f728 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxMailTab.js @@ -0,0 +1,200 @@ +const React = require('react') +const MailboxTab = require('../MailboxTab') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { composeStore, composeActions } = require('../../../stores/compose') +const { mailboxStore } = require('../../../stores/mailbox') +const { settingsStore } = require('../../../stores/settings') +const { googleActions } = require('../../../stores/google') +const { mailboxDispatch } = require('../../../Dispatch') +const URL = window.nativeRequire('url') +const { + remote: {shell}, ipcRenderer +} = window.nativeRequire('electron') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxMailTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + // Stores + composeStore.listen(this.composeChanged) + mailboxStore.listen(this.mailboxChanged) + settingsStore.listen(this.settingsChanged) + + // Handle dispatch events + mailboxDispatch.on('openMessage', this.handleOpenMessage) + mailboxDispatch.respond('get-google-unread-count:' + this.props.mailboxId, this.handleGetGoogleUnreadCount) + + // Fire an artifical compose change in case the compose event is waiting + this.composeChanged(composeStore.getState()) + }, + + componentWillUnmount () { + // Stores + composeStore.unlisten(this.composeChanged) + mailboxStore.unlisten(this.mailboxChanged) + settingsStore.unlisten(this.settingsChanged) + + // Handle dispatch events + mailboxDispatch.off('openMessage', this.handleOpenMessage) + mailboxDispatch.unrespond('get-google-unread-count:' + this.props.mailboxId, this.handleGetGoogleUnreadCount) + }, + + componentWillReceiveProps (nextProps) { + if (this.props.mailboxId !== nextProps.mailboxId) { + mailboxDispatch.unrespond('get-google-unread-count:' + this.props.mailboxId, this.handleGetGoogleUnreadCount) + mailboxDispatch.respond('get-google-unread-count:' + nextProps.mailboxId, this.handleGetGoogleUnreadCount) + } + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + mailboxCount: mailboxStore.getState().mailboxCount(), + ui: settingsState.ui, + os: settingsState.os + } + }, + + mailboxChanged (mailboxState) { + this.setState({ + mailboxCount: mailboxState.mailboxCount() + }) + }, + + composeChanged (composeState) { + // Look to see if we should dispatch a compose event down to the UI + // We clear this directly here rather resetting state + if (composeState.composing) { + if (this.state.mailboxCount === 1 || composeState.targetMailbox === this.props.mailboxId) { + this.refs[REF].send('compose-message', composeState.getMessageInfo()) + composeActions.clearCompose.defer() + } + } + }, + + settingsChanged (settingsState) { + this.setState((prevState) => { + const update = { os: settingsState.os } + if (settingsState.ui !== prevState.ui) { + this.refs[REF].send('window-icons-in-screen', { + inscreen: !settingsState.ui.sidebarEnabled && !settingsState.ui.showTitlebar && process.platform === 'darwin' + }) + update.ui = settingsState.ui + } + return update + }) + }, + + /* **************************************************************************/ + // Dispatcher Events + /* **************************************************************************/ + + /** + * Handles opening a new message + * @param evt: the event that fired + */ + handleOpenMessage (evt) { + if (evt.mailboxId === this.props.mailboxId) { + this.refs[REF].send('open-message', { messageId: evt.messageId, threadId: evt.threadId }) + } + }, + + /** + * Fetches the gmail unread count + * @return promise + */ + handleGetGoogleUnreadCount () { + return this.refs[REF].sendWithResponse('get-google-unread-count', {}, 1000) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Dispatches browser IPC messages to the correct call + * @param evt: the event that fired + */ + dispatchBrowserIPCMessage (evt) { + switch (evt.channel.type) { + case 'unread-count-changed': googleActions.suggestSyncMailboxUnreadCount(this.props.mailboxId); break + case 'js-new-window': this.handleBrowserJSNewWindow(evt); break + default: break + } + }, + + /** + * Handles the Browser DOM becoming ready + */ + handleBrowserDomReady () { + // UI Fixes + const ui = this.state.ui + this.refs[REF].send('window-icons-in-screen', { + inscreen: !ui.sidebarEnabled && !ui.showTitlebar && process.platform === 'darwin' + }) + }, + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + const purl = URL.parse(url, true) + let mode = 'external' + if (purl.host === 'inbox.google.com') { + mode = 'source' + } else if (purl.host === 'mail.google.com') { + if (purl.query.ui === '2' || purl.query.view === 'om') { + mode = 'tab' + } else { + mode = 'source' + } + } + + switch (mode) { + case 'external': + shell.openExternal(url, { activate: !this.state.os.openLinksInBackground }) + break + case 'source': + this.setState({ browserSrc: url }) + break + case 'tab': + ipcRenderer.send('new-window', { partition: 'persist:' + this.props.mailboxId, url: url }) + break + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + return ( + { this.handleOpenNewWindow(evt.url) }} + domReady={this.handleBrowserDomReady} + ipcMessage={this.dispatchBrowserIPCMessage} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxNotesTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxNotesTab.js new file mode 100644 index 00000000..54d96a05 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxNotesTab.js @@ -0,0 +1,76 @@ +const React = require('react') +const MailboxTabSleepable = require('../MailboxTabSleepable') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { settingsStore } = require('../../../stores/settings') +const { + remote: {shell} +} = window.nativeRequire('electron') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxNotesTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + os: settingsState.os + } + }, + + settingsChanged (settingsState) { + this.setState({ os: settingsState.os }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + shell.openExternal(url, { activate: !this.state.os.openLinksInBackground }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { mailboxId } = this.props + + return ( + { this.handleOpenNewWindow(evt.url) }} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxStorageTab.js b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxStorageTab.js new file mode 100644 index 00000000..5cd78875 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/Google/GoogleMailboxStorageTab.js @@ -0,0 +1,82 @@ +const React = require('react') +const MailboxTabSleepable = require('../MailboxTabSleepable') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const { settingsStore } = require('../../../stores/settings') +const URL = window.nativeRequire('url') +const { + remote: {shell}, ipcRenderer +} = window.nativeRequire('electron') + +const REF = 'mailbox_tab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'GoogleMailboxStorageTab', + propTypes: { + mailboxId: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + os: settingsState.os + } + }, + + settingsChanged (settingsState) { + this.setState({ os: settingsState.os }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Opens a new url in the correct way + * @param url: the url to open + */ + handleOpenNewWindow (url) { + const purl = URL.parse(url) + if (purl.host === 'docs.google.com') { + ipcRenderer.send('new-window', { partition: 'persist:' + this.props.mailboxId, url: url }) + } else { + shell.openExternal(url, { activate: !this.state.os.openLinksInBackground }) + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { mailboxId } = this.props + + return ( + { this.handleOpenNewWindow(evt.url) }} /> + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/GoogleMailboxWindow.js b/src/scenes/mailboxes/src/ui/Mailbox/GoogleMailboxWindow.js deleted file mode 100644 index 1967fa47..00000000 --- a/src/scenes/mailboxes/src/ui/Mailbox/GoogleMailboxWindow.js +++ /dev/null @@ -1,304 +0,0 @@ -import './mailboxWindow.less' -const React = require('react') -const ReactDOM = require('react-dom') -const flux = { - mailbox: require('../../stores/mailbox'), - google: require('../../stores/google'), - settings: require('../../stores/settings') -} -const Mailbox = require('shared/Models/Mailbox/Mailbox') -const {shell} = window.nativeRequire('electron').remote -const URL = window.nativeRequire('url') -const ipc = window.nativeRequire('electron').ipcRenderer -const mailboxDispatch = require('../Dispatch/mailboxDispatch') -const TimerMixin = require('react-timer-mixin') - -module.exports = React.createClass({ - displayName: 'GoogleMailboxWindow', - mixins: [TimerMixin], - propTypes: { - mailbox_id: React.PropTypes.string.isRequired - }, - - /* **************************************************************************/ - // Lifecycle - /* **************************************************************************/ - - componentDidMount () { - this.lastSetZoomFactor = 1.0 - this.isMounted = true - - flux.mailbox.S.listen(this.mailboxesChanged) - mailboxDispatch.on('reload', this.reload) - mailboxDispatch.on('devtools', this.openDevTools) - mailboxDispatch.on('refocus', this.refocus) - mailboxDispatch.on('openMessage', this.openMessage) - // Wait for the dom to start rendering - setTimeout(() => { - ReactDOM.findDOMNode(this).appendChild(this.renderWebviewDOMNode()) - }) - }, - - componentWillUnmount () { - this.isMounted = false - mailboxDispatch.off('reload', this.reload) - mailboxDispatch.off('devtools', this.openDevTools) - mailboxDispatch.off('refocus', this.refocus) - mailboxDispatch.off('openMessage', this.openMessage) - flux.mailbox.S.unlisten(this.mailboxesChanged) - }, - - /* **************************************************************************/ - // Data lifecycle - /* **************************************************************************/ - - getInitialState () { - const mailboxStore = flux.mailbox.S.getState() - return { - mailbox: mailboxStore.getMailbox(this.props.mailbox_id), - isActive: mailboxStore.activeMailboxId() === this.props.mailbox_id - } - }, - - mailboxesChanged (store) { - if (this.isMounted === false) { return } - this.setState({ - mailbox: store.getMailbox(this.props.mailbox_id), - isActive: store.activeMailboxId() === this.props.mailbox_id - }) - }, - - shouldComponentUpdate (nextProps, nextState) { - this.updateWebviewDOMNode(nextProps, nextState) - return false // we never update this element - }, - - /* **************************************************************************/ - // Dispatcher Events - /* **************************************************************************/ - - /** - * Handles a reload dispatch event - * @param evt: the event that fired - */ - reload (evt) { - if (evt.mailboxId === this.props.mailbox_id) { - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - if (webview) { - webview.setAttribute('src', this.state.mailbox.url) - flux.google.A.syncMailbox(this.state.mailbox) - } - } - }, - - /** - * Handles the inspector dispatch event - * @param evt: the event that fired - */ - openDevTools (evt) { - if (evt.mailboxId === this.props.mailbox_id) { - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - if (webview) { - webview.openDevTools() - } - } - }, - - /** - * Handles refocusing the mailbox - * @param evt: the event that fired - */ - refocus (evt) { - if (evt.mailboxId === this.props.mailbox_id || (!evt.mailboxId && this.state.isActive)) { - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - if (webview) { - setTimeout(() => { webview.focus() }) - } - } - }, - - /** - * Handles opening a new message - * @param evt: the event that fired - */ - openMessage (evt) { - if (evt.mailboxId === this.props.mailbox_id) { - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - if (webview) { - webview.send('open-message', { messageId: evt.messageId, threadId: evt.threadId }) - } - } - }, - - /* **************************************************************************/ - // UI Events - /* **************************************************************************/ - - /** - * Handles a new window open request - * @param evt: the event - * @param webview: the webview element the event came from - */ - handleOpenNewWindow (evt, webview) { - const url = URL.parse(evt.url, true) - let mode = 'external' - if (url.host === 'inbox.google.com') { - mode = 'source' - } else if (url.host === 'mail.google.com') { - if (url.query.ui === '2') { - mode = 'tab' - } else { - mode = 'source' - } - } - - switch (mode) { - case 'external': - shell.openExternal(evt.url, { activate: !flux.settings.S.getState().os.openLinksInBackground }) - break - case 'source': - webview.src = evt.url - break - case 'tab': - ipc.send('new-window', { partition: webview.partition, url: evt.url }) - break - } - }, - - /* **************************************************************************/ - // UI Modifiers - /* **************************************************************************/ - - /** - * Composes a new message - * @param email: the email address to send the message to - */ - composeMessage (email) { - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - - if (webview) { - switch (this.state.mailbox.type) { - case Mailbox.TYPE_GMAIL: - ipc.send('new-window', { - partition: webview.partition, - url: 'https://mail.google.com/mail/?view=cm&fs=1&tf=1&shva=1&to=' + email - }) - break - case Mailbox.TYPE_GINBOX: - webview.loadURL('https://inbox.google.com/?view=cm&fs=1&tf=1&shva=1&to=' + email) - break - } - } - }, - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * For some reason react strips out the partition keyword, so we have to generate - * the dom node. Also because it reloads the element when active changes and we need - * the ref to the node for binding electron events we sink down to normal html - */ - renderWebviewDOMNode () { - // Setup the session that will be used - const partition = 'persist:' + this.state.mailbox.id - ipc.send('prepare-webview-session', { partition: partition }) - - // Build the dom - const webview = document.createElement('webview') - webview.setAttribute('preload', '../platform/webviewInjection/google') - webview.setAttribute('partition', partition) - webview.setAttribute('src', this.state.mailbox.url) - webview.setAttribute('data-mailbox', this.state.mailbox.id) - webview.classList.add('mailbox-window') - - // Active state - if (this.state.isActive) { - webview.classList.add('active') - setTimeout(() => { - webview.focus() - }) - } - - // Bind events - webview.addEventListener('dom-ready', () => { - // Push the settings across - webview.send('zoom-factor-set', { value: this.state.mailbox.zoomFactor }) - this.lastSetZoomFactor = this.state.mailbox.zoomFactor - webview.send('start-spellcheck', { - enabled: flux.settings.S.getState().language.spellcheckerEnabled - }) - }) - - // Handle messages from the page - webview.addEventListener('ipc-message', (evt) => { - if (evt.channel.type === 'page-click') { - if (!flux.google.S.getState().hasOpenUnreadCountRequest(this.state.mailbox.id)) { - flux.google.A.syncMailboxUnreadCount(this.state.mailbox.id) - } - } else if (evt.channel.type === 'js-new-window') { - shell.openExternal(evt.channel.url, { activate: !flux.settings.S.getState().os.openLinksInBackground }) - } - }) - webview.addEventListener('new-window', (evt) => { - this.handleOpenNewWindow(evt, webview) - }) - webview.addEventListener('will-navigate', (evt) => { - // the lamest protection again dragging files into the window - // but this is the only thing I could find that leaves file drag working - if (evt.url.indexOf('file://') === 0) { - webview.setAttribute('src', this.state.mailbox.url) - } - }) - webview.addEventListener('focus', (evt) => { - mailboxDispatch.focused(this.props.mailbox_id) - }) - webview.addEventListener('blur', (evt) => { - mailboxDispatch.blurred(this.props.mailbox_id) - }) - - return webview - }, - - /** - * Update the dom node manually so that react doesn't keep re-loading our - * webview element when it decides that it wants to re-render - * @param nextProps: the next props - * @param nextState: the next state - */ - updateWebviewDOMNode (nextProps, nextState) { - if (!nextState.mailbox) { return } - const webview = ReactDOM.findDOMNode(this).getElementsByTagName('webview')[0] - if (!webview) { return } - - // Change the active state - if (this.state.isActive !== nextState.isActive) { - if (nextState.isActive) { - webview.classList.add('active') - setTimeout(() => { - webview.focus() - }) - } else { - webview.classList.remove('active') - } - } - - if (this.state.mailbox !== nextState.mailbox) { - // Set the zoom factor - if (nextState.mailbox.zoomFactor !== this.lastSetZoomFactor) { - webview.send('zoom-factor-set', { value: nextState.mailbox.zoomFactor }) - this.lastSetZoomFactor = nextState.mailbox.zoomFactor - } - } - }, - - /** - * Renders the app - */ - render () { - if (!this.state.mailbox) { return false } - - return
- } -}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxComposePicker.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxComposePicker.js new file mode 100644 index 00000000..f88a61a4 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxComposePicker.js @@ -0,0 +1,136 @@ +const React = require('react') +const { Dialog, RaisedButton, List, ListItem, Avatar } = require('material-ui') +const { composeStore, composeActions } = require('../../stores/compose') +const { mailboxStore, mailboxActions } = require('../../stores/mailbox') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxComposePicker', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + composeStore.listen(this.composeChanged) + mailboxStore.listen(this.mailboxChanged) + }, + + componentWillUnmount () { + composeStore.unlisten(this.composeChanged) + mailboxStore.unlisten(this.mailboxChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const mailboxState = mailboxStore.getState() + const composeState = composeStore.getState() + return { + mailboxes: mailboxState.allMailboxes(), + composing: composeState.composing + } + }, + + composeChanged (composeState) { + this.setState({ composing: composeState.composing }) + }, + + mailboxChanged (mailboxesState) { + this.setState({ mailboxes: mailboxesState.allMailboxes() }) + }, + + /* **************************************************************************/ + // Data utils + /* **************************************************************************/ + + /** + * Decides if the dialog is open or not + * @param state=this.state: the state to calc from + * @return true if the dialog should be open, false otherwise + */ + isOpen (state = this.state) { + return state.composing && state.mailboxes.length > 1 + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + /** + * Dismisses the compose actions + * @param evt: the event that fired + */ + handleCancel (evt) { + composeActions.clearCompose() + }, + + /** + * Handles selecting the target mailbox + * @param evt: the event that fired + * @param mailboxId: the id of the mailbox + */ + handleSelectMailbox (evt, mailboxId) { + mailboxActions.changeActive(mailboxId) + composeActions.setTargetMailbox(mailboxId) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + const prevOpen = this.isOpen(this.state) + const nextOpen = this.isOpen(nextState) + + if (prevOpen !== nextOpen) { return true } + if (nextOpen === false) { return false } + + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailboxes } = this.state + const mailboxState = mailboxStore.getState() + const actions = ( + + ) + + return ( + + + {mailboxes.map((mailbox) => { + let avatarSrc = '' + if (mailbox.hasCustomAvatar) { + avatarSrc = mailboxState.getAvatar(mailbox.customAvatarId) + } else if (mailbox.avatarURL) { + avatarSrc = mailbox.avatarURL + } + + return ( + } + primaryText={(mailbox.email || mailbox.name || mailbox.id)} + onClick={(evt) => this.handleSelectMailbox(evt, mailbox.id)} + key={mailbox.id} />) + })} + + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxSearch.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxSearch.js new file mode 100644 index 00000000..384c7b99 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxSearch.js @@ -0,0 +1,132 @@ +const React = require('react') +const { Paper, TextField, IconButton } = require('material-ui') +const Colors = require('material-ui/styles/colors') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxSearch', + propTypes: { + isSearching: React.PropTypes.bool.isRequired, + onSearchChange: React.PropTypes.func, + onSearchNext: React.PropTypes.func, + onSearchCancel: React.PropTypes.func + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState () { + return { + searchQuery: '' + } + }, + + /* **************************************************************************/ + // Actions + /* **************************************************************************/ + + /** + * Focuses the textfield + */ + focus () { this.refs.textField.focus() }, + + /** + * @return the current search query + */ + searchQuery () { return this.state.searchQuery }, + + /* **************************************************************************/ + // Events + /* **************************************************************************/ + + /** + * Handles the input string changing + */ + handleChange (evt) { + this.setState({searchQuery: evt.target.value}) + if (this.props.onSearchChange) { + this.props.onSearchChange(evt.target.value) + } + }, + + /** + * Handles the find next command + */ + handleFindNext () { + if (this.props.onSearchNext) { + this.props.onSearchNext(this.state.searchQuery) + } + }, + + /** + * Handles the search stopping + */ + handleStopSearch () { + this.setState({searchQuery: ''}) + if (this.props.onSearchCancel) { + this.props.onSearchCancel() + } + }, + + /** + * Handles a key being pressed + * @param evt: the event that fired + */ + handleKeyPress (evt) { + if (evt.keyCode === 13) { + evt.preventDefault() + this.handleFindNext() + } else if (evt.keyCode === 27) { + evt.preventDefault() + this.handleStopSearch() + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const passProps = Object.assign({}, this.props) + delete passProps.onSearchCancel + delete passProps.onSearchChange + delete passProps.onSearchNext + delete passProps.isSearching + + const className = [ + 'ReactComponent-MailboxSearch', + this.props.isSearching ? 'active' : undefined + ].concat(this.props.className).filter((c) => !!c).join(' ') + + return ( + + + + search + + + close + + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxTab.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTab.js new file mode 100644 index 00000000..dfe2ceff --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTab.js @@ -0,0 +1,477 @@ +const React = require('react') +const { mailboxStore, mailboxActions } = require('../../stores/mailbox') +const { settingsStore } = require('../../stores/settings') +const { ipcRenderer } = window.nativeRequire('electron') +const {mailboxDispatch, navigationDispatch} = require('../../Dispatch') +const { WebView } = require('../../Components') +const MailboxSearch = require('./MailboxSearch') +const MailboxTargetUrl = require('./MailboxTargetUrl') +const shallowCompare = require('react-addons-shallow-compare') + +const BROWSER_REF = 'browser' +const SEARCH_REF = 'search' + +module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxTab', + propTypes: Object.assign({ + mailboxId: React.PropTypes.string.isRequired, + service: React.PropTypes.string.isRequired, + preload: React.PropTypes.string, + src: React.PropTypes.string + }, WebView.REACT_WEBVIEW_EVENTS.reduce((acc, name) => { + acc[name] = React.PropTypes.func + return acc + }, {})), + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + componentDidMount () { + // Stores + mailboxStore.listen(this.mailboxesChanged) + settingsStore.listen(this.settingsChanged) + + // Handle dispatch events + mailboxDispatch.on('devtools', this.handleOpenDevTools) + mailboxDispatch.on('refocus', this.handleRefocus) + mailboxDispatch.on('reload', this.handleReload) + mailboxDispatch.respond('fetch-process-memory-info', this.handleFetchProcessMemoryInfo) + ipcRenderer.on('mailbox-window-find-start', this.handleIPCSearchStart) + ipcRenderer.on('mailbox-window-find-next', this.handleIPCSearchNext) + ipcRenderer.on('mailbox-window-navigate-back', this.handleIPCNavigateBack) + ipcRenderer.on('mailbox-window-navigate-forward', this.handleIPCNavigateForward) + + // Autofocus on the first run + if (this.state.isActive) { + setTimeout(() => { this.refs[BROWSER_REF].focus() }) + } + }, + + componentWillUnmount () { + // Stores + mailboxStore.unlisten(this.mailboxesChanged) + settingsStore.unlisten(this.settingsChanged) + + // Handle dispatch events + mailboxDispatch.off('devtools', this.handleOpenDevTools) + mailboxDispatch.off('refocus', this.handleRefocus) + mailboxDispatch.off('reload', this.handleReload) + mailboxDispatch.unrespond('fetch-process-memory-info', this.handleFetchProcessMemoryInfo) + ipcRenderer.removeListener('mailbox-window-find-start', this.handleIPCSearchStart) + ipcRenderer.removeListener('mailbox-window-find-next', this.handleIPCSearchNext) + ipcRenderer.removeListener('mailbox-window-navigate-back', this.handleIPCNavigateBack) + ipcRenderer.removeListener('mailbox-window-navigate-forward', this.handleIPCNavigateForward) + }, + + componentWillReceiveProps (nextProps) { + if (this.props.mailboxId !== nextProps.mailboxId || this.props.service !== nextProps.service || this.props.src !== nextProps.src) { + this.setState(this.getInitialState(nextProps)) + } + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState (props = this.props) { + const mailboxState = mailboxStore.getState() + const mailbox = mailboxState.getMailbox(props.mailboxId) + const settingState = settingsStore.getState() + + const isActive = mailboxState.isActive(props.mailboxId, props.service) + return { + mailbox: mailbox, + isActive: isActive, + isSearching: mailboxState.isSearchingMailbox(props.mailboxId, props.service), + browserSrc: props.src || mailbox.resolveServiceUrl(props.service), + language: settingState.language, + focusedUrl: null + } + }, + + mailboxesChanged (mailboxState) { + const { mailboxId, service } = this.props + const mailbox = mailboxState.getMailbox(mailboxId) + if (mailbox) { + this.setState((prevState) => { + const isActive = mailboxState.isActive(mailboxId, service) + + // Submit zoom state + if (prevState.mailbox.zoomFactor !== mailbox.zoomFactor) { + this.refs[BROWSER_REF].setZoomLevel(mailbox.zoomFactor) + } + + // Return state + return { + mailbox: mailbox, + isActive: isActive, + isSearching: mailboxState.isSearchingMailbox(mailboxId, service), + browserSrc: this.props.src || mailbox.resolveServiceUrl(service) + } + }) + } else { + this.setState({ mailbox: null }) + } + }, + + settingsChanged (settingsState) { + this.setState((prevState) => { + if (settingsState.language !== prevState.language) { + const prevLanguage = prevState.language + const nextLanguage = settingsState.language + + if (prevLanguage.spellcheckerLanguage !== nextLanguage.spellcheckerLanguage || prevLanguage.secondarySpellcheckerLanguage !== nextLanguage.secondarySpellcheckerLanguage) { + this.refs[BROWSER_REF].send('start-spellcheck', { + language: nextLanguage.spellcheckerLanguage, + secondaryLanguage: nextLanguage.secondarySpellcheckerLanguage + }) + } + + return { language: nextLanguage } + } else { + return undefined + } + }) + }, + + /* **************************************************************************/ + // Webview pass throughs + /* **************************************************************************/ + + send () { return this.refs[BROWSER_REF].send.apply(this, Array.from(arguments)) }, + sendWithResponse () { return this.refs[BROWSER_REF].sendWithResponse.apply(this, Array.from(arguments)) }, + + /* **************************************************************************/ + // Dispatcher Events + /* **************************************************************************/ + + /** + * Handles the inspector dispatch event + * @param evt: the event that fired + */ + handleOpenDevTools (evt) { + if (evt.mailboxId === this.props.mailboxId) { + if (!evt.service && this.state.isActive) { + this.refs[BROWSER_REF].openDevTools() + } else if (evt.service === this.props.service) { + this.refs[BROWSER_REF].openDevTools() + } + } + }, + + /** + * Handles refocusing the mailbox + * @param evt: the event that fired + */ + handleRefocus (evt) { + if (!evt.mailboxId || !evt.service || (evt.mailboxId === this.props.mailboxId && evt.service === this.props.service)) { + setTimeout(() => { this.refs[BROWSER_REF].focus() }) + } + }, + + /** + * Handles reloading the mailbox + * @param evt: the event that fired + */ + handleReload (evt) { + if (evt.mailboxId === this.props.mailboxId) { + if (evt.allServices) { + this.refs[BROWSER_REF].reload() + } else if (!evt.service && this.state.isActive) { + this.refs[BROWSER_REF].reload() + } else if (evt.service === this.props.service) { + this.refs[BROWSER_REF].reload() + } + } + }, + + /** + * Fetches the webviews process memory info + * @return promise + */ + handleFetchProcessMemoryInfo () { + return this.refs[BROWSER_REF].getProcessMemoryInfo().then((memoryInfo) => { + return Promise.resolve({ + mailboxId: this.props.mailboxId, + memoryInfo: memoryInfo + }) + }) + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Calls multiple handlers for browser events + * @param callers: a list of callers to execute + * @param args: the arguments to supply them with + */ + multiCallBrowserEvent (callers, args) { + callers.forEach((caller) => { + if (caller) { + caller.apply(this, args) + } + }) + }, + + /* **************************************************************************/ + // Browser Events : Dispatcher + /* **************************************************************************/ + + /** + * Dispatches browser IPC messages to the correct call + * @param evt: the event that fired + */ + dispatchBrowserIPCMessage (evt) { + switch (evt.channel.type) { + case 'open-settings': navigationDispatch.openSettings(); break + default: break + } + }, + + /* **************************************************************************/ + // Browser Events + /* **************************************************************************/ + + /** + * Handles the Browser DOM becoming ready + */ + handleBrowserDomReady () { + // Push the settings across + this.refs[BROWSER_REF].setZoomLevel(this.state.mailbox.zoomFactor) + + // Language + const languageSettings = this.state.language + if (languageSettings.spellcheckerEnabled) { + this.refs[BROWSER_REF].send('start-spellcheck', { + language: languageSettings.spellcheckerLanguage, + secondaryLanguage: languageSettings.secondarySpellcheckerLanguage + }) + } + + // Push the custom user content + if (this.state.mailbox.hasCustomCSS || this.state.mailbox.hasCustomJS) { + this.refs[BROWSER_REF].send('inject-custom-content', { + css: this.state.mailbox.customCSS, + js: this.state.mailbox.customJS + }) + } + }, + + /** + * Until https://github.com/electron/electron/issues/6958 is fixed we need to + * be really agressive about setting zoom levels + */ + handleZoomFixEvent () { + this.refs[BROWSER_REF].setZoomLevel(this.state.mailbox.zoomFactor) + }, + + /** + * Updates the target url that the user is hovering over + * @param evt: the event that fired + */ + handleBrowserUpdateTargetUrl (evt) { + this.setState({ focusedUrl: evt.url !== '' ? evt.url : null }) + }, + + /* **************************************************************************/ + // Browser Events : Navigation + /* **************************************************************************/ + + /** + * Handles a browser preparing to navigate + * @param evt: the event that fired + */ + handleBrowserWillNavigate (evt) { + // the lamest protection again dragging files into the window + // but this is the only thing I could find that leaves file drag working + if (evt.url.indexOf('file://') === 0) { + this.setState({ browserSrc: this.state.mailbox.resolveServiceUrl(this.props.service) }) + } + }, + + /* **************************************************************************/ + // Browser Events : Focus + /* **************************************************************************/ + + /** + * Handles a browser focusing + */ + handleBrowserFocused () { + mailboxDispatch.focused(this.props.mailboxId, this.props.service) + }, + + /** + * Handles a browser un-focusing + */ + handleBrowserBlurred () { + mailboxDispatch.blurred(this.props.mailboxId, this.props.service) + }, + + /* **************************************************************************/ + // UI Events : Search + /* **************************************************************************/ + + /** + * Handles the search text changing + * @param str: the search string + */ + handleSearchChanged (str) { + if (str.length) { + this.refs[BROWSER_REF].findInPage(str) + } else { + this.refs[BROWSER_REF].stopFindInPage('clearSelection') + } + }, + + /** + * Handles searching for the next occurance + */ + handleSearchNext (str) { + if (str.length) { + this.refs[BROWSER_REF].findInPage(str, { findNext: true }) + } + }, + + /** + * Handles cancelling searching + */ + handleSearchCancel () { + mailboxActions.stopSearchingMailbox(this.props.mailboxId, this.props.service) + this.refs[BROWSER_REF].stopFindInPage('clearSelection') + }, + + /* **************************************************************************/ + // IPC Events + /* **************************************************************************/ + + /** + * Handles an ipc search start event coming in + */ + handleIPCSearchStart () { + if (this.state.isActive) { + setTimeout(() => { this.refs[SEARCH_REF].focus() }) + } + }, + + /** + * Handles an ipc search next event coming in + */ + handleIPCSearchNext () { + if (this.state.isActive) { + this.handleSearchNext(this.refs[SEARCH_REF].searchQuery()) + } + }, + + /** + * Handles navigating the mailbox back + */ + handleIPCNavigateBack () { + if (this.state.isActive) { + this.refs[BROWSER_REF].navigateBack() + } + }, + + /** + * Handles navigating the mailbox forward + */ + handleIPCNavigateForward () { + if (this.state.isActive) { + this.refs[BROWSER_REF].navigateForward() + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * Renders the app + */ + render () { + // Extract our props and pass props + const { isActive, browserSrc, focusedUrl, isSearching, mailbox } = this.state + const { mailboxId, className, preload, ...passProps } = this.props + delete passProps.service + const webviewEventProps = WebView.REACT_WEBVIEW_EVENTS.reduce((acc, name) => { + acc[name] = this.props[name] + delete passProps[name] + return acc + }, {}) + + // See if we should render + if (!mailbox) { return false } + + // Prep Clasnames and running functions + const saltedClassName = [ + className, + 'ReactComponent-MailboxTab', + isActive ? 'active' : undefined + ].filter((c) => !!c).join(' ') + const zoomFixFn = mailbox.zoomFactor === 1 ? undefined : this.handleZoomFixEvent + + if (isActive) { + setTimeout(() => { this.refs[BROWSER_REF].focus() }) + } + + return ( +
+ { + this.multiCallBrowserEvent([zoomFixFn, webviewEventProps.loadCommit], [evt]) + }} + didGetResponseDetails={(evt) => { + this.multiCallBrowserEvent([zoomFixFn, webviewEventProps.didGetResponseDetails], [evt]) + }} + didNavigate={(evt) => { + this.multiCallBrowserEvent([zoomFixFn, webviewEventProps.didNavigate], [evt]) + }} + didNavigateInPage={(evt) => { + this.multiCallBrowserEvent([zoomFixFn, webviewEventProps.didNavigateInPage], [evt]) + }} + domReady={(evt) => { + this.multiCallBrowserEvent([this.handleBrowserDomReady, webviewEventProps.domReady], [evt]) + }} + ipcMessage={(evt) => { + this.multiCallBrowserEvent([this.dispatchBrowserIPCMessage, webviewEventProps.ipcMessage], [evt]) + }} + willNavigate={(evt) => { + this.multiCallBrowserEvent([zoomFixFn, this.handleBrowserWillNavigate, webviewEventProps.willNavigate], [evt]) + }} + focus={(evt) => { + this.multiCallBrowserEvent([this.handleBrowserFocused, webviewEventProps.focus], [evt]) + }} + blur={(evt) => { + this.multiCallBrowserEvent([this.handleBrowserBlurred, webviewEventProps.blur], [evt]) + }} + updateTargetUrl={(evt) => { + this.multiCallBrowserEvent([this.handleBrowserUpdateTargetUrl, webviewEventProps.updateTargetUrl], [evt]) + }} /> + + +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxTabSleepable.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTabSleepable.js new file mode 100644 index 00000000..42a3e8bd --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTabSleepable.js @@ -0,0 +1,105 @@ +const React = require('react') +const MailboxTab = require('./MailboxTab') +const { mailboxStore } = require('../../stores/mailbox') +const { MAILBOX_SLEEP_WAIT } = require('shared/constants') + +const REF = 'mailboxTab' + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxTabSleepable', + propTypes: Object.assign({}, MailboxTab.propTypes), + + /* **************************************************************************/ + // Component lifecylce + /* **************************************************************************/ + + componentDidMount () { + this.sleepWait = null + + mailboxStore.listen(this.mailboxUpdated) + }, + + componentWillUnmount () { + clearTimeout(this.sleepWait) + + mailboxStore.unlisten(this.mailboxUpdated) + }, + + componentWillReceiveProps (nextProps) { + if (this.props.mailboxId !== nextProps.mailboxId || this.props.service !== nextProps.service) { + clearTimeout(this.sleepWait) + this.setState(this.getInitialState(nextProps)) + } + }, + + /* **************************************************************************/ + // Data lifecylce + /* **************************************************************************/ + + getInitialState (props = this.props) { + const mailboxState = mailboxStore.getState() + const isActive = mailboxState.isActive(props.mailboxId, props.service) + const mailbox = mailboxState.getMailbox(props.mailboxId) + return { + isActive: isActive, + isSleeping: !isActive, + allowsSleeping: mailbox ? (new Set(mailbox.sleepableServices)).has(props.service) : true + } + }, + + mailboxUpdated (mailboxState) { + this.setState((prevState) => { + const mailbox = mailboxState.getMailbox(this.props.mailboxId) + const update = { + isActive: mailboxState.isActive(this.props.mailboxId, this.props.service), + allowsSleeping: mailbox ? (new Set(mailbox.sleepableServices)).has(this.props.service) : true + } + if (prevState.isActive !== update.isActive) { + clearTimeout(this.sleepWait) + if (prevState.isActive && !update.isActive) { + this.sleepWait = setTimeout(() => { + this.setState({ isSleeping: true }) + }, MAILBOX_SLEEP_WAIT) + } else { + update.isSleeping = false + } + } + return update + }) + }, + + /* **************************************************************************/ + // Webview pass throughs + /* **************************************************************************/ + + send () { + if (this.refs[REF]) { + return this.refs[REF].send.apply(this, Array.from(arguments)) + } else { + throw new Error('MailboxTab is sleeping') + } + }, + sendWithResponse () { + if (this.refs[REF]) { + return this.refs[REF].sendWithResponse.apply(this, Array.from(arguments)) + } else { + throw new Error('MailboxTab is sleeping') + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + if (this.state.allowsSleeping && this.state.isSleeping) { + return false + } else { + return () + } + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxTargetUrl.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTargetUrl.js new file mode 100644 index 00000000..adde018b --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxTargetUrl.js @@ -0,0 +1,31 @@ +const React = require('react') +const { Paper } = require('material-ui') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxTargetUrl', + propTypes: { + url: React.PropTypes.string + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { url, ...passProps } = this.props + + const className = [ + 'ReactComponent-MailboxTargetUrl', + url ? 'active' : undefined + ].concat(this.props.className).filter((c) => !!c).join(' ') + return ( + + {url} + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/MailboxWindows.js b/src/scenes/mailboxes/src/ui/Mailbox/MailboxWindows.js index ecb909a1..3de72992 100644 --- a/src/scenes/mailboxes/src/ui/Mailbox/MailboxWindows.js +++ b/src/scenes/mailboxes/src/ui/Mailbox/MailboxWindows.js @@ -1,11 +1,16 @@ -'use strict' +import './mailboxWindow.less' const React = require('react') -const flux = { - mailbox: require('../../stores/mailbox') -} -const GoogleMailboxWindow = require('./GoogleMailboxWindow') +const { mailboxStore } = require('../../stores/mailbox') const Welcome = require('../Welcome/Welcome') +const Mailbox = require('shared/Models/Mailbox/Mailbox') + +const GoogleMailboxCalendarTab = require('./Google/GoogleMailboxCalendarTab') +const GoogleMailboxContactsTab = require('./Google/GoogleMailboxContactsTab') +const GoogleMailboxMailTab = require('./Google/GoogleMailboxMailTab') +const GoogleMailboxNotesTab = require('./Google/GoogleMailboxNotesTab') +const GoogleMailboxStorageTab = require('./Google/GoogleMailboxStorageTab') +const GoogleMailboxCommunicationTab = require('./Google/GoogleMailboxCommunicationTab') module.exports = React.createClass({ displayName: 'MailboxWindows', @@ -15,11 +20,11 @@ module.exports = React.createClass({ /* **************************************************************************/ componentDidMount () { - flux.mailbox.S.listen(this.mailboxesChanged) + mailboxStore.listen(this.mailboxesChanged) }, componentWillUnmount () { - flux.mailbox.S.unlisten(this.mailboxesChanged) + mailboxStore.unlisten(this.mailboxesChanged) }, /* **************************************************************************/ @@ -27,44 +32,83 @@ module.exports = React.createClass({ /* **************************************************************************/ getInitialState () { - return { mailbox_ids: flux.mailbox.S.getState().mailboxIds() } + const mailboxState = mailboxStore.getState() + return { + tabIds: this.generateMailboxList(mailboxState), + activeMailboxId: mailboxState.activeMailboxId() // doesn't cause re-render + } }, - mailboxesChanged (store) { - this.setState({ mailbox_ids: store.mailboxIds() }) + /** + * Generates the mailbox list from the state + * @param mailboxState: the state of the mailbox + * @return a list of mailboxIds + service types + */ + generateMailboxList (mailboxState) { + return mailboxState.allMailboxes().reduce((acc, mailbox) => { + return acc.concat( + [`${mailbox.type}:${mailbox.id}:${Mailbox.SERVICES.DEFAULT}`], + mailbox.enabledServies.map((service) => { + return `${mailbox.type}:${mailbox.id}:${service}` + }) + ) + }, []) }, - shouldComponentUpdate (nextProps, nextState) { - if (!this.state || !nextState) { return true } - if (this.state.mailbox_ids.length !== nextState.mailbox_ids.length) { return true } - - const mismatch = this.state.mailbox_ids.findIndex((id) => { - return nextState.mailbox_ids.findIndex((nId) => nId === id) === -1 - }) !== -1 - if (mismatch) { return true } - - return false + mailboxesChanged (mailboxState) { + this.setState({ + tabIds: this.generateMailboxList(mailboxState), + activeMailboxId: mailboxState.activeMailboxId() + }) }, /* **************************************************************************/ // Rendering /* **************************************************************************/ + shouldComponentUpdate (nextProps, nextState) { + if (JSON.stringify(this.state.tabIds) !== JSON.stringify(nextState.tabIds)) { return true } + return false + }, + /** - * Renders the app + * Renders an individual tab + * @param key: the element key + * @param mailboxType: the type of mailbox + * @param mailboxId: the id of the mailbox + * @param service: the service of the tab + * @return jsx */ + renderTab (key, mailboxType, mailboxId, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.DEFAULT: return () + case Mailbox.SERVICES.CALENDAR: return () + case Mailbox.SERVICES.CONTACTS: return () + case Mailbox.SERVICES.NOTES: return () + case Mailbox.SERVICES.STORAGE: return () + case Mailbox.SERVICES.COMMUNICATION: return () + } + } + + return undefined + }, + render () { - if (this.state.mailbox_ids.length) { + const { tabIds } = this.state + + if (tabIds.length) { return ( -
- {this.state.mailbox_ids.map((id) => { - return () +
+ {tabIds.map((id) => { + const [mailboxType, mailboxId, service] = id.split(':') + return this.renderTab(id, mailboxType, mailboxId, service) })}
) } else { return ( -
+
) diff --git a/src/scenes/mailboxes/src/ui/Mailbox/mailboxWindow.less b/src/scenes/mailboxes/src/ui/Mailbox/mailboxWindow.less index 509216c0..42e9bf47 100644 --- a/src/scenes/mailboxes/src/ui/Mailbox/mailboxWindow.less +++ b/src/scenes/mailboxes/src/ui/Mailbox/mailboxWindow.less @@ -1,14 +1,57 @@ -.mailboxes .mailbox-window { - position: absolute; - top: 0px; - bottom: 0px; - left: auto; - right: -10000px; - width: 100%; - height: 100%; - - &.active { - left: 0px; - right: 0px; - } -} \ No newline at end of file +.ReactComponent-MailboxWindows { + .ReactComponent-MailboxTab { + position: absolute; + top: 10000px; + bottom: -10000px; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + + &.active { + top: 0px; + bottom: 0px; + } + + @SEARCH_HEIGHT: 48px; + .ReactComponent-MailboxSearch { + position: absolute; + bottom: -@SEARCH_HEIGHT; + left: 0px; + min-width: 300px; + height: @SEARCH_HEIGHT; + background-color: white; + transition: none !important; + z-index: 10; + overflow: hidden; + + &.active { + bottom: 0px; + } + } + + @TARGET_URL_HEIGHT: 16px; + .ReactComponent-MailboxTargetUrl { + position: absolute; + bottom: -@TARGET_URL_HEIGHT; + height: @TARGET_URL_HEIGHT; + max-width: 50%; + right: 0px; + background-color: white; + z-index: 9; + overflow: hidden; + text-align: right; + font-size: 11px; + line-height: @TARGET_URL_HEIGHT; + padding-left: 3px; + padding-right: 3px; + transition-duration: 150ms !important; + white-space: nowrap; + text-overflow: ellipsis; + + &.active { + bottom: 0px; + } + } + } +} diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/AddMailboxWizardDialog.js b/src/scenes/mailboxes/src/ui/MailboxWizard/AddMailboxWizardDialog.js new file mode 100644 index 00000000..019bac5f --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/AddMailboxWizardDialog.js @@ -0,0 +1,110 @@ +const React = require('react') +const { Dialog, RaisedButton, Avatar } = require('material-ui') +const { mailboxWizardStore, mailboxWizardActions } = require('../../stores/mailboxWizard') +const shallowCompare = require('react-addons-shallow-compare') + +const styles = { + mailboxRow: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center' + }, + mailboxCell: { + textAlign: 'center', + marginTop: 20, + marginBottom: 20, + marginLeft: 40, + marginRight: 40 + }, + mailboxAvatar: { + cursor: 'pointer' + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AddMailboxWizardDialog', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxWizardStore.listen(this.wizardChanged) + }, + + componentWillUnmount () { + mailboxWizardStore.unlisten(this.wizardChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const wizardState = mailboxWizardStore.getState() + return { + isOpen: wizardState.addMailboxOpen + } + }, + + wizardChanged (wizardState) { + this.setState({ + isOpen: wizardState.addMailboxOpen + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isOpen } = this.state + const actions = ( + mailboxWizardActions.cancelAddMailbox()} /> + ) + + return ( + mailboxWizardActions.cancelAddMailbox()}> +
+
+ mailboxWizardActions.authenticateGmailMailbox()} /> +

Add your Gmail account

+ mailboxWizardActions.authenticateGmailMailbox()} /> +
+
+ mailboxWizardActions.authenticateGinboxMailbox()} /> +

Add your Google Inbox account

+ mailboxWizardActions.authenticateGinboxMailbox()} /> +
+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureCompleteWizardDialog.js b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureCompleteWizardDialog.js new file mode 100644 index 00000000..329250fd --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureCompleteWizardDialog.js @@ -0,0 +1,98 @@ +const React = require('react') +const { FontIcon, Dialog, RaisedButton } = require('material-ui') +const Colors = require('material-ui/styles/colors') +const { mailboxWizardStore, mailboxWizardActions } = require('../../stores/mailboxWizard') +const { appWizardActions } = require('../../stores/appWizard') +const { settingsStore } = require('../../stores/settings') + +const styles = { + container: { + textAlign: 'center' + }, + tick: { + color: Colors.green600, + fontSize: '80px' + }, + instruction: { + textAlign: 'center' + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'ConfigureCompleteWizardDialog', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxWizardStore.listen(this.mailboxWizardChanged) + settingsStore.listen(this.settingsChanged) + }, + + componentWillUnmount () { + mailboxWizardStore.unlisten(this.mailboxWizardChanged) + settingsStore.unlisten(this.settingsChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + isOpen: mailboxWizardStore.getState().configurationCompleteOpen, + hasSeenAppWizard: settingsStore.getState().app.hasSeenAppWizard + } + }, + + mailboxWizardChanged (wizardState) { + this.setState({ isOpen: wizardState.configurationCompleteOpen }) + }, + + settingsChanged (settingsState) { + this.setState({ hasSeenAppWizard: settingsStore.getState().app.hasSeenAppWizard }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { isOpen, hasSeenAppWizard } = this.state + const actions = ( + { + mailboxWizardActions.configurationComplete() + if (!hasSeenAppWizard) { + setTimeout(() => { + appWizardActions.startWizard() + }, 500) // Feels more natural after a delay + } + }} /> + ) + + return ( + +
+ check_circle +

All Done!

+

+ You can change your mailbox settings at any time in the settings +

+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGinboxMailboxWizard.js b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGinboxMailboxWizard.js new file mode 100644 index 00000000..563714af --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGinboxMailboxWizard.js @@ -0,0 +1,123 @@ +const React = require('react') +const { RaisedButton, Paper } = require('material-ui') +const shallowCompare = require('react-addons-shallow-compare') +const { Configurations } = require('../../stores/mailboxWizard') +const { Mailbox } = require('shared/Models/Mailbox') +const Colors = require('material-ui/styles/colors') + +const styles = { + introduction: { + textAlign: 'center', + padding: 12, + fontSize: '110%', + fontWeight: 'bold' + }, + configurations: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center' + }, + configuration: { + padding: 8, + margin: 8, + textAlign: 'center', + display: 'flex', + flexGrow: 1, + flexDirection: 'column', + flexBasis: '50%', + justifyContent: 'space-between', + cursor: 'pointer' + }, + configurationButton: { + display: 'block', + margin: 12 + }, + configurationImage: { + height: 150, + marginTop: 8, + backgroundSize: 'contain', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat' + }, + configurationTechInfo: { + color: Colors.grey500, + fontSize: '85%' + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'ConfigureGinboxMailboxWizard', + propTypes: { + onPickedConfiguration: React.PropTypes.func.isRequired + }, + statics: { + /** + * Renders the title element + * @return jsx + */ + renderTitle () { + return ( +
+ Pick the way that you normally use Google Inbox to configure WMail + notifications and unread counters +
+ ) + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { onPickedConfiguration } = this.props + + return ( +
+
+ onPickedConfiguration(Configurations[Mailbox.TYPE_GINBOX].DEFAULT_INBOX)}> +
+ +
+

+ I'm only interested in messages in my inbox that aren't in bundles. + This is default behaviour also seen in the iOS and Android Inbox Apps +

+

+ Unread Unbundled Messages in Inbox +

+
+ + onPickedConfiguration(Configurations[Mailbox.TYPE_GINBOX].UNREAD_INBOX)}> +
+ +
+

+ I'm interested in all unread messages in my inbox +

+

+ Unread Messages in Inbox +

+
+ +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGmailMailboxWizard.js b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGmailMailboxWizard.js new file mode 100644 index 00000000..b73f47cf --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureGmailMailboxWizard.js @@ -0,0 +1,138 @@ +const React = require('react') +const { RaisedButton, Paper } = require('material-ui') +const shallowCompare = require('react-addons-shallow-compare') +const { Configurations } = require('../../stores/mailboxWizard') +const { Mailbox } = require('shared/Models/Mailbox') +const Colors = require('material-ui/styles/colors') + +const styles = { + introduction: { + textAlign: 'center', + padding: 12, + fontSize: '110%', + fontWeight: 'bold' + }, + configurations: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center' + }, + configuration: { + padding: 8, + margin: 8, + textAlign: 'center', + display: 'flex', + flexGrow: 1, + flexDirection: 'column', + flexBasis: '50%', + justifyContent: 'space-between', + cursor: 'pointer' + }, + configurationButton: { + display: 'block', + margin: 12 + }, + configurationImage: { + height: 80, + marginTop: 8, + backgroundSize: 'contain', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat' + }, + configurationTechInfo: { + color: Colors.grey500, + fontSize: '85%' + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'ConfigureGmailMailboxWizard', + propTypes: { + onPickedConfiguration: React.PropTypes.func.isRequired + }, + statics: { + /** + * Renders the title element + * @return jsx + */ + renderTitle () { + return ( +
+ Pick the type of inbox that you use in Gmail to configure WMail + notifications and unread counters +
+ ) + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { onPickedConfiguration } = this.props + + return ( +
+
+ onPickedConfiguration(Configurations[Mailbox.TYPE_GMAIL].DEFAULT_INBOX)}> +
+ +
+

+ I'm only interested in unread messages in the primary category +

+

+ Unread Messages in Primary Category +

+
+ + onPickedConfiguration(Configurations[Mailbox.TYPE_GMAIL].UNREAD_INBOX)}> +
+ +
+

+ I'm interested in all unread messages in my inbox +

+

+ All Unread Messages +

+
+ + onPickedConfiguration(Configurations[Mailbox.TYPE_GMAIL].PRIORIY_INBOX)}> +
+ +
+

+ I'm only interested in unread messages if they are marked as important +

+

+ Unread Important Messages +

+
+ +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxServicesDialog.js b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxServicesDialog.js new file mode 100644 index 00000000..2f2d4538 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxServicesDialog.js @@ -0,0 +1,191 @@ +const React = require('react') +const { + Dialog, RaisedButton, Checkbox, Toggle, + Table, TableBody, TableRow, TableRowColumn +} = require('material-ui') +const { mailboxWizardStore, mailboxWizardActions } = require('../../stores/mailboxWizard') +const { Mailbox } = require('shared/Models/Mailbox') + +const styles = { + introduction: { + textAlign: 'center', + padding: 12, + fontSize: '110%', + fontWeight: 'bold' + }, + actionCell: { + width: 48, + paddingLeft: 0, + paddingRight: 0, + textAlign: 'center' + }, + titleCell: { + paddingLeft: 0, + paddingRight: 0 + }, + avatar: { + height: 22, + width: 22, + top: 2 + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'ConfigureMailboxServicesDialog', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxWizardStore.listen(this.mailboxWizardChanged) + }, + + componentWillUnmount () { + mailboxWizardStore.unlisten(this.mailboxWizardChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState (wizardState = mailboxWizardStore.getState()) { + return { + isOpen: wizardState.configureServicesOpen, + mailboxType: wizardState.provisonaMailboxType(), + availableServices: wizardState.provisionalMailboxSupportedServices(), + enabledServices: new Set(wizardState.provisionalDefaultMailboxServices()), + compactServices: false + } + }, + + mailboxWizardChanged (wizardState) { + this.setState((prevState) => { + if (!prevState.isOpen && wizardState.configureServicesOpen) { + return this.getInitialState(wizardState) + } else { + return { isOpen: wizardState.configureServicesOpen } + } + }) + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + /** + * Toggles a service + * @param service: the service type + * @param toggled: true if its enabled, false otherwise + */ + handleToggleService (service, toggled) { + this.setState((prevState) => { + const enabledServices = new Set(Array.from(prevState.enabledServices)) + enabledServices[toggled ? 'add' : 'delete'](service) + return { enabledServices: enabledServices } + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + /** + * Renders the service name + * @param mailboxType: the type of mailbox + * @param service: the service type + * @return the human name for the service + */ + getServiceName (mailboxType, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.STORAGE: return 'Google Drive' + case Mailbox.SERVICES.CONTACTS: return 'Google Contacts' + case Mailbox.SERVICES.NOTES: return 'Google Keep' + case Mailbox.SERVICES.CALENDAR: return 'Google Calendar' + case Mailbox.SERVICES.COMMUNICATION: return 'Google Hangouts' + } + } + + return '' + }, + + /** + * @param mailboxType: the type of mailbox + * @param service: the service type + * @return the url of the service icon + */ + getServiceIconUrl (mailboxType, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.STORAGE: return '../../images/google_services/logo_drive_128px.png' + case Mailbox.SERVICES.CONTACTS: return '../../images/google_services/logo_contacts_128px.png' + case Mailbox.SERVICES.NOTES: return '../../images/google_services/logo_keep_128px.png' + case Mailbox.SERVICES.CALENDAR: return '../../images/google_services/logo_calendar_128px.png' + case Mailbox.SERVICES.COMMUNICATION: return '../../images/google_services/logo_hangouts_128px.png' + } + } + + return '' + }, + + render () { + const { isOpen, enabledServices, mailboxType, availableServices, compactServices } = this.state + const actions = ( + { + mailboxWizardActions.configureMailboxServices(Array.from(enabledServices), compactServices) + }} /> + ) + + return ( + +
+ WMail also gives you access to the other services you use. Pick which + services you would like to enable for this account +
+ + + + {availableServices.map((service) => { + return ( + + + + + + {this.getServiceName(mailboxType, service)} + + + this.handleToggleService(service, checked)} + checked={enabledServices.has(service)} /> + + + ) + })} + +
+ + this.setState({ compactServices: toggled })} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxWizardDialog.js b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxWizardDialog.js new file mode 100644 index 00000000..b88eeb02 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/ConfigureMailboxWizardDialog.js @@ -0,0 +1,122 @@ +const React = require('react') +const { Dialog, RaisedButton } = require('material-ui') +const { mailboxWizardStore, mailboxWizardActions } = require('../../stores/mailboxWizard') +const shallowCompare = require('react-addons-shallow-compare') +const { Mailbox } = require('shared/Models/Mailbox') + +const ConfigureGinboxMailboxWizard = require('./ConfigureGinboxMailboxWizard') +const ConfigureGmailMailboxWizard = require('./ConfigureGmailMailboxWizard') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'ConfigureMailboxWizardDialog', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxWizardStore.listen(this.wizardChanged) + }, + + componentWillUnmount () { + mailboxWizardStore.unlisten(this.wizardChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const wizardState = mailboxWizardStore.getState() + return { + isOpen: wizardState.configurationOpen, + mailboxType: wizardState.provisonaMailboxType() + } + }, + + wizardChanged (wizardState) { + this.setState({ + isOpen: wizardState.addMailboxOpen, + mailboxType: wizardState.provisonaMailboxType() + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * @param mailboxType: the type of mailbox + * @return the configurator class for this mailbox type or undefined + */ + getConfiguratorClass (mailboxType) { + switch (mailboxType) { + case Mailbox.TYPE_GINBOX: return ConfigureGinboxMailboxWizard + case Mailbox.TYPE_GMAIL: return ConfigureGmailMailboxWizard + default: return undefined + } + }, + + /** + * Renders the mailbox configurator for the given type + * @param mailboxType: the type of mailbox + * @return jsx + */ + renderMailboxConfigurator (mailboxType) { + const Configurator = this.getConfiguratorClass(mailboxType) + return Configurator ? ( + mailboxWizardActions.configureMailbox(cfg)} /> + ) : undefined + }, + + /** + * Renders the mailbox configurator title for the given type + * @param mailboxType: the type of mailbox + * @return jsx + */ + renderMailboxConfiguratorTitle (mailboxType) { + const Configurator = this.getConfiguratorClass(mailboxType) + return Configurator && Configurator.renderTitle ? Configurator.renderTitle() : undefined + }, + + /** + * Renders the action buttons based on if there is a configuration or not + * @return jsx + */ + renderActions () { + return ( +
+ mailboxWizardActions.configureMailbox({})} /> +
+ ) + }, + + render () { + const { isOpen, mailboxType } = this.state + + return ( + mailboxWizardActions.cancelAddMailbox()} + autoScrollBodyContent> + {this.renderMailboxConfigurator(mailboxType)} + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/MailboxWizard.js b/src/scenes/mailboxes/src/ui/MailboxWizard/MailboxWizard.js new file mode 100644 index 00000000..698abf22 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/MailboxWizard.js @@ -0,0 +1,82 @@ +const React = require('react') +const { mailboxWizardStore } = require('../../stores/mailboxWizard') +const shallowCompare = require('react-addons-shallow-compare') +const AddMailboxWizardDialog = require('./AddMailboxWizardDialog') +const ConfigureMailboxWizardDialog = require('./ConfigureMailboxWizardDialog') +const ConfigureMailboxServicesDialog = require('./ConfigureMailboxServicesDialog') +const ConfigureCompleteWizardDialog = require('./ConfigureCompleteWizardDialog') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'MailboxWizard', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + this.renderTO = null + mailboxWizardStore.listen(this.wizardChanged) + }, + + componentWillUnmount () { + clearTimeout(this.renderTO) + mailboxWizardStore.unlisten(this.wizardChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const itemsOpen = mailboxWizardStore.getState().hasAnyItemsOpen() + return { + itemsOpen: itemsOpen, + render: itemsOpen + } + }, + + wizardChanged (wizardState) { + this.setState((prevState) => { + const itemsOpen = wizardState.hasAnyItemsOpen() + const update = { itemsOpen: itemsOpen } + if (prevState.itemsOpen !== itemsOpen) { + clearTimeout(this.renderTO) + if (prevState.itemsOpen && !itemsOpen) { + this.renderTO = setTimeout(() => { + this.setState({ render: false }) + }, 1000) + } else if (!prevState.itemsOpen && itemsOpen) { + update.render = true + } + } + return update + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + if (this.state.render) { + return ( +
+ + + + +
+ ) + } else { + return null + } + } +}) diff --git a/src/scenes/mailboxes/src/ui/MailboxWizard/index.js b/src/scenes/mailboxes/src/ui/MailboxWizard/index.js new file mode 100644 index 00000000..b61033bc --- /dev/null +++ b/src/scenes/mailboxes/src/ui/MailboxWizard/index.js @@ -0,0 +1 @@ +module.exports = require('./MailboxWizard') diff --git a/src/scenes/mailboxes/src/ui/NewsDialog.js b/src/scenes/mailboxes/src/ui/NewsDialog.js new file mode 100644 index 00000000..93b3b916 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/NewsDialog.js @@ -0,0 +1,145 @@ +import './NewsDialog.less' + +const React = require('react') +const shallowCompare = require('react-addons-shallow-compare') +const { RaisedButton, Dialog, Toggle } = require('material-ui') +const { settingsActions, settingsStore } = require('../stores/settings') +const navigationDispatch = require('../Dispatch/navigationDispatch') +const WebView = require('../Components/WebView') +const { + remote: {shell} +} = window.nativeRequire('electron') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'NewsDialog', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsUpdated) + navigationDispatch.on('opennews', this.handleOpen) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsUpdated) + navigationDispatch.off('opennews', this.handleOpen) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const settingsState = settingsStore.getState() + return { + feedUrl: settingsState.news.newsFeed, + newsId: settingsState.news.newsId, + newsLevel: settingsState.news.newsLevel, + hasUnopenedNewsId: settingsState.news.hasUnopenedNewsId, + hasUpdateInfo: settingsState.news.hasUpdateInfo, + showNewsInSidebar: settingsState.news.showNewsInSidebar, + open: this.shouldAutoOpen(settingsState.news) + } + }, + + settingsUpdated (settingsState) { + this.setState((prevState) => { + const update = { + feedUrl: settingsState.news.newsFeed, + newsId: settingsState.news.newsId, + newsLevel: settingsState.news.newsLevel, + hasUnopenedNewsId: settingsState.news.hasUnopenedNewsId, + hasUpdateInfo: settingsState.news.hasUpdateInfo, + showNewsInSidebar: settingsState.news.showNewsInSidebar + } + + const autoOpen = this.shouldAutoOpen(settingsState.news) + if (autoOpen && prevState.open !== autoOpen) { + update.open = true + } + + return update + }) + }, + + shouldAutoOpen (news) { + if (news.hasUnopenedNewsId && news.hasUpdateInfo && news.newsLevel === 'dialog') { + return true + } else { + return false + } + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + handleDone (evt) { + settingsActions.openNewsItem(this.state.newsId) + this.setState({ open: false }) + }, + + handleOpen () { + settingsActions.openNewsItem(this.state.newsId) + this.setState({ open: true }) + }, + + handleOpenNewWindow (evt) { + shell.openExternal(evt.url) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { open, feedUrl, showNewsInSidebar } = this.state + + const buttons = ( +
+
+ { + settingsActions.setShowNewsInSidebar(toggled) + }} /> +
+
+ +
+
+ ) + + return ( + + {open ? ( + + ) : undefined} + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/NewsDialog.less b/src/scenes/mailboxes/src/ui/NewsDialog.less new file mode 100644 index 00000000..3644ba3d --- /dev/null +++ b/src/scenes/mailboxes/src/ui/NewsDialog.less @@ -0,0 +1,22 @@ +.ReactComponent-NewsDialog-Body { + padding: 0; + overflow: hidden; + position: relative; + + &:before { + content: ""; + margin-bottom: 100%; + display: inline-block; + } + + >iframe, >webview { + border: none; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} diff --git a/src/scenes/mailboxes/src/ui/Settings/AccountSettings.js b/src/scenes/mailboxes/src/ui/Settings/AccountSettings.js index 16c2f6dd..e783b92f 100644 --- a/src/scenes/mailboxes/src/ui/Settings/AccountSettings.js +++ b/src/scenes/mailboxes/src/ui/Settings/AccountSettings.js @@ -1,34 +1,45 @@ -import './accountSettings.less' const React = require('react') +const {SelectField, MenuItem, Avatar, Paper} = require('material-ui') const { - SelectField, MenuItem, - Paper, Toggle, RaisedButton -} = require('material-ui') -const { - ColorPickerButton, - Flexbox: { Row, Col } + Grid: { Container, Row, Col } } = require('../../Components') -const Colors = require('material-ui/styles/colors') -const GoogleInboxAccountSettings = require('./Accounts/GoogleInboxAccountSettings') -const GoogleMailAccountSettings = require('./Accounts/GoogleMailAccountSettings') -const flux = { - mailbox: require('../../stores/mailbox') -} -const Mailbox = require('shared/Models/Mailbox/Mailbox') +const mailboxStore = require('../../stores/mailbox/mailboxStore') +const styles = require('./settingStyles') + +const AccountAvatarSettings = require('./Accounts/AccountAvatarSettings') +const AccountUnreadSettings = require('./Accounts/AccountUnreadSettings') +const AccountCustomCodeSettings = require('./Accounts/AccountCustomCodeSettings') +const AccountAdvancedSettings = require('./Accounts/AccountAdvancedSettings') +const AccountManagementSettings = require('./Accounts/AccountManagementSettings') +const AccountServiceSettings = require('./Accounts/AccountServiceSettings') +const pkg = window.appPackage() module.exports = React.createClass({ displayName: 'AccountSettings', + propTypes: { + showRestart: React.PropTypes.func.isRequired, + initialMailboxId: React.PropTypes.string + }, /* **************************************************************************/ // Lifecycle /* **************************************************************************/ componentDidMount () { - flux.mailbox.S.listen(this.mailboxesChanged) + mailboxStore.listen(this.mailboxesChanged) }, componentWillUnmount () { - flux.mailbox.S.unlisten(this.mailboxesChanged) + mailboxStore.unlisten(this.mailboxesChanged) + }, + + componentWillReceiveProps (nextProps) { + if (this.props.initialMailboxId !== nextProps.initialMailboxId) { + const mailbox = mailboxStore.getState().getMailbox(nextProps.initialMailboxId) + if (mailbox) { + this.setState({ selected: mailbox }) + } + } }, /* **************************************************************************/ @@ -36,18 +47,22 @@ module.exports = React.createClass({ /* **************************************************************************/ getInitialState () { - const store = flux.mailbox.S.getState() + const { initialMailboxId } = this.props + const store = mailboxStore.getState() const all = store.allMailboxes() return { mailboxes: all, - selected: all[0] + selected: (initialMailboxId ? store.getMailbox(initialMailboxId) : all[0]) || all[0] } }, mailboxesChanged (store) { - const all = store.all() + const all = store.allMailboxes() if (this.state.selected) { - this.setState({ mailboxes: all, selected: store.getMailbox(this.state.selected.id) }) + this.setState({ + mailboxes: all, + selected: store.getMailbox(this.state.selected.id) || all[0] + }) } else { this.setState({ mailboxes: all, selected: all[0] }) } @@ -58,167 +73,92 @@ module.exports = React.createClass({ /* **************************************************************************/ handleAccountChange (evt, index, mailboxId) { - this.setState({ selected: flux.mailbox.S.getState().getMailbox(mailboxId) }) - }, - - handleShowUnreadBadgeChange (evt, toggled) { - flux.mailbox.A.setShowUnreadBage(this.state.selected.id, toggled) + this.setState({ selected: mailboxStore.getState().getMailbox(mailboxId) }) }, - handleShowNotificationsChange (evt, toggled) { - flux.mailbox.A.setShowNotifications(this.state.selected.id, toggled) - }, - - handleUnreadCountsTowardsAppUnread (evt, toggled) { - flux.mailbox.A.setUnreadCountsTowardsAppUnread(this.state.selected.id, toggled) - }, + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ - handleAccountColorChange (col) { - flux.mailbox.A.setColor(this.state.selected.id, col) - }, + renderNoMailboxes () { + const passProps = Object.assign({}, this.props) + delete passProps.showRestart + delete passProps.initialMailboxId - handleCustomAvatarChange (evt) { - if (!evt.target.files[0]) { return } - - // Load the image - const reader = new window.FileReader() - reader.addEventListener('load', () => { - // Get the image size - const image = new window.Image() - image.onload = () => { - // Scale the image down - const scale = 150 / (image.width > image.height ? image.width : image.height) - const width = image.width * scale - const height = image.height * scale - - // Resize the image - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - const ctx = canvas.getContext('2d') - ctx.drawImage(image, 0, 0, width, height) - - // Save it to disk - flux.mailbox.A.setCustomAvatar(this.state.selected.id, canvas.toDataURL()) - } - image.src = reader.result - }, false) - reader.readAsDataURL(evt.target.files[0]) + return ( +
+ + No accounts available + +
+ ) }, - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ + renderMailboxes () { + const {selected} = this.state + const {showRestart, ...passProps} = this.props + delete passProps.initialMailboxId - /** - * Renders the app - */ - render () { - const selected = this.state.selected - let content let avatarSrc = '' - if (selected) { - let accountSpecific - if (selected.type === Mailbox.TYPE_GINBOX) { - accountSpecific = - } else if (selected.type === Mailbox.TYPE_GMAIL) { - accountSpecific = - } - content = ( -
- - - - - - - - - - - -
-
- -
- -
-
- {accountSpecific} -
- ) - if (selected.hasCustomAvatar) { - avatarSrc = flux.mailbox.S.getState().getAvatar(selected.customAvatar) - } else if (selected.avatarURL) { - avatarSrc = selected.avatarURL - } - } else { - content = ( - - No accounts available - ) + if (selected.hasCustomAvatar) { + avatarSrc = mailboxStore.getState().getAvatar(selected.customAvatarId) + } else if (selected.avatarURL) { + avatarSrc = selected.avatarURL } return ( -
- -
-
-
-
- - { - this.state.mailboxes.map((m) => { - return ( - - ) - }) - } - -
+
+
+ +
+ + { + this.state.mailboxes.map((m) => { + return ( + + ) + }) + } +
- - {content} +
+ + + + + + + + + {pkg.prerelease ? ( + + ) : undefined} + + + + +
) + }, + + render () { + if (this.state.mailboxes.length) { + return this.renderMailboxes() + } else { + return this.renderNoMailboxes() + } } }) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAdvancedSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAdvancedSettings.js new file mode 100644 index 00000000..6bbc80d8 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAdvancedSettings.js @@ -0,0 +1,43 @@ +const React = require('react') +const { Paper, Toggle } = require('material-ui') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AccountAdvancedSettings', + propTypes: { + mailbox: React.PropTypes.object.isRequired, + showRestart: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailbox, showRestart, ...passProps } = this.props + + return ( + +

Advanced

+ { + showRestart() + mailboxActions.artificiallyPersistCookies(mailbox.id, toggled) + }} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAvatarSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAvatarSettings.js new file mode 100644 index 00000000..8cc074da --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountAvatarSettings.js @@ -0,0 +1,94 @@ +const React = require('react') +const { Paper, RaisedButton, FontIcon } = require('material-ui') +const { ColorPickerButton } = require('../../../Components') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AccountAvatarSettings', + propTypes: { + mailbox: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // User Interaction + /* **************************************************************************/ + + handleCustomAvatarChange (evt) { + if (!evt.target.files[0]) { return } + + // Load the image + const reader = new window.FileReader() + reader.addEventListener('load', () => { + // Get the image size + const image = new window.Image() + image.onload = () => { + // Scale the image down + const scale = 150 / (image.width > image.height ? image.width : image.height) + const width = image.width * scale + const height = image.height * scale + + // Resize the image + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + ctx.drawImage(image, 0, 0, width, height) + + // Save it to disk + mailboxActions.setCustomAvatar(this.props.mailbox.id, canvas.toDataURL()) + } + image.src = reader.result + }, false) + reader.readAsDataURL(evt.target.files[0]) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailbox, ...passProps } = this.props + + return ( + +

Icon

+
+ insert_emoticon} + style={styles.fileInputButton}> + + +
+
+ not_interested} + onClick={() => mailboxActions.setCustomAvatar(mailbox.id, undefined)} + label='Reset Account Icon' /> +
+
+ color_lens} + value={mailbox.color} + onChange={(col) => mailboxActions.setColor(mailbox.id, col)} /> +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountCustomCodeSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountCustomCodeSettings.js new file mode 100644 index 00000000..3136c06d --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountCustomCodeSettings.js @@ -0,0 +1,100 @@ +const React = require('react') +const { Paper, RaisedButton, FontIcon } = require('material-ui') +const CustomCodeEditingModal = require('./CustomCodeEditingModal') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') +const {mailboxDispatch} = require('../../../Dispatch') +const { USER_SCRIPTS_WEB_URL } = require('shared/constants') +const Colors = require('material-ui/styles/colors') +const { + remote: {shell} +} = window.nativeRequire('electron') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AccountCustomCodeSettings', + propTypes: { + mailbox: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + editingCSS: false, + editingJS: false + } + }, + + /* **************************************************************************/ + // User Interaction + /* **************************************************************************/ + + handleSave (evt, code) { + if (this.state.editingCSS) { + mailboxActions.setCustomCSS(this.props.mailbox.id, code) + } else if (this.state.editingJS) { + mailboxActions.setCustomJS(this.props.mailbox.id, code) + } + + this.setState({ editingJS: false, editingCSS: false }) + mailboxDispatch.reloadAllServices(this.props.mailbox.id) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailbox, ...passProps } = this.props + let editingCode + let editingTitle + if (this.state.editingCSS) { + editingCode = mailbox.customCSS + editingTitle = 'Custom CSS' + } else if (this.state.editingJS) { + editingCode = mailbox.customJS + editingTitle = 'Custom JS' + } + + return ( + +

Custom Code

+
+ code} + onTouchTap={() => this.setState({ editingCSS: true, editingJS: false })} /> +
+
+ code} + onTouchTap={() => this.setState({ editingCSS: false, editingJS: true })} /> +
+ + this.setState({ editingCSS: false, editingJS: false })} + onSave={this.handleSave} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountManagementSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountManagementSettings.js new file mode 100644 index 00000000..3c4e3547 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountManagementSettings.js @@ -0,0 +1,85 @@ +const React = require('react') +const Colors = require('material-ui/styles/colors') +const { Paper, FlatButton, FontIcon } = require('material-ui') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') +const TimerMixin = require('react-timer-mixin') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AccountManagementSettings', + mixins: [TimerMixin], + propTypes: { + mailbox: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentWillMount () { + this.confirmingDeleteTO = null + }, + + componentWillReceiveProps (nextProps) { + if (this.props.mailbox.id !== nextProps.mailbox.id) { + this.setState({ confirmingDelete: false }) + this.clearTimeout(this.confirmingDeleteTO) + } + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + confirmingDelete: false + } + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + /** + * Handles the delete button being tapped + */ + handleDeleteTapped (evt) { + if (this.state.confirmingDelete) { + mailboxActions.remove(this.props.mailbox.id) + } else { + this.setState({ confirmingDelete: true }) + this.confirmingDeleteTO = this.setTimeout(() => { + this.setState({ confirmingDelete: false }) + }, 4000) + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const passProps = Object.assign({}, this.props) + delete passProps.mailbox + + return ( + + delete} + labelStyle={{color: Colors.red600}} + onTouchTap={this.handleDeleteTapped} /> + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountServiceSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountServiceSettings.js new file mode 100644 index 00000000..d7cbd5da --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountServiceSettings.js @@ -0,0 +1,248 @@ +const React = require('react') +const { + Paper, IconButton, FontIcon, FlatButton, Popover, Menu, MenuItem, Checkbox, Toggle, + Table, TableBody, TableRow, TableRowColumn, TableHeader, TableHeaderColumn +} = require('material-ui') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const shallowCompare = require('react-addons-shallow-compare') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const Colors = require('material-ui/styles/colors') + +const settingStyles = require('../settingStyles') +const serviceStyles = { + actionCell: { + width: 48, + paddingLeft: 0, + paddingRight: 0, + textAlign: 'center' + }, + titleCell: { + paddingLeft: 0, + paddingRight: 0 + }, + avatar: { + height: 22, + width: 22, + top: 2 + }, + disabled: { + textAlign: 'center', + fontSize: '85%', + color: Colors.grey300 + } +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'AccountServiceSettings', + propTypes: { + mailbox: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + addPopoverOpen: false, + addPopoverAnchor: null + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * Renders the service name + * @param mailboxType: the type of mailbox + * @param service: the service type + * @return the human name for the service + */ + getServiceName (mailboxType, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.STORAGE: return 'Google Drive' + case Mailbox.SERVICES.CONTACTS: return 'Google Contacts' + case Mailbox.SERVICES.NOTES: return 'Google Keep' + case Mailbox.SERVICES.CALENDAR: return 'Google Calendar' + case Mailbox.SERVICES.COMMUNICATION: return 'Google Hangouts' + } + } + + return '' + }, + + /** + * @param mailboxType: the type of mailbox + * @param service: the service type + * @return the url of the service icon + */ + getServiceIconUrl (mailboxType, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.STORAGE: return '../../images/google_services/logo_drive_128px.png' + case Mailbox.SERVICES.CONTACTS: return '../../images/google_services/logo_contacts_128px.png' + case Mailbox.SERVICES.NOTES: return '../../images/google_services/logo_keep_128px.png' + case Mailbox.SERVICES.CALENDAR: return '../../images/google_services/logo_calendar_128px.png' + case Mailbox.SERVICES.COMMUNICATION: return '../../images/google_services/logo_hangouts_128px.png' + } + } + + return '' + }, + + /** + * Renders the services + * @param mailbox: the mailbox + * @param services: the services list + * @param sleepableServices: the list of services that are able to sleep + * @return jsx + */ + renderServices (mailbox, services, sleepableServices) { + if (services.length) { + const sleepableServicesSet = new Set(sleepableServices) + + return ( + + + + + Service + + Sleep when not in use + + + + + + + + {services.map((service, index, arr) => { + return ( + + + + + + {this.getServiceName(mailbox.type, service)} + + + mailboxActions.toggleServiceSleepable(mailbox.id, service, checked)} + checked={sleepableServicesSet.has(service)} /> + + + mailboxActions.moveServiceUp(mailbox.id, service)} + disabled={index === 0}> + arrow_upwards + + + + mailboxActions.moveServiceDown(mailbox.id, service)} + disabled={index === arr.length - 1}> + arrow_downwards + + + + mailboxActions.removeService(mailbox.id, service)}> + delete + + + + ) + })} + +
+ ) + } else { + return ( + + + + + All Services Disabled + + + +
+ ) + } + }, + + /** + * Renders the add popover + * @param mailbox: the mailbox + * @param disabledServices: the list of disabled services + * @return jsx + */ + renderAddPopover (mailbox, disabledServices) { + if (disabledServices.length) { + const { addPopoverOpen, addPopoverAnchor } = this.state + return ( +
+ this.setState({ addPopoverOpen: true, addPopoverAnchor: evt.currentTarget })} /> + this.setState({ addPopoverOpen: false })}> + + {disabledServices.map((service) => { + return ( + { + this.setState({ addPopoverOpen: false }) + mailboxActions.addService(mailbox.id, service) + }} + primaryText={this.getServiceName(mailbox.type, service)} />) + })} + + +
+ ) + } else { + return undefined + } + }, + + render () { + const { mailbox, ...passProps } = this.props + + const enabledServicesSet = new Set(mailbox.enabledServies) + const disabledServices = mailbox.supportedServices + .filter((s) => s !== Mailbox.SERVICES.DEFAULT && !enabledServicesSet.has(s)) + + return ( + +

Services

+ {this.renderServices(mailbox, mailbox.enabledServies, mailbox.sleepableServices)} + {this.renderAddPopover(mailbox, disabledServices)} + mailboxActions.setCompactServicesUI(mailbox.id, toggled)} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountUnreadSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountUnreadSettings.js new file mode 100644 index 00000000..13130910 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/AccountUnreadSettings.js @@ -0,0 +1,115 @@ +const React = require('react') +const {Paper, Toggle, SelectField, MenuItem} = require('material-ui') +const Mailbox = require('shared/Models/Mailbox/Mailbox') +const Google = require('shared/Models/Mailbox/Google') +const mailboxActions = require('../../../stores/mailbox/mailboxActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') +const Colors = require('material-ui/styles/colors') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + displayName: 'AccountUnreadSettings', + propTypes: { + mailbox: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailbox, ...passProps } = this.props + + return ( + +

Unread & Notifications

+ mailboxActions.setShowUnreadBage(mailbox.id, toggled)} /> + mailboxActions.setUnreadCountsTowardsAppUnread(mailbox.id, toggled)} /> + mailboxActions.setShowNotifications(mailbox.id, toggled)} /> + {mailbox.type === Mailbox.TYPE_GINBOX ? ( + { + mailboxActions.updateGoogleConfig(mailbox.id, { unreadMode: unreadMode }) + }} + floatingLabelText='Unread Mode'> + + + + + ) : undefined} + {mailbox.type === Mailbox.TYPE_GMAIL ? ( + { + mailboxActions.updateGoogleConfig(mailbox.id, { unreadMode: unreadMode }) + }} + floatingLabelText='Unread Mode'> + + + + + + ) : undefined} + {mailbox.type === Mailbox.TYPE_GMAIL ? ( +
+ { + mailboxActions.updateGoogleConfig(mailbox.id, { takeLabelCountFromUI: toggled }) + }} /> +
+ This will take the unread count directly from the Gmail user interface. This can improve unread count accuracy +
+
+ ) : undefined} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/CustomCodeEditingModal.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/CustomCodeEditingModal.js new file mode 100644 index 00000000..a8127699 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/Accounts/CustomCodeEditingModal.js @@ -0,0 +1,89 @@ +const React = require('react') +const { RaisedButton, FlatButton, Dialog, TextField } = require('material-ui') +const shallowCompare = require('react-addons-shallow-compare') +const uuid = require('uuid') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'CustomCodeEditingModal', + propTypes: { + title: React.PropTypes.string, + open: React.PropTypes.bool.isRequired, + code: React.PropTypes.string, + onCancel: React.PropTypes.func.isRequired, + onSave: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentWillReceiveProps (nextProps) { + if (this.props.open !== nextProps.open) { + this.setState({ editingKey: uuid.v4() }) + } + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + editingKey: uuid.v4() + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const actions = [ + ( this.props.onCancel(evt)} />), + ( this.props.onSave(evt, this.refs.editor.getValue())} />) + ] + + return ( + + + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleInboxAccountSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleInboxAccountSettings.js deleted file mode 100644 index ea8f13cd..00000000 --- a/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleInboxAccountSettings.js +++ /dev/null @@ -1,49 +0,0 @@ -const React = require('react') -const { Paper, SelectField, MenuItem } = require('material-ui') -const flux = { - mailbox: require('../../../stores/mailbox') -} -const Google = require('shared/Models/Mailbox/Google') - -module.exports = React.createClass({ - displayName: 'GoogleInboxAccountSettings', - - propTypes: { - mailbox: React.PropTypes.object.isRequired - }, - - /* **************************************************************************/ - // User Interaction - /* **************************************************************************/ - - handleUnreadModeChange: function (evt, index, unreadMode) { - flux.mailbox.A.updateGoogleConfig(this.props.mailbox.id, { unreadMode: unreadMode }) - }, - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * Renders the app - */ - render: function () { - return ( - - - - - - - ) - } -}) diff --git a/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleMailAccountSettings.js b/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleMailAccountSettings.js deleted file mode 100644 index 91227a9d..00000000 --- a/src/scenes/mailboxes/src/ui/Settings/Accounts/GoogleMailAccountSettings.js +++ /dev/null @@ -1,53 +0,0 @@ -const React = require('react') -const { SelectField, MenuItem, Paper } = require('material-ui') -const flux = { - mailbox: require('../../../stores/mailbox') -} -const Google = require('shared/Models/Mailbox/Google') - -module.exports = React.createClass({ - displayName: 'GoogleMailAccountSettings', - - propTypes: { - mailbox: React.PropTypes.object.isRequired - }, - - /* **************************************************************************/ - // User Interaction - /* **************************************************************************/ - - handleUnreadModeChange: function (evt, index, unreadMode) { - flux.mailbox.A.updateGoogleConfig(this.props.mailbox.id, { unreadMode: unreadMode }) - }, - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * Renders the app - */ - render: function () { - return ( - - - - - - - - ) - } -}) diff --git a/src/scenes/mailboxes/src/ui/Settings/AdvancedSettings.js b/src/scenes/mailboxes/src/ui/Settings/AdvancedSettings.js index 662d9514..c1dbe685 100644 --- a/src/scenes/mailboxes/src/ui/Settings/AdvancedSettings.js +++ b/src/scenes/mailboxes/src/ui/Settings/AdvancedSettings.js @@ -1,12 +1,21 @@ const React = require('react') const { Toggle, TextField, Paper } = require('material-ui') -const { Row, Col } = require('../../Components/Flexbox') +const { Container, Row, Col } = require('../../Components/Grid') const flux = { settings: require('../../stores/settings') } +const styles = require('./settingStyles') +const shallowCompare = require('react-addons-shallow-compare') module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + displayName: 'AdvancedSettings', + propTypes: { + showRestart: React.PropTypes.func.isRequired + }, /* **************************************************************************/ // Lifecycle @@ -32,7 +41,8 @@ module.exports = React.createClass({ return { proxyEnabled: store.proxy.enabled, proxyHost: store.proxy.host || '', - proxyPort: store.proxy.port || '' + proxyPort: store.proxy.port || '', + app: store.app } }, @@ -66,39 +76,80 @@ module.exports = React.createClass({ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + render () { + const { proxyEnabled, proxyPort, proxyHost, app } = this.state + const { showRestart, ...passProps } = this.props + return ( -
- +
+ +

Proxy Server

You also need to set the proxy settings on your OS to ensure all requests use the server - - - - - - - - + + + + + + + + + + +
+ + { + showRestart() + flux.settings.A.ignoreGPUBlacklist(toggled) + }} /> + { + showRestart() + flux.settings.A.enableUseZoomForDSF(toggled) + }} /> + { + showRestart() + flux.settings.A.disableSmoothScrolling(toggled) + }} /> + { + showRestart() + flux.settings.A.checkForUpdates(toggled) + }} />
) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/DownloadSettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/DownloadSettingsSection.js new file mode 100644 index 00000000..a5956e30 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/DownloadSettingsSection.js @@ -0,0 +1,70 @@ +const React = require('react') +const ReactDOM = require('react-dom') +const { Toggle, Paper, RaisedButton, FontIcon } = require('material-ui') +const settingsActions = require('../../../stores/settings/settingsActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'DownloadSettingsSection', + propTypes: { + os: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + ReactDOM.findDOMNode(this.refs.defaultDownloadInput).setAttribute('webkitdirectory', 'webkitdirectory') + }, + + componentDidUpdate () { + ReactDOM.findDOMNode(this.refs.defaultDownloadInput).setAttribute('webkitdirectory', 'webkitdirectory') + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const {os, ...passProps} = this.props + + return ( + +

Downloads

+
+ settingsActions.setAlwaysAskDownloadLocation(toggled)} /> +
+
+ folder} + containerElement='label' + disabled={os.alwaysAskDownloadLocation} + style={styles.fileInputButton}> + settingsActions.setDefaultDownloadLocation(evt.target.files[0].path)} /> + + {os.alwaysAskDownloadLocation ? undefined : {os.defaultDownloadLocation}} +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/InfoSettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/InfoSettingsSection.js new file mode 100644 index 00000000..fb75ed4b --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/InfoSettingsSection.js @@ -0,0 +1,91 @@ +const React = require('react') +const {Paper} = require('material-ui') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') +const Colors = require('material-ui/styles/colors') +const { remote } = window.nativeRequire('electron') +const { shell } = remote +const { WEB_URL, GITHUB_URL, GITHUB_ISSUE_URL } = require('shared/constants') +const {mailboxDispatch} = require('../../../Dispatch') +const mailboxStore = require('../../../stores/mailbox/mailboxStore') +const pkg = window.appPackage() + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'InfoSettingsSection', + + /* **************************************************************************/ + // UI Event + /* **************************************************************************/ + + /** + * Shows a snapshot of the current memory consumed + */ + handleShowMemoryInfo (evt) { + evt.preventDefault() + + const sizeToMb = (size) => { return Math.round(size / 1024) } + + mailboxDispatch.fetchProcessMemoryInfo().then((mailboxesProc) => { + const mailboxProcIndex = mailboxesProc.reduce((acc, info) => { + acc[info.mailboxId] = info.memoryInfo + return acc + }, {}) + const mailboxes = mailboxStore.getState().mailboxIds().map((mailboxId, index) => { + if (mailboxProcIndex[mailboxId]) { + return `Mailbox ${index + 1}: ${sizeToMb(mailboxProcIndex[mailboxId].workingSetSize)}mb` + } else { + return `Mailbox ${index + 1}: No info` + } + }) + + window.alert([ + `Main Process ${sizeToMb(remote.process.getProcessMemoryInfo().workingSetSize)}mb`, + `Mailboxes Window ${sizeToMb(process.getProcessMemoryInfo().workingSetSize)}mb`, + '' + ].concat(mailboxes).join('\n')) + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + return ( + + { evt.preventDefault(); shell.openExternal(WEB_URL) }} + href={WEB_URL}>WMail Website + { evt.preventDefault(); shell.openExternal(GITHUB_URL) }} + href={GITHUB_URL}>WMail GitHub + { evt.preventDefault(); shell.openExternal(GITHUB_ISSUE_URL) }} + href={GITHUB_ISSUE_URL}>Report a bug + Memory Info +
+

+ {`Version ${pkg.version} ${pkg.prerelease ? 'Prerelease' : ''}`} +

+

+ Made with ♥ by Thomas Beverley +

+
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/LanguageSettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/LanguageSettingsSection.js new file mode 100644 index 00000000..0fcb0802 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/LanguageSettingsSection.js @@ -0,0 +1,106 @@ +const React = require('react') +const { Toggle, Paper, SelectField, MenuItem, RaisedButton, FontIcon } = require('material-ui') +const flux = { + settings: require('../../../stores/settings'), + dictionaries: require('../../../stores/dictionaries') +} +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'LanguageSettingsSection', + propTypes: { + language: React.PropTypes.object.isRequired, + showRestart: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + flux.dictionaries.S.listen(this.dictionariesChanged) + }, + + componentWillUnmount () { + flux.dictionaries.S.unlisten(this.dictionariesChanged) + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + installedDictionaries: flux.dictionaries.S.getState().sortedInstalledDictionaryInfos() + } + }, + + dictionariesChanged (store) { + this.setState({ + installedDictionaries: flux.dictionaries.S.getState().sortedInstalledDictionaryInfos() + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const {language, showRestart, ...passProps} = this.props + const { installedDictionaries } = this.state + const dictionaryState = flux.dictionaries.S.getState() + const primaryDictionaryInfo = dictionaryState.getDictionaryInfo(language.spellcheckerLanguage) + + return ( + +

Language

+ { + showRestart() + flux.settings.A.setEnableSpellchecker(toggled) + }} /> + { flux.settings.A.setSpellcheckerLanguage(value) }}> + {installedDictionaries.map((info) => { + return () + })} + + { + flux.settings.A.setSecondarySpellcheckerLanguage(value !== '__none__' ? value : null) + }}> + {[undefined].concat(installedDictionaries).map((info) => { + if (info === undefined) { + return () + } else { + const disabled = primaryDictionaryInfo.charset !== info.charset + return () + } + })} + + language} + onTouchTap={() => { flux.dictionaries.A.startDictionaryInstall() }} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/NotificationSettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/NotificationSettingsSection.js new file mode 100644 index 00000000..f626a199 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/NotificationSettingsSection.js @@ -0,0 +1,45 @@ +const React = require('react') +const { Toggle, Paper } = require('material-ui') +const settingsActions = require('../../../stores/settings/settingsActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + displayName: 'NotificationSettingsSection', + propTypes: { + os: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { os, ...passProps } = this.props + + return ( + +

Notifications

+ settingsActions.setNotificationsEnabled(toggled)} /> + settingsActions.setNotificationsSilent(!toggled)} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/PlatformSettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/PlatformSettingsSection.js new file mode 100644 index 00000000..ce43e928 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/PlatformSettingsSection.js @@ -0,0 +1,92 @@ +const React = require('react') +const { Toggle, Paper, SelectField, MenuItem } = require('material-ui') +const platformActions = require('../../../stores/platform/platformActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +const LOGIN_OPEN_MODES = { + OFF: 'false|false', + ON: 'true|false', + ON_BACKGROUND: 'true|true' +} + +module.exports = React.createClass({ + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + displayName: 'PlatformSettingsSection', + propTypes: { + mailtoLinkHandlerSupported: React.PropTypes.bool.isRequired, + isMailtoLinkHandler: React.PropTypes.bool.isRequired, + openAtLoginSupported: React.PropTypes.bool.isRequired, + openAtLogin: React.PropTypes.bool.isRequired, + openAsHiddenAtLogin: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + /** + * Handles the open at login state chaning + */ + handleOpenAtLoginChanged (evt, index, value) { + switch (value) { + case LOGIN_OPEN_MODES.OFF: + platformActions.changeLoginPref(false, false) + break + case LOGIN_OPEN_MODES.ON: + platformActions.changeLoginPref(true, false) + break + case LOGIN_OPEN_MODES.ON_BACKGROUND: + platformActions.changeLoginPref(true, true) + break + } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { + mailtoLinkHandlerSupported, + isMailtoLinkHandler, + openAtLoginSupported, + openAtLogin, + openAsHiddenAtLogin, + ...passProps + } = this.props + + if (!mailtoLinkHandlerSupported && !openAtLoginSupported) { return null } + + return ( + +

Platform

+ {mailtoLinkHandlerSupported ? ( + platformActions.changeMailtoLinkHandler(toggled)} /> + ) : undefined} + {openAtLoginSupported ? ( + + + + + + ) : undefined} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/TraySettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/TraySettingsSection.js new file mode 100644 index 00000000..f92e3967 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/TraySettingsSection.js @@ -0,0 +1,63 @@ +const React = require('react') +const { Toggle, Paper, SelectField, MenuItem } = require('material-ui') +const { TrayIconEditor } = require('../../../Components') +const settingsActions = require('../../../stores/settings/settingsActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') +const Tray = require('../../Tray') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'TraySettingsSection', + propTypes: { + tray: React.PropTypes.object.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const {tray, ...passProps} = this.props + + return ( + +

{process.platform === 'darwin' ? 'Menu Bar' : 'Tray'}

+
+ settingsActions.setShowTrayIcon(toggled)} /> + settingsActions.setShowTrayUnreadCount(toggled)} /> + {Tray.platformSupportsDpiMultiplier() ? ( + settingsActions.setDpiMultiplier(value)}> + + + + + + + ) : undefined } +
+
+ +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/General/UISettingsSection.js b/src/scenes/mailboxes/src/ui/Settings/General/UISettingsSection.js new file mode 100644 index 00000000..994adc32 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/General/UISettingsSection.js @@ -0,0 +1,94 @@ +const React = require('react') +const { Toggle, Paper } = require('material-ui') +const settingsActions = require('../../../stores/settings/settingsActions') +const styles = require('../settingStyles') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'UISettingsSection', + propTypes: { + ui: React.PropTypes.object.isRequired, + os: React.PropTypes.object.isRequired, + news: React.PropTypes.object.isRequired, + showRestart: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { + ui, + os, + news, + showRestart, + ...passProps + } = this.props + + return ( +
+ +

User Interface

+ {process.platform !== 'darwin' ? undefined : ( + { + showRestart() + settingsActions.setShowTitlebar(toggled) + }} /> + )} + {process.platform === 'darwin' ? undefined : ( + settingsActions.setShowAppMenu(toggled)} /> + )} + settingsActions.setEnableSidebar(toggled)} /> + settingsActions.setShowAppBadge(toggled)} /> + settingsActions.setShowTitlebarUnreadCount(toggled)} /> + {process.platform === 'darwin' ? ( + settingsActions.setOpenLinksInBackground(toggled)} /> + ) : undefined} + settingsActions.setOpenHidden(toggled)} /> + { settingsActions.setShowNewsInSidebar(toggled) }} /> +
+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Settings/GeneralSettings.js b/src/scenes/mailboxes/src/ui/Settings/GeneralSettings.js index 0ef165d8..714854b2 100644 --- a/src/scenes/mailboxes/src/ui/Settings/GeneralSettings.js +++ b/src/scenes/mailboxes/src/ui/Settings/GeneralSettings.js @@ -1,32 +1,40 @@ const React = require('react') -const ReactDOM = require('react-dom') -const { Toggle, Paper, RaisedButton } = require('material-ui') const { - ColorPickerButton, - Flexbox: { Row, Col } + Grid: { Container, Row, Col } } = require('../../Components') -const flux = { - settings: require('../../stores/settings') -} +const settingsStore = require('../../stores/settings/settingsStore') +const platformStore = require('../../stores/platform/platformStore') + +const DownloadSettingsSection = require('./General/DownloadSettingsSection') +const LanguageSettingsSection = require('./General/LanguageSettingsSection') +const NotificationSettingsSection = require('./General/NotificationSettingsSection') +const TraySettingsSection = require('./General/TraySettingsSection') +const UISettingsSection = require('./General/UISettingsSection') +const InfoSettingsSection = require('./General/InfoSettingsSection') +const PlatformSettingsSection = require('./General/PlatformSettingsSection') module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + displayName: 'GeneralSettings', + propTypes: { + showRestart: React.PropTypes.func.isRequired + }, /* **************************************************************************/ // Lifecycle /* **************************************************************************/ componentDidMount () { - flux.settings.S.listen(this.settingsChanged) - ReactDOM.findDOMNode(this.refs.defaultDownloadInput).setAttribute('webkitdirectory', 'webkitdirectory') + settingsStore.listen(this.settingsChanged) + platformStore.listen(this.platformChanged) }, componentWillUnmount () { - flux.settings.S.unlisten(this.settingsChanged) - }, - - componentDidUpdate () { - ReactDOM.findDOMNode(this.refs.defaultDownloadInput).setAttribute('webkitdirectory', 'webkitdirectory') + settingsStore.unlisten(this.settingsChanged) + platformStore.unlisten(this.platformChanged) }, /* **************************************************************************/ @@ -34,24 +42,44 @@ module.exports = React.createClass({ /* **************************************************************************/ /** - * Generates the state from the settings + * Generates the settings state from the settings * @param store=settingsStore: the store to use */ - generateState (store = flux.settings.S.getState()) { + generateSettingsState (store = settingsStore.getState()) { return { ui: store.ui, os: store.os, + news: store.news, language: store.language, tray: store.tray } }, + /** + * Generates the platform state from the settings + * @param store=platformStore: the store to use + */ + generatePlatformState (store = platformStore.getState()) { + const loginPref = store.loginPrefAssumed() + return { + openAtLoginSupported: store.loginPrefSupported(), + openAtLogin: loginPref.openAtLogin, + openAsHiddenAtLogin: loginPref.openAsHidden, + mailtoLinkHandlerSupported: store.mailtoLinkHandlerSupported(), + isMailtoLinkHandler: store.isMailtoLinkHandler() + } + }, + getInitialState () { - return this.generateState() + return Object.assign({}, this.generateSettingsState(), this.generatePlatformState()) }, settingsChanged (store) { - this.setState(this.generateState(store)) + this.setState(this.generateSettingsState(store)) + }, + + platformChanged (store) { + this.setState(this.generatePlatformState(store)) }, /* **************************************************************************/ @@ -62,135 +90,46 @@ module.exports = React.createClass({ * Renders the app */ render () { - const {ui, os, language, tray} = this.state + const { + ui, + os, + language, + tray, + news, + openAtLoginSupported, + openAtLogin, + openAsHiddenAtLogin, + mailtoLinkHandlerSupported, + isMailtoLinkHandler + } = this.state + const {showRestart, ...passProps} = this.props return ( -
- - - - {process.platform !== 'darwin' ? undefined : ( - flux.settings.A.setShowTitlebar(toggled)} /> - )} - {process.platform === 'darwin' ? undefined : ( - flux.settings.A.setShowAppMenu(toggled)} /> - )} - flux.settings.A.setEnableSidebar(toggled)} /> - - - flux.settings.A.setShowAppBadge(toggled)} /> - flux.settings.A.setOpenLinksInBackground(toggled)} /> - - - - +
+ - - flux.settings.A.setShowTrayIcon(toggled)} /> - flux.settings.A.setShowTrayUnreadCount(toggled)} /> + + + + + - -
- flux.settings.A.setTrayReadColor(col.hex)} /> -
-
-
- flux.settings.A.setTrayUnreadColor(col.hex)} /> -
+ + + +
- - - flux.settings.A.setEnableSpellchecker(toggled)} /> - - - flux.settings.A.setNotificationsEnabled(toggled)} /> -
- flux.settings.A.setNotificationsSilent(!toggled)} /> -
- -
- flux.settings.A.setAlwaysAskDownloadLocation(toggled)} /> -
-
-
- - flux.settings.A.setDefaultDownloadLocation(evt.target.files[0].path)} /> - - {os.alwaysAskDownloadLocation ? undefined : {os.defaultDownloadLocation}} -
-
+
) } diff --git a/src/scenes/mailboxes/src/ui/Settings/SettingsDialog.js b/src/scenes/mailboxes/src/ui/Settings/SettingsDialog.js index 11ec4bad..bc8806f9 100644 --- a/src/scenes/mailboxes/src/ui/Settings/SettingsDialog.js +++ b/src/scenes/mailboxes/src/ui/Settings/SettingsDialog.js @@ -7,25 +7,44 @@ const GeneralSettings = require('./GeneralSettings') const AccountSettings = require('./AccountSettings') const AdvancedSettings = require('./AdvancedSettings') const Colors = require('material-ui/styles/colors') +const styles = require('./settingStyles') +const { ipcRenderer } = window.nativeRequire('electron') module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + displayName: 'SettingsDialog', + propTypes: { + open: React.PropTypes.bool.isRequired, + onRequestClose: React.PropTypes.func.isRequired, + initialRoute: React.PropTypes.object + }, /* **************************************************************************/ - // Data lifecycle + // Component Lifecycle /* **************************************************************************/ - getInitialState () { - return { - currentTab: 'general' + componentWillReceiveProps (nextProps) { + if (this.props.open !== nextProps.open) { + const updates = { showRestart: false } + if (nextProps.open) { + updates.currentTab = (nextProps.initialRoute || {}).tab || 'general' + } + this.setState(updates) } }, - shouldComponentUpdate (nextProps, nextState) { - if (this.state.currentTab !== nextState.currentTab) { return true } - if (nextProps.open !== this.props.open) { return true } + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ - return false + getInitialState () { + return { + currentTab: (this.props.initialRoute || {}).tab || 'general', + showRestart: false + } }, /* **************************************************************************/ @@ -42,48 +61,57 @@ module.exports = React.createClass({ }, /** - * Closes the modal + * Shows the option to restart */ - handleClose () { - this.props.onRequestClose() + handleShowRestart () { + this.setState({ showRestart: true }) }, /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ + shouldComponentUpdate (nextProps, nextState) { + if (this.state.currentTab !== nextState.currentTab) { return true } + if (this.state.showRestart !== nextState.showRestart) { return true } + if (nextProps.open !== this.props.open) { return true } + + return false + }, + render () { - const buttons = ( + const { showRestart, currentTab } = this.state + const { onRequestClose, initialRoute, open } = this.props + + const buttons = showRestart ? (
- + + ipcRenderer.send('relaunch-app', { })} /> +
+ ) : ( +
+
) - const tabInfo = [ - { label: 'General', value: 'general' }, - { label: 'Accounts', value: 'accounts' }, - { label: 'Advanced', value: 'advanced' } + const tabHeadings = [ + ['General', 'general'], + ['Accounts', 'accounts'], + ['Advanced', 'advanced'] ] + const heading = ( -
- {tabInfo.map(({label, value}) => { +
+ {tabHeadings.map(([label, value]) => { return ( + onRequestClose={onRequestClose}> - + {currentTab !== 'general' ? undefined : ( + + )} - + {currentTab !== 'accounts' ? undefined : ( + + )} - + {currentTab !== 'advanced' ? undefined : ( + + )} diff --git a/src/scenes/mailboxes/src/ui/Settings/accountSettings.less b/src/scenes/mailboxes/src/ui/Settings/accountSettings.less deleted file mode 100644 index 6aea7caf..00000000 --- a/src/scenes/mailboxes/src/ui/Settings/accountSettings.less +++ /dev/null @@ -1,26 +0,0 @@ -.settings-account-picker { - @HEIGHT: 56px; - @AVATAR_MARG: 3px; - position: relative; - height: @HEIGHT; - - >.avatar { - position: absolute; - top: @AVATAR_MARG; - left: AVATAR_MARG; - height: @HEIGHT - @AVATAR_MARG - @AVATAR_MARG; - width: @HEIGHT - @AVATAR_MARG - @AVATAR_MARG; - background-size: cover; - background-position: center center; - background-repeat: no-repeat; - border-radius: 50%; - border: 1px solid #B0BEC5; - } - >.picker-container { - position: absolute; - top: 0px; - left: @HEIGHT + 10px; - height: @HEIGHT; - right: 0px; - } -} \ No newline at end of file diff --git a/src/scenes/mailboxes/src/ui/Settings/settingStyles.js b/src/scenes/mailboxes/src/ui/Settings/settingStyles.js new file mode 100644 index 00000000..dee98fbf --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Settings/settingStyles.js @@ -0,0 +1,77 @@ +module.exports = { + /* **************************************************************************/ + // Modal + /* **************************************************************************/ + dialog: { + width: '90%', + maxWidth: 1200 + }, + tabToggles: { + display: 'flex', + flexDirection: 'row', + alignContent: 'stretch' + }, + tabToggle: { + height: 50, + borderRadius: 0, + flex: 1, + borderBottomWidth: 2, + borderBottomStyle: 'solid' + }, + + /* **************************************************************************/ + // General + /* **************************************************************************/ + paper: { + padding: 15, + marginBottom: 5, + marginTop: 5 + }, + subheading: { + marginTop: 0, + marginBottom: 10, + color: '#CCC', + fontWeight: '300', + fontSize: 16 + }, + fileInputButton: { + marginRight: 15, + position: 'relative', + overflow: 'hidden' + }, + fileInput: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: 0, + width: '100%', + cursor: 'pointer' + }, + button: { + marginTop: 5, + marginBottom: 5 + }, + + /* **************************************************************************/ + // Account + /* **************************************************************************/ + + accountPicker: { + position: 'relative', + height: 100 + }, + accountPickerAvatar: { + position: 'absolute', + top: 20, + left: 20, + boxShadow: 'rgba(0, 0, 0, 0.117647) 0px 1px 6px, rgba(0, 0, 0, 0.117647) 0px 1px 4px' // copied from paper + }, + accountPickerContainer: { + position: 'absolute', + top: 25, + left: 100, + right: 0 + } +} diff --git a/src/scenes/mailboxes/src/ui/Sidelist/MailboxListItem.js b/src/scenes/mailboxes/src/ui/Sidelist/MailboxListItem.js deleted file mode 100644 index c342c4df..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/MailboxListItem.js +++ /dev/null @@ -1,278 +0,0 @@ -import './mailboxListItem.less' -const React = require('react') -const flux = { - mailbox: require('../../stores/mailbox'), - google: require('../../stores/google') -} -const { Badge, Popover, Menu, MenuItem, Divider, FontIcon } = require('material-ui') -const Colors = require('material-ui/styles/colors') -const mailboxDispatch = require('../Dispatch/mailboxDispatch') -const shallowCompare = require('react-addons-shallow-compare') - -module.exports = React.createClass({ - displayName: 'MailboxListItem', - - propTypes: { - mailboxId: React.PropTypes.string.isRequired, - index: React.PropTypes.number.isRequired, - isFirst: React.PropTypes.bool.isRequired, - isLast: React.PropTypes.bool.isRequired - }, - - /* **************************************************************************/ - // Lifecycle - /* **************************************************************************/ - - componentWillMount () { - this.isMounted = true - this.cssElement = document.createElement('style') - document.head.appendChild(this.cssElement) - flux.mailbox.S.listen(this.mailboxesChanged) - }, - - componentWillUnmount () { - this.isMounted = false - document.head.removeChild(this.cssElement) - flux.mailbox.S.unlisten(this.mailboxesChanged) - }, - - /* **************************************************************************/ - // Data lifecycle - /* **************************************************************************/ - - getInitialState () { - const mailboxStore = flux.mailbox.S.getState() - const mailbox = mailboxStore.getMailbox(this.props.mailboxId) - return { - mailbox: mailbox, - isActive: mailboxStore.activeMailboxId() === this.props.mailboxId, - popover: false, - popoverAnchor: null - } - }, - - mailboxesChanged (store) { - if (this.isMounted === false) { return } - const mailbox = store.getMailbox(this.props.mailboxId) - this.setState({ - mailbox: mailbox, - isActive: store.activeMailboxId() === this.props.mailboxId - }) - }, - - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - }, - - /* **************************************************************************/ - // User Interaction - /* **************************************************************************/ - - /** - * Handles the item being clicked on - * @param evt: the event that fired - */ - handleClick (evt) { - evt.preventDefault() - flux.mailbox.A.changeActive(this.props.mailboxId) - }, - - /** - * Opens the popover - */ - handleOpenPopover (evt) { - evt.preventDefault() - this.setState({ popover: true, popoverAnchor: evt.currentTarget }) - }, - - /** - * Closes the popover - */ - handleClosePopover () { - this.setState({ popover: false }) - }, - - /** - * Deletes this mailbox - */ - handleDelete () { - flux.mailbox.A.remove(this.props.mailboxId) - this.setState({ popover: false }) - }, - - /** - * Opens the inspector window for this mailbox - */ - handleInspect () { - mailboxDispatch.openDevTools(this.props.mailboxId) - this.setState({ popover: false }) - }, - - /** - * Reloads this mailbox - */ - handleReload () { - mailboxDispatch.reload(this.props.mailboxId) - this.setState({ popover: false }) - }, - - /** - * Moves this item up - */ - handleMoveUp () { - flux.mailbox.A.moveUp(this.props.mailboxId) - this.setState({ popover: false }) - }, - - /** - * Moves this item down - */ - handleMoveDown () { - flux.mailbox.A.moveDown(this.props.mailboxId) - this.setState({ popover: false }) - }, - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * Updates the css styles for the mailbox - * @param mailbox: the mailbox to update for - */ - updateCssStyles (mailbox) { - this.cssElement.innerHTML = ` - .mailbox-list .list-item[data-id="${mailbox.id}"] .mailbox.active { - border-color: ${mailbox.color}; - } - .mailbox-list .list-item[data-id="${mailbox.id}"] .mailbox:hover { - border-color: ${mailbox.color}; - } - .mailbox-list .list-item[data-id="${mailbox.id}"] .mailbox.active:before { - background-color: ${mailbox.color}; - } - ` - }, - - /** - * Renders the menu items - * @return array of jsx elements - */ - renderMenuItems () { - const menuItems = [] - if (!this.props.isFirst) { - menuItems.push(arrow_upward} />) - } - if (!this.props.isLast) { - menuItems.push(arrow_downward} />) - } - if (!this.props.isFirst || !this.props.isLast) { - menuItems.push() - } - menuItems.push( - delete} />) - menuItems.push() - menuItems.push( - refresh} />) - menuItems.push( - bug_report} />) - return menuItems - }, - - /** - * Renders the app - */ - render () { - const mailbox = this.state.mailbox - if (!mailbox) { return false } - - this.updateCssStyles(mailbox) - - // Setup the classnames - const containerProps = { - 'className': 'mailbox' + (this.state.isActive ? ' active' : ''), - 'data-type': mailbox.type - } - if (mailbox.email || mailbox.name) { - containerProps.title = [ - mailbox.email || '', - (mailbox.name ? '(' + mailbox.name + ')' : '') - ].join(' ') - } - - // Generate avatar - let innerElement - if (mailbox.avatarURL || mailbox.hasCustomAvatar) { - containerProps.className += ' avatar' - if (mailbox.hasCustomAvatar) { - innerElement = ( - - ) - } else { - innerElement = - } - } else { - containerProps.className += ' index' - innerElement = {this.props.index + 1} - } - - // Generate badge - let badgeElement - if (mailbox.showUnreadBadge && mailbox.unread) { - badgeElement = ( - - ) - } - - return ( -
-
- {innerElement} - {badgeElement} -
- - - {this.renderMenuItems()} - - -
- ) - } -}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/Sidelist.js b/src/scenes/mailboxes/src/ui/Sidelist/Sidelist.js index e6321ef3..1cecdd38 100644 --- a/src/scenes/mailboxes/src/ui/Sidelist/Sidelist.js +++ b/src/scenes/mailboxes/src/ui/Sidelist/Sidelist.js @@ -1,47 +1,100 @@ -'use strict' - -import './sidelist.less' - const React = require('react') -const Colors = require('material-ui/styles/colors') -const MailboxList = require('./MailboxList') -const SidelistAddMailbox = require('./SidelistAddMailbox') -const SidelistSettings = require('./SidelistSettings') +const SidelistMailboxes = require('./SidelistMailboxes') +const SidelistItemAddMailbox = require('./SidelistItemAddMailbox') +const SidelistItemSettings = require('./SidelistItemSettings') +const SidelistItemWizard = require('./SidelistItemWizard') +const SidelistItemNews = require('./SidelistItemNews') +const { settingsStore } = require('../../stores/settings') +const styles = require('./SidelistStyles') +const shallowCompare = require('react-addons-shallow-compare') module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + displayName: 'Sidelist', + /* **************************************************************************/ + // Component lifecyle + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsUpdated) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsUpdated) + }, + /* **************************************************************************/ // Data lifecyle /* **************************************************************************/ - shouldComponentUpdate (nextProps, nextState) { - return false + getInitialState () { + const settingsState = settingsStore.getState() + return { + showTitlebar: settingsState.ui.showTitlebar, // purposely don't update this, because effects are only seen after restart + showWizard: !settingsState.app.hasSeenAppWizard, + showNewsInSidebar: settingsState.news.showNewsInSidebar, + hasUnopenedNewsId: settingsState.news.hasUnopenedNewsId, + hasUpdateInfo: settingsState.news.hasUpdateInfo + } + }, + + settingsUpdated (settingsState) { + this.setState({ + showWizard: !settingsState.app.hasSeenAppWizard, + showNewsInSidebar: settingsState.news.showNewsInSidebar, + hasUnopenedNewsId: settingsState.news.hasUnopenedNewsId, + hasUpdateInfo: settingsState.news.hasUpdateInfo + }) }, /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + render () { + const { showTitlebar, showWizard, showNewsInSidebar, hasUnopenedNewsId, hasUpdateInfo } = this.state + const isDarwin = process.platform === 'darwin' const { style, ...passProps } = this.props + let extraItems = 0 + extraItems += showWizard ? 1 : 0 + extraItems += hasUpdateInfo && (showNewsInSidebar || hasUnopenedNewsId) ? 1 : 0 + + const scrollerStyle = Object.assign({}, + styles.scroller, + extraItems === 1 ? styles.scroller3Icons : undefined, + extraItems === 2 ? styles.scroller4Icons : undefined, + { top: isDarwin && !showTitlebar ? 25 : 0 } + ) + const footerStyle = Object.assign({}, + styles.footer, + extraItems === 1 ? styles.footer3Icons : undefined, + extraItems === 2 ? styles.footer4Icons : undefined + ) + return (
- - - + style={Object.assign({}, styles.container, style)}> +
+ +
+
+ {showWizard ? () : undefined} + {hasUpdateInfo && (showNewsInSidebar || hasUnopenedNewsId) ? () : undefined} + + +
) } diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistAddMailbox.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistAddMailbox.js deleted file mode 100644 index 1727282d..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/SidelistAddMailbox.js +++ /dev/null @@ -1,86 +0,0 @@ -const React = require('react') -const { IconButton, Popover, MenuItem, Menu } = require('material-ui') -const Colors = require('material-ui/styles/colors') -const flux = { - google: require('../../stores/google') -} - -/* eslint-disable react/prop-types */ - -module.exports = React.createClass({ - displayName: 'SidelistAddMailbox', - - /* **************************************************************************/ - // Data lifecycle - /* **************************************************************************/ - - getInitialState () { - return { popover: false, popoverAnchor: null } - }, - - /* **************************************************************************/ - // User Interaction - /* **************************************************************************/ - - /** - * Opens the popover - */ - handleOpenPopover (evt) { - this.setState({ popover: true, popoverAnchor: evt.currentTarget }) - }, - - /** - * Closes the popover - */ - handleClosePopover () { - this.setState({ popover: false }) - }, - - /** - * Adds an inbox mail account - */ - handleAddInbox () { - flux.google.A.authInboxMailbox() - this.setState({ popover: false }) - }, - - /** - * Adds a gmail mail account - */ - handleAddGmail () { - flux.google.A.authGmailMailbox() - this.setState({popover: false}) - }, - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * Renders the app - */ - render () { - return ( -
- - add_circle - - - - - - - -
- ) - } -}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemAddMailbox.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemAddMailbox.js new file mode 100644 index 00000000..9e84341c --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemAddMailbox.js @@ -0,0 +1,42 @@ +const React = require('react') +const { IconButton } = require('material-ui') +const Colors = require('material-ui/styles/colors') +const styles = require('./SidelistStyles') +const ReactTooltip = require('react-tooltip') +const { mailboxWizardActions } = require('../../stores/mailboxWizard') + +module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemAddMailbox', + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { style, ...passProps } = this.props + return ( +
+ mailboxWizardActions.openAddMailbox()} + iconStyle={{ color: Colors.blueGrey400 }}> + add_circle + + +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailbox.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailbox.js new file mode 100644 index 00000000..b066301b --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailbox.js @@ -0,0 +1,225 @@ +const React = require('react') +const { Badge, FontIcon } = require('material-ui') +const { navigationDispatch } = require('../../../Dispatch') +const { mailboxStore, mailboxActions } = require('../../../stores/mailbox') +const shallowCompare = require('react-addons-shallow-compare') +const ReactTooltip = require('react-tooltip') +const styles = require('../SidelistStyles') +const SidelistItemMailboxPopover = require('./SidelistItemMailboxPopover') +const SidelistItemMailboxAvatar = require('./SidelistItemMailboxAvatar') +const SidelistItemMailboxServices = require('./SidelistItemMailboxServices') +const pkg = window.appPackage() + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemMailbox', + propTypes: { + mailboxId: React.PropTypes.string.isRequired, + index: React.PropTypes.number.isRequired, + isFirst: React.PropTypes.bool.isRequired, + isLast: React.PropTypes.bool.isRequired + }, + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + componentDidMount () { + mailboxStore.listen(this.mailboxesChanged) + // Adding new items can cause the popover not to come up. Rebuild the tooltip + // after a little time. Bad but seems to fix + setTimeout(() => ReactTooltip.rebuild(), 1000) + }, + + componentWillUnmount () { + mailboxStore.unlisten(this.mailboxesChanged) + }, + + /* **************************************************************************/ + // Data lifecycle + /* **************************************************************************/ + + getInitialState () { + const mailboxState = mailboxStore.getState() + const mailbox = mailboxState.getMailbox(this.props.mailboxId) + return { + mailbox: mailbox, + isActive: mailboxState.activeMailboxId() === this.props.mailboxId, + activeService: mailboxState.activeMailboxService(), + popover: false, + popoverAnchor: null, + hovering: false + } + }, + + mailboxesChanged (mailboxState) { + const mailbox = mailboxState.getMailbox(this.props.mailboxId) + this.setState({ + mailbox: mailbox, + isActive: mailboxState.activeMailboxId() === this.props.mailboxId, + activeService: mailboxState.activeMailboxService() + }) + }, + + /* **************************************************************************/ + // User Interaction + /* **************************************************************************/ + + /** + * Handles the item being clicked on + * @param evt: the event that fired + */ + handleClick (evt) { + evt.preventDefault() + if (evt.metaKey) { + navigationDispatch.openMailboxSettings(this.props.mailboxId) + } else { + mailboxActions.changeActive(this.props.mailboxId) + } + }, + + /** + * Handles opening a service + * @param evt: the event that fired + * @param service: the service to open + */ + handleOpenService (evt, service) { + evt.preventDefault() + mailboxActions.changeActive(this.props.mailboxId, service) + }, + + /** + * Opens the popover + */ + handleOpenPopover (evt) { + evt.preventDefault() + this.setState({ popover: true, popoverAnchor: evt.currentTarget }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * Renders the badge element + * @param mailbox: the mailbox to render for + * @return jsx + */ + renderBadge (mailbox) { + if (mailbox.google.authHasGrantError) { + return ( + )} + badgeStyle={styles.mailboxBadge} + style={styles.mailboxBadgeContainer} /> + ) + } else if (mailbox.showUnreadBadge && mailbox.unread) { + const badgeContent = mailbox.unread >= 1000 ? Math.floor(mailbox.unread / 1000) + 'K+' : mailbox.unread + return ( + + ) + } else { + return undefined + } + }, + + /** + * Renders the active indicator + * @param mailbox: the mailbox to render for + * @param isActive: true if the mailbox is active + * @return jsx + */ + renderActiveIndicator (mailbox, isActive) { + if (isActive) { + return ( +
+ ) + } else { + return undefined + } + }, + + /** + * Renders the content for the tooltip + * @param mailbox: the mailbox to render for + * @return jsx + */ + renderTooltipContent (mailbox) { + if (!mailbox.email && !mailbox.unread) { return undefined } + const hr = '
' + const hasError = mailbox.google.authHasGrantError + return ` +
+ ${mailbox.email || ''} + ${mailbox.email && mailbox.unread ? hr : ''} + ${mailbox.unread ? `${mailbox.unread} unread message${mailbox.unread > 1 ? 's' : ''}` : ''} + ${hasError ? hr : ''} + ${hasError ? 'Authentication Error. Right click to reauthenticate' : ''} +
+ ` + }, + + render () { + if (!this.state.mailbox) { return null } + const { mailbox, isActive, activeService, popover, popoverAnchor, hovering } = this.state + const { index, isFirst, isLast, style, ...passProps } = this.props + delete passProps.mailboxId + + return ( +
this.setState({ hovering: true })} + onMouseLeave={() => this.setState({ hovering: false })} + data-tip={this.renderTooltipContent(mailbox)} + data-for={`ReactComponent-Sidelist-Item-Mailbox-${mailbox.id}`} + data-html> + + + {pkg.prerelease ? ( + + ) : undefined} + {this.renderBadge(mailbox)} + {this.renderActiveIndicator(mailbox, isActive)} + this.setState({ popover: false })} /> +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxAvatar.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxAvatar.js new file mode 100644 index 00000000..83f5dc35 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxAvatar.js @@ -0,0 +1,60 @@ +const React = require('react') +const { Avatar } = require('material-ui') +const { mailboxStore } = require('../../../stores/mailbox') +const shallowCompare = require('react-addons-shallow-compare') +const styles = require('../SidelistStyles') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemMailboxAvatar', + propTypes: { + isActive: React.PropTypes.bool.isRequired, + isHovering: React.PropTypes.bool.isRequired, + mailbox: React.PropTypes.object.isRequired, + index: React.PropTypes.number.isRequired, + onClick: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { isActive, isHovering, mailbox, index, ...passProps } = this.props + + let url + let children + let backgroundColor + const borderColor = isActive || isHovering ? mailbox.color : 'white' + if (mailbox.hasCustomAvatar) { + url = mailboxStore.getState().getAvatar(mailbox.customAvatarId) + backgroundColor = 'white' + } else if (mailbox.avatarURL) { + url = mailbox.avatarURL + backgroundColor = 'white' + } else { + children = index + backgroundColor = mailbox.color + } + + return ( + + {children} + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxPopover.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxPopover.js new file mode 100644 index 00000000..0d3eeabf --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxPopover.js @@ -0,0 +1,201 @@ +const React = require('react') +const { Popover, Menu, MenuItem, Divider, FontIcon } = require('material-ui') +const { mailboxDispatch, navigationDispatch } = require('../../../Dispatch') +const { mailboxActions } = require('../../../stores/mailbox') +const { mailboxWizardActions } = require('../../../stores/mailboxWizard') +const shallowCompare = require('react-addons-shallow-compare') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemMailboxPopover', + propTypes: { + mailbox: React.PropTypes.object.isRequired, + isFirst: React.PropTypes.bool.isRequired, + isLast: React.PropTypes.bool.isRequired, + isOpen: React.PropTypes.bool.isRequired, + anchor: React.PropTypes.any, + onRequestClose: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // User Interaction + /* **************************************************************************/ + + /** + * Closes the popover + * @param evtOrFn: the fired event or a function to call on closed + */ + handleClosePopover (evtOrFn) { + this.props.onRequestClose() + if (typeof (evtOrFn) === 'function') { + setTimeout(() => { evtOrFn() }, 200) + } + }, + + /** + * Deletes this mailbox + */ + handleDelete () { + this.handleClosePopover(() => { + mailboxActions.remove(this.props.mailbox.id) + }) + }, + + /** + * Opens the inspector window for this mailbox + */ + handleInspect () { + mailboxDispatch.openDevTools(this.props.mailbox.id) + this.handleClosePopover() + }, + + /** + * Reloads this mailbox + */ + handleReload () { + mailboxDispatch.reload(this.props.mailbox.id) + this.handleClosePopover() + }, + + /** + * Moves this item up + */ + handleMoveUp () { + this.handleClosePopover(() => { + mailboxActions.moveUp(this.props.mailbox.id) + }) + }, + + /** + * Moves this item down + */ + handleMoveDown () { + this.handleClosePopover(() => { + mailboxActions.moveDown(this.props.mailbox.id) + }) + }, + + /** + * Handles the user requesting an account reauthentication + */ + handeReAuthenticate () { + mailboxActions.reauthenticateBrowserSession(this.props.mailbox.id) + this.handleClosePopover() + }, + + /** + * Handles opening the account settings + */ + handleAccountSettings () { + this.handleClosePopover(() => { + navigationDispatch.openMailboxSettings(this.props.mailbox.id) + }) + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * Renders the menu items + * @param mailbox: the mailbox to render for + * @param isFirst: true if this is the first item + * @Param isLast: true if this is the last item + * @return array of jsx elements + */ + renderMenuItems (mailbox, isFirst, isLast) { + const menuItems = [ + // Mailbox Info + mailbox.email ? ( + ) : undefined, + + mailbox.google.authHasGrantError ? ( + { + mailboxWizardActions.reauthenticateGoogleMailbox(mailbox.id) + this.handleClosePopover() + }} + leftIcon={error_outline} /> + ) : undefined, + mailbox.google.authHasGrantError ? () : undefined, + + // Ordering controls + isFirst ? undefined : ( + arrow_upward} />), + isLast ? undefined : ( + arrow_downward} />), + isFirst && isLast ? undefined : (), + + // Account Actions + (delete} />), + (settings} />), + !mailbox.artificiallyPersistCookies ? undefined : ( + lock_outline} />), + (), + + // Advanced Actions + (refresh} />), + (bug_report} />) + ].filter((item) => !!item) + + return menuItems + }, + + render () { + const { mailbox, isFirst, isLast, isOpen, anchor } = this.props + + return ( + + + {this.renderMenuItems(mailbox, isFirst, isLast)} + + + ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxService.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxService.js new file mode 100644 index 00000000..bde0c4f5 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxService.js @@ -0,0 +1,91 @@ +const React = require('react') +const shallowCompare = require('react-addons-shallow-compare') +const { Mailbox } = require('shared/Models/Mailbox') +const { Avatar } = require('material-ui') +const styles = require('../SidelistStyles') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemMailboxServices', + propTypes: { + mailbox: React.PropTypes.object.isRequired, + isActiveMailbox: React.PropTypes.bool.isRequired, + isActiveService: React.PropTypes.bool.isRequired, + onOpenService: React.PropTypes.func.isRequired, + service: React.PropTypes.string.isRequired + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { isHovering: false } + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + /** + * @param mailboxType: the type of mailbox + * @param service: the service type + * @return the url of the service icon + */ + getServiceIconUrl (mailboxType, service) { + if (mailboxType === Mailbox.TYPE_GMAIL || mailboxType === Mailbox.TYPE_GINBOX) { + switch (service) { + case Mailbox.SERVICES.STORAGE: return '../../images/google_services/logo_drive_128px.png' + case Mailbox.SERVICES.CONTACTS: return '../../images/google_services/logo_contacts_128px.png' + case Mailbox.SERVICES.NOTES: return '../../images/google_services/logo_keep_128px.png' + case Mailbox.SERVICES.CALENDAR: return '../../images/google_services/logo_calendar_128px.png' + case Mailbox.SERVICES.COMMUNICATION: return '../../images/google_services/logo_hangouts_128px.png' + } + } + + return '' + }, + + render () { + const { mailbox, isActiveMailbox, isActiveService, service, onOpenService, ...passProps } = this.props + const { isHovering } = this.state + const isActive = isActiveMailbox && isActiveService + + if (mailbox.compactServicesUI) { + return ( +
this.setState({ isHovering: true })} + onMouseLeave={() => this.setState({ isHovering: false })} + style={styles.mailboxServiceIconCompact} + onClick={(evt) => onOpenService(evt, service)}> + +
+ ) + } else { + const borderColor = isActive || isHovering ? mailbox.color : 'white' + const baseStyle = isActive || isHovering ? styles.mailboxServiceIconImageFullActive : styles.mailboxServiceIconImageFull + return ( + this.setState({ isHovering: true })} + onMouseLeave={() => this.setState({ isHovering: false })} + size={35} + backgroundColor='white' + draggable={false} + onClick={(evt) => onOpenService(evt, service)} + style={Object.assign({ borderColor: borderColor }, baseStyle)} /> + ) + } + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxServices.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxServices.js new file mode 100644 index 00000000..37541d8a --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/SidelistItemMailboxServices.js @@ -0,0 +1,48 @@ +const React = require('react') +const shallowCompare = require('react-addons-shallow-compare') +const styles = require('../SidelistStyles') +const SidelistItemMailboxService = require('./SidelistItemMailboxService') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemMailboxServices', + propTypes: { + mailbox: React.PropTypes.object.isRequired, + isActiveMailbox: React.PropTypes.bool.isRequired, + activeService: React.PropTypes.string.isRequired, + onOpenService: React.PropTypes.func.isRequired + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const { mailbox, isActiveMailbox, activeService, onOpenService, onContextMenu } = this.props + if (!mailbox.hasEnabledServices) { return null } + + return ( +
+ {mailbox.enabledServies.map((service) => { + return ( + + ) + })} +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/index.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/index.js new file mode 100644 index 00000000..b5154bab --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemMailbox/index.js @@ -0,0 +1 @@ +module.exports = require('./SidelistItemMailbox') diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemNews.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemNews.js new file mode 100644 index 00000000..1077770e --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemNews.js @@ -0,0 +1,87 @@ +const React = require('react') +const { IconButton, Badge } = require('material-ui') +const Colors = require('material-ui/styles/colors') +const {navigationDispatch} = require('../../Dispatch') +const styles = require('./SidelistStyles') +const ReactTooltip = require('react-tooltip') +const { settingsStore } = require('../../stores/settings') + +module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemNews', + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + settingsStore.listen(this.settingsUpdated) + }, + + componentWillUnmount () { + settingsStore.unlisten(this.settingsUpdated) + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + hasUnopenedNewsId: settingsStore.getState().news.hasUnopenedNewsId, + newsLevel: settingsStore.getState().news.newsLevel + } + }, + + settingsUpdated (settingsState) { + this.setState({ + hasUnopenedNewsId: settingsState.news.hasUnopenedNewsId, + newsLevel: settingsState.news.newsLevel + }) + }, + + /* **************************************************************************/ + // UI Events + /* **************************************************************************/ + + handleClick () { + navigationDispatch.openNews() + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + render () { + const { style, ...passProps } = this.props + const { hasUnopenedNewsId, newsLevel } = this.state + return ( +
+ + {hasUnopenedNewsId && newsLevel === 'notify' ? ( + + ) : undefined} + +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemSettings.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemSettings.js new file mode 100644 index 00000000..c13cf7cd --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemSettings.js @@ -0,0 +1,45 @@ +const React = require('react') +const { IconButton } = require('material-ui') +const Colors = require('material-ui/styles/colors') +const {navigationDispatch} = require('../../Dispatch') +const styles = require('./SidelistStyles') +const ReactTooltip = require('react-tooltip') + +module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemSettings', + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + /** + * Renders the app + */ + render () { + const { style, ...passProps } = this.props + return ( +
+ navigationDispatch.openSettings()} + iconStyle={{ color: Colors.blueGrey400 }}> + settings + + +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemWizard.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemWizard.js new file mode 100644 index 00000000..4009c210 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistItemWizard.js @@ -0,0 +1,43 @@ +const React = require('react') +const { IconButton } = require('material-ui') +const Colors = require('material-ui/styles/colors') +const { appWizardActions } = require('../../stores/appWizard') +const styles = require('./SidelistStyles') +const ReactTooltip = require('react-tooltip') + +module.exports = React.createClass({ + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistItemWizard', + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + /** + * Renders the app + */ + render () { + const { style, ...passProps } = this.props + return ( +
+ appWizardActions.startWizard()} + iconStyle={{ color: Colors.yellow600 }} /> + +
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/MailboxList.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistMailboxes.js similarity index 51% rename from src/scenes/mailboxes/src/ui/Sidelist/MailboxList.js rename to src/scenes/mailboxes/src/ui/Sidelist/SidelistMailboxes.js index 8aab7f98..d5164b62 100644 --- a/src/scenes/mailboxes/src/ui/Sidelist/MailboxList.js +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistMailboxes.js @@ -1,26 +1,25 @@ -'use strict' - -import './mailboxList.less' - const React = require('react') -const flux = { - mailbox: require('../../stores/mailbox') -} -const MailboxListItem = require('./MailboxListItem') +const { mailboxStore } = require('../../stores/mailbox') +const SidelistItemMailbox = require('./SidelistItemMailbox') module.exports = React.createClass({ - displayName: 'MailboxList', + + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'SidelistMailboxes', /* **************************************************************************/ // Lifecycle /* **************************************************************************/ componentDidMount () { - flux.mailbox.S.listen(this.mailboxesChanged) + mailboxStore.listen(this.mailboxesChanged) }, componentWillUnmount () { - flux.mailbox.S.unlisten(this.mailboxesChanged) + mailboxStore.unlisten(this.mailboxesChanged) }, /* **************************************************************************/ @@ -28,36 +27,36 @@ module.exports = React.createClass({ /* **************************************************************************/ getInitialState () { - return { mailboxIds: flux.mailbox.S.getState().mailboxIds() } + return { + mailboxIds: mailboxStore.getState().mailboxIds() + } }, mailboxesChanged (store) { - this.setState({ mailboxIds: store.mailboxIds() }) - }, - - shouldComponentUpdate (nextProps, nextState) { - if (!this.state || !nextState) { return true } - if (this.state.mailboxIds.length !== nextState.mailboxIds.length) { return true } - if (this.state.mailboxIds.find((id, i) => id !== nextState.mailboxIds[i].id)) { return true } - - return false + this.setState({ + mailboxIds: store.mailboxIds() + }) }, /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ + shouldComponentUpdate (nextProps, nextState) { + if (JSON.stringify(this.state.mailboxIds) !== JSON.stringify(nextState.mailboxIds)) { return true } + return false + }, + render () { + const { styles, ...passProps } = this.props + const { mailboxIds } = this.state return ( -
- {this.state.mailboxIds.map((id, index, arr) => { +
+ {mailboxIds.map((mailboxId, index, arr) => { return ( - ) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistSettings.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistSettings.js deleted file mode 100644 index b1b2111b..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/SidelistSettings.js +++ /dev/null @@ -1,30 +0,0 @@ -const React = require('react') -const { IconButton } = require('material-ui') -const Colors = require('material-ui/styles/colors') -const navigationDispatch = require('../Dispatch/navigationDispatch') - -module.exports = React.createClass({ - displayName: 'SidelistSettings', - - /* **************************************************************************/ - // Rendering - /* **************************************************************************/ - - /** - * Renders the app - */ - render () { - return ( -
- navigationDispatch.openSettings()} - iconStyle={{ color: Colors.blueGrey400 }}> - settings - -
- ) - } -}) diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.js b/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.js new file mode 100644 index 00000000..fd853b54 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.js @@ -0,0 +1,160 @@ +import './SidelistStyles.less' +const Colors = require('material-ui/styles/colors') +const FOOTER_ITEM_HEIGHT = 50 + +module.exports = { + /** + * Layout + */ + container: { + backgroundColor: Colors.blueGrey900, + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0 + }, + footer: { + height: 2 * FOOTER_ITEM_HEIGHT, + position: 'absolute', + bottom: 0, + left: 0, + right: 0 + }, + footer3Icons: { + height: 3 * FOOTER_ITEM_HEIGHT + }, + footer4Icons: { + height: 4 * FOOTER_ITEM_HEIGHT + }, + scroller: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 2 * FOOTER_ITEM_HEIGHT, + overflowY: 'auto', + overflowX: 'hidden' + }, + scroller3Icons: { + bottom: 3 * FOOTER_ITEM_HEIGHT + }, + scroller4Icons: { + bottom: 4 * FOOTER_ITEM_HEIGHT + }, + itemContainer: { + textAlign: 'center' + }, + + /** + * Mailbox Item + */ + mailboxItemContainer: { + marginTop: 10, + marginBottom: 10, + position: 'relative' + }, + mailboxAvatar: { + borderWidth: 4, + borderStyle: 'solid', + cursor: 'pointer' + }, + mailboxBadge: { + backgroundColor: 'rgba(238, 54, 55, 0.95)', + color: Colors.red50, + fontWeight: '100', + width: 'auto', + minWidth: 24, + paddingLeft: 4, + paddingRight: 4, + borderRadius: 12, + WebkitUserSelect: 'none', + cursor: 'pointer' + }, + mailboxBadgeContainer: { + position: 'absolute', + top: -3, + right: 3, + cursor: 'pointer' + }, + mailboxActiveIndicator: { + position: 'absolute', + left: 2, + top: 25, + width: 6, + height: 6, + marginTop: -3, + borderRadius: '50%', + cursor: 'pointer' + }, + + /** + * Mailbox Item: Services + */ + mailboxServiceIconsCompact: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + marginLeft: 2, + marginRight: 2 + }, + mailboxServiceIconCompact: { + cursor: 'pointer', + padding: 2 + }, + mailboxServiceIconImageCompact: { + maxWidth: '100%', + maxHeight: 18, + opacity: 0.7, + filter: 'grayscale(25%)' + }, + mailboxServiceIconImageActiveCompact: { + maxWidth: '100%', + maxHeight: 18 + }, + mailboxServiceIconsFull: { + + }, + mailboxServiceIconImageFull: { + display: 'block', + margin: '4px auto', + borderWidth: 3, + borderStyle: 'solid', + cursor: 'pointer', + opacity: 0.8 + }, + mailboxServiceIconImageFullActive: { + display: 'block', + margin: '4px auto', + borderWidth: 3, + borderStyle: 'solid', + cursor: 'pointer' + }, + + /** + * News Item + */ + newsItemContainer: { + position: 'relative', + textAlign: 'center' + }, + newsBadge: { + backgroundColor: 'rgba(238, 54, 55, 0.95)', + color: Colors.red50, + fontWeight: '100', + width: 'auto', + minWidth: 24, + paddingLeft: 4, + paddingRight: 4, + borderRadius: 12, + WebkitUserSelect: 'none', + cursor: 'pointer' + }, + newsBadgeContainer: { + position: 'absolute', + top: -3, + right: 3, + cursor: 'pointer', + zIndex: 2 + } +} diff --git a/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.less b/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.less new file mode 100644 index 00000000..d1042475 --- /dev/null +++ b/src/scenes/mailboxes/src/ui/Sidelist/SidelistStyles.less @@ -0,0 +1,3 @@ +.ReactComponent-Sidelist-Scroller { + &::-webkit-scrollbar { display: none; } +} diff --git a/src/scenes/mailboxes/src/ui/Sidelist/mailboxList.less b/src/scenes/mailboxes/src/ui/Sidelist/mailboxList.less deleted file mode 100644 index 17b0b63e..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/mailboxList.less +++ /dev/null @@ -1,14 +0,0 @@ -@HEAD_HEIGHT: 25px; -@FOOT_HEIGHT: 96px; - -.mailbox-list { - position: absolute; - top: @HEAD_HEIGHT; - left: 0px; - right: 0px; - bottom: @FOOT_HEIGHT; - overflow-y: auto; - overflow-x: hidden; - - &::-webkit-scrollbar { display: none; } -} \ No newline at end of file diff --git a/src/scenes/mailboxes/src/ui/Sidelist/mailboxListItem.less b/src/scenes/mailboxes/src/ui/Sidelist/mailboxListItem.less deleted file mode 100644 index c8526a7d..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/mailboxListItem.less +++ /dev/null @@ -1,113 +0,0 @@ -@INBOX_PRIM_COL: rgb(66, 133, 244); -@INBOX_SEC_COL: rgb(42, 117, 243); -@GMAIL_PRIM_COL: rgb(220, 75, 75); -@GMAIL_SEC_COL: rgb(216, 54, 54); - -@ITEM_SIZE: 50px; -@ITEM_BORDER_SIZE: 4px; - -@INDICATOR_SIZE: 6px; - -.mailbox-list { - - .list-item { - position: relative; - margin: 10px auto; - width: @ITEM_SIZE; - height: @ITEM_SIZE; - -webkit-app-region: no-drag; - - &, * { - -webkit-touch-callout: none; - -webkit-user-select: none; - user-select: none; - } - - /****************************************************** - * Mailbox icon - ******************************************************/ - - .mailbox { - margin: 10px auto; - width: @ITEM_SIZE; - height: @ITEM_SIZE; - overflow: hidden; - cursor: pointer; - border-radius: 50%; - border: @ITEM_BORDER_SIZE solid white; - - &:hover { - box-shadow: 0px 0px 5px 0px rgba(255, 255, 255, 0.5); - } - - &.active:before, &:hover:before { - content: ''; - position: absolute; - left: -8px; - top: 50%; - background-color: rgba(255, 255, 255, 0.4); - width: @INDICATOR_SIZE; - height: @INDICATOR_SIZE; - margin-top: -@INDICATOR_SIZE/2; - border-radius: 50%; - } - - /****************************************************** - * Mailbox : avatar - ******************************************************/ - - .avatar { - display: block; - width: 100%; - height: 100%; - } - - /****************************************************** - * Mailbox : index - ******************************************************/ - - .index { - display: block; - width: @ITEM_SIZE - @ITEM_BORDER_SIZE - @ITEM_BORDER_SIZE; - height: @ITEM_SIZE - @ITEM_BORDER_SIZE - @ITEM_BORDER_SIZE; - text-align: center; - font-size: 20px; - line-height: @ITEM_SIZE - @ITEM_BORDER_SIZE - @ITEM_BORDER_SIZE; - color: white; - } - - /****************************************************** - * Mailbox types - ******************************************************/ - &[data-type="ginbox"] { - background-color: @INBOX_PRIM_COL; - &.active, &:hover { border-color: @INBOX_PRIM_COL; } - &.avatar { background-color: white; } - &.index { - &.active, &:hover { border-color: @INBOX_SEC_COL; } - } - &.active:before { background-color: @INBOX_PRIM_COL; } - } - - &[data-type="gmail"] { - background-color: @GMAIL_PRIM_COL; - &.active, &:hover { border-color: @GMAIL_PRIM_COL; } - &.avatar { background-color: white; } - &.index { - &.active, &:hover { border-color: @GMAIL_SEC_COL; } - } - &.active:before { background-color: @GMAIL_PRIM_COL; } - } - } - - /****************************************************** - * Unread Count - ******************************************************/ - .unread-badge { - position: absolute !important; - top: 0px; - right: -8px; - font-weight: 100; - } - } -} diff --git a/src/scenes/mailboxes/src/ui/Sidelist/sidelist.less b/src/scenes/mailboxes/src/ui/Sidelist/sidelist.less deleted file mode 100644 index b36e1137..00000000 --- a/src/scenes/mailboxes/src/ui/Sidelist/sidelist.less +++ /dev/null @@ -1,14 +0,0 @@ -.add-mailbox-control, .settings-control { - @ITEM_SIZE: 48px; - - position: absolute; - left: 50%; - margin-left: -@ITEM_SIZE /2; - width: @ITEM_SIZE; - height: @ITEM_SIZE; - text-align: center; - -webkit-app-region: no-drag; - - &.add-mailbox-control { bottom: @ITEM_SIZE; } - &.settings-control { bottom: 0px; } -} \ No newline at end of file diff --git a/src/scenes/mailboxes/src/ui/Tray.js b/src/scenes/mailboxes/src/ui/Tray.js index 2f4f1797..6543240d 100644 --- a/src/scenes/mailboxes/src/ui/Tray.js +++ b/src/scenes/mailboxes/src/ui/Tray.js @@ -1,41 +1,64 @@ const electron = window.nativeRequire('electron') -const {Tray, systemPreferences, Menu, nativeImage} = electron.remote -const ipc = electron.ipcRenderer +const { ipcRenderer, remote } = electron +const { Tray, Menu, nativeImage } = remote const React = require('react') -const shallowCompare = require('react-addons-shallow-compare') -const mailboxDispatch = require('./Dispatch/mailboxDispatch') -const mailboxActions = require('../stores/mailbox/mailboxActions') -const { - BLANK_PNG, - MAIL_SVG -} = require('shared/b64Assets') +const { mailboxDispatch } = require('../Dispatch') +const { mailboxActions, mailboxStore } = require('../stores/mailbox') +const { composeActions } = require('../stores/compose') +const { BLANK_PNG } = require('shared/b64Assets') +const { TrayRenderer } = require('../Components') +const navigationDispatch = require('../Dispatch/navigationDispatch') +const uuid = require('uuid') module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ displayName: 'Tray', + // Pretty strict on updating. If you're changing these, change shouldComponentUpdate :) propTypes: { unreadCount: React.PropTypes.number.isRequired, - unreadMessages: React.PropTypes.object.isRequired, - showUnreadCount: React.PropTypes.bool.isRequired, - unreadColor: React.PropTypes.string, - readColor: React.PropTypes.string + traySettings: React.PropTypes.object.isRequired + }, + statics: { + platformSupportsDpiMultiplier: () => { + return process.platform === 'darwin' || process.platform === 'linux' + } }, /* **************************************************************************/ - // Lifecycle + // Component Lifecycle /* **************************************************************************/ componentDidMount () { - const loader = new window.Image() - loader.src = MAIL_SVG - loader.onload = (e) => { - this.setState({ icon: loader }) + mailboxStore.listen(this.mailboxesChanged) + + this.appTray = new Tray(nativeImage.createFromDataURL(BLANK_PNG)) + if (process.platform === 'win32') { + this.appTray.on('double-click', () => { + ipcRenderer.send('toggle-mailbox-visibility-from-tray') + }) + this.appTray.on('click', () => { + ipcRenderer.send('toggle-mailbox-visibility-from-tray') + }) + } else if (process.platform === 'linux') { + // On platforms that have app indicator support - i.e. ubuntu clicking on the + // icon will launch the context menu. On other linux platforms the context + // menu is opened on right click. For app indicator platforms click event + // is ignored + this.appTray.on('click', () => { + ipcRenderer.send('toggle-mailbox-visibility-from-tray') + }) } }, componentWillUnmount () { - if (this.state.appTray) { - this.state.appTray.destroy() + mailboxStore.unlisten(this.mailboxesChanged) + + if (this.appTray) { + this.appTray.destroy() + this.appTray = null } }, @@ -43,72 +66,84 @@ module.exports = React.createClass({ // Data lifecycle /* **************************************************************************/ - getDefaultReadColor () { return process.platform === 'darwin' && systemPreferences.isDarkMode() ? '#FFFFFF' : '#000000' }, - getDefaultUnreadColor () { return '#C82018' }, - getInitialState () { - const appTray = new Tray(nativeImage.createFromDataURL(BLANK_PNG)) - if (process.platform === 'win32') { - appTray.on('double-click', () => { - ipc.send('focus-app') - }) - } - return { appTray: appTray } + return Object.assign({}, this.generateMenuUnreadMessages()) }, - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) + mailboxesChanged (store) { + this.setState(this.generateMenuUnreadMessages(store)) + }, + + /** + * Generates the unread messages from the mailboxes store + * @param store=autogen: the mailbox store + * @return { menuUnreadMessages, menuUnreadMessagesSig } with menuUnreadMessages + * being an array of mailboxes with menu items prepped to display and menuUnreadMessagesSig + * being a string hash of these to compare + */ + generateMenuUnreadMessages (store = mailboxStore.getState()) { + const menuItems = store.mailboxIds().map((mailboxId) => { + const mailbox = store.getMailbox(mailboxId) + const menuItems = mailbox.google.latestUnreadMessages.map((message) => { + const headers = message.payload.headers + const subject = (headers.find((h) => h.name === 'Subject') || {}).value || 'No Subject' + const fromEmail = (headers.find((h) => h.name === 'From') || {}).value || '' + const fromEmailMatch = fromEmail.match('(.+)<(.+)@(.+)>$') + const sender = fromEmailMatch ? fromEmailMatch[1].trim() : fromEmail + + return { + id: `${mailboxId}:${message.threadId}:${message.id}`, // used for update tracking + label: `${sender} : ${subject}`, + date: parseInt(message.internalDate), + click: (e) => { + ipcRenderer.send('focus-app', { }) + mailboxActions.changeActive(mailboxId) + mailboxDispatch.openMessage(mailboxId, message.threadId, message.id) + } + } + }) + .filter((info) => info !== undefined) + .sort((a, b) => b.date - a.date) + .slice(0, 10) + + const unreadString = isNaN(mailbox.unread) || mailbox.unread === 0 ? '' : `(${mailbox.unread})` + + return { + label: `${unreadString} ${mailbox.email || 'Untitled'}`, + submenu: menuItems.length !== 0 ? menuItems : [ + { label: 'No messages', enabled: false } + ] + } + }) + + const sig = menuItems + .map((mailboxItem) => mailboxItem.submenu.map((item) => item.id).join('|')) + .join('|') + + return { menuUnreadMessages: menuItems, menuUnreadMessagesSig: sig } }, /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * @return the nativeImage for the tray - */ - renderImage () { - const SIZE = 22 * window.devicePixelRatio - const PADDING = SIZE * 0.15 - const CENTER = SIZE / 2 - const COLOR = this.props.unreadCount ? (this.props.unreadColor || this.getDefaultUnreadColor()) : (this.props.readColor || this.getDefaultReadColor()) - - const canvas = document.createElement('canvas') - canvas.width = SIZE - canvas.height = SIZE - - const ctx = canvas.getContext('2d') - - // Count - if (this.props.showUnreadCount && this.props.unreadCount && this.props.unreadCount < 99) { - ctx.fillStyle = COLOR - ctx.textAlign = 'center' - if (this.props.unreadCount < 10) { - ctx.font = (SIZE * 0.5) + 'px Helvetica' - ctx.fillText(this.props.unreadCount, CENTER, CENTER + (SIZE * 0.20)) - } else { - ctx.font = (SIZE * 0.4) + 'px Helvetica' - ctx.fillText(this.props.unreadCount, CENTER, CENTER + (SIZE * 0.15)) - } - } else { - const ICON_SIZE = SIZE * 0.5 - const POS = (SIZE - ICON_SIZE) / 2 - ctx.fillStyle = COLOR - ctx.fillRect(0, 0, SIZE, SIZE) - ctx.globalCompositeOperation = 'destination-atop' - ctx.drawImage(this.state.icon, POS, POS, ICON_SIZE, ICON_SIZE) - } + shouldComponentUpdate (nextProps, nextState) { + if (this.props.unreadCount !== nextProps.unreadCount) { return true } + if (this.state.menuUnreadMessagesSig !== nextState.menuUnreadMessagesSig) { return true } - // Outer circle - ctx.globalCompositeOperation = 'source-over' - ctx.beginPath() - ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false) - ctx.lineWidth = window.devicePixelRatio * 1.1 - ctx.strokeStyle = COLOR - ctx.stroke() + const trayDiff = [ + 'unreadColor', + 'unreadBackgroundColor', + 'readColor', + 'readBackgroundColor', + 'showUnreadCount', + 'dpiMultiplier' + ].findIndex((k) => { + return this.props.traySettings[k] !== nextProps.traySettings[k] + }) !== -1 + if (trayDiff) { return true } - const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPng() - return nativeImage.createFromBuffer(pngData, window.devicePixelRatio) + return false }, /** @@ -122,68 +157,100 @@ module.exports = React.createClass({ * @return the context menu for the tray icon */ renderContextMenu () { - // Build the unread items up - const unreadItems = Object.keys(this.props.unreadMessages) - .reduce((acc, mailboxId) => { - const messages = Object.keys(this.props.unreadMessages[mailboxId]) - .map((id) => this.props.unreadMessages[mailboxId][id]) - .map((info) => { - info.mailboxId = mailboxId - return info - }) - return acc.concat(messages) - }, []) - .filter((info) => info.message !== undefined) - .sort((a, b) => { - return parseInt(b.message.internalDate) - parseInt(a.message.internalDate) - }) - .slice(0, 5) - .map((info) => { - const headers = info.message.payload.headers - const subject = (headers.find((h) => h.name === 'Subject') || {}).value || 'No Subject' - const fromEmail = (headers.find((h) => h.name === 'From') || {}).value || '' - const fromEmailMatch = fromEmail.match('(.+)<(.+)@(.+)>$') - if (fromEmailMatch) { - info.snippet = fromEmailMatch[1].trim() + ' : ' + subject - } else { - info.snippet = fromEmail + ' : ' + subject - } - return info - }) - .map((info) => { - return { - label: info.snippet, - click: (e) => { - mailboxActions.changeActive(info.mailboxId) - mailboxDispatch.openMessage(info.mailboxId, info.message.threadId, info.message.id) - } - } - }) + let unreadItems = [] + if (this.state.menuUnreadMessages.length === 1) { // Only one account + unreadItems = this.state.menuUnreadMessages[0].submenu + } else if (this.state.menuUnreadMessages.length > 1) { // Multiple accounts + unreadItems = this.state.menuUnreadMessages + } // Build the template let template = [ + { + label: 'Compose New Message', + click: (e) => { + ipcRenderer.send('focus-app') + composeActions.composeNewMessage() + } + }, { label: this.renderTooltip(), enabled: false }, { type: 'separator' } ] + if (unreadItems.length) { template = template.concat(unreadItems) template.push({ type: 'separator' }) } + template = template.concat([ - { label: 'Focus', click: (e) => ipc.send('focus-app') }, + { + label: 'Show / Hide', + click: (e) => { + ipcRenderer.send('toggle-mailbox-visibility-from-tray') + } + }, + { + label: 'WMail News', + click: (e) => { + navigationDispatch.openNews() + ipcRenderer.send('focus-app', { }) + } + }, { type: 'separator' }, - { label: 'Quit', click: (e) => ipc.send('quit-app') } + { + label: 'Quit', + click: (e) => { + ipcRenderer.send('quit-app') + } + } ]) return Menu.buildFromTemplate(template) }, + /** + * @return the tray icon size + */ + trayIconSize () { + switch (process.platform) { + case 'darwin': return 22 + case 'win32': return 16 + case 'linux': return 32 * this.props.traySettings.dpiMultiplier + default: return 32 + } + }, + + /** + * @return the pixel ratio + */ + trayIconPixelRatio () { + switch (process.platform) { + case 'darwin': return this.props.traySettings.dpiMultiplier + default: return 1 + } + }, + render () { - if (!this.state.appTray || !this.state.icon) { return false } - this.state.appTray.setImage(this.renderImage()) - this.state.appTray.setToolTip(this.renderTooltip()) - this.state.appTray.setContextMenu(this.renderContextMenu()) + const { unreadCount, traySettings } = this.props + + const renderId = uuid.v4() + this.renderId = renderId + TrayRenderer.renderNativeImage({ + unreadCount: unreadCount, + showUnreadCount: traySettings.showUnreadCount, + unreadColor: traySettings.unreadColor, + readColor: traySettings.readColor, + unreadBackgroundColor: traySettings.unreadBackgroundColor, + readBackgroundColor: traySettings.readBackgroundColor, + size: this.trayIconSize(), + pixelRatio: this.trayIconPixelRatio() + }).then((image) => { + if (renderId !== this.renderId) { return } + this.appTray.setImage(image) + this.appTray.setToolTip(this.renderTooltip()) + this.appTray.setContextMenu(this.renderContextMenu()) + }) - return
+ return (
) } }) diff --git a/src/scenes/mailboxes/src/ui/UpdateCheckDialog.js b/src/scenes/mailboxes/src/ui/UpdateCheckDialog.js new file mode 100644 index 00000000..5d9c76fb --- /dev/null +++ b/src/scenes/mailboxes/src/ui/UpdateCheckDialog.js @@ -0,0 +1,175 @@ +const React = require('react') +const shallowCompare = require('react-addons-shallow-compare') +const TimerMixin = require('react-timer-mixin') +const compareVersion = require('compare-version') +const { UPDATE_CHECK_URL, UPDATE_CHECK_INTERVAL, UPDATE_DOWNLOAD_URL } = require('shared/constants') +const { FlatButton, RaisedButton, Dialog } = require('material-ui') +const settingsStore = require('../stores/settings/settingsStore') +const settingsActions = require('../stores/settings/settingsActions') +const pkg = window.appPackage() +const { + remote: {shell} +} = window.nativeRequire('electron') + +module.exports = React.createClass({ + /* **************************************************************************/ + // Class + /* **************************************************************************/ + + displayName: 'UpdateCheckDialog', + mixins: [TimerMixin], + + /* **************************************************************************/ + // Component Lifecycle + /* **************************************************************************/ + + componentDidMount () { + this.recheckTO = null + this.checkNow() + }, + + /* **************************************************************************/ + // Data Lifecycle + /* **************************************************************************/ + + getInitialState () { + return { + newerVersion: null, + recheckRestart: false + } + }, + + /* **************************************************************************/ + // Checking + /* **************************************************************************/ + + /** + * Checks with the server for an update + */ + checkNow () { + Promise.resolve() + .then(() => window.fetch(`${UPDATE_CHECK_URL}?_=${new Date().getTime()}`)) + .then((res) => res.ok ? Promise.resolve(res) : Promise.reject(res)) + .then((res) => res.json()) + .then((res) => { + let update + if (pkg.prerelease) { + if (compareVersion(res.prerelease.version, res.release.version) >= 1) { // prerelease is newest + if (compareVersion(res.prerelease.version, pkg.version) >= 1) { + update = res.prerelease.version + } + } else { // release is newest + if (compareVersion(res.release.version, pkg.version) >= 1) { + update = res.release.version + } + } + } else { + if (compareVersion(res.release.version, pkg.version) >= 1) { + update = res.release.version + } + } + + if (pkg.prerelease) { + if (res.prerelease.news) { + settingsActions.updateLatestNews(res.prerelease.news) + } + } else { + if (res.release.news) { + settingsActions.updateLatestNews(res.release.news) + } + } + + if (update) { + if (this.state.recheckRestart || settingsStore.getState().app.checkForUpdates === false) { + this.scheduleNextCheck() + } else { + this.setState({ newerVersion: update }) + this.clearTimeout(this.recheckTO) + } + } else { + this.setState({ newerVersion: null }) + this.scheduleNextCheck() + } + }) + }, + + /** + * Schedules the next check + */ + scheduleNextCheck () { + this.clearTimeout(this.recheckTO) + this.recheckTO = this.setTimeout(() => { + this.checkNow() + }, UPDATE_CHECK_INTERVAL) + }, + + /** + * Dismisses the modal an waits for the next check + */ + recheckLater () { + this.setState({ newerVersion: null }) + this.scheduleNextCheck() + }, + + /** + * Cancels the recheck and rechecks after reboot + */ + recheckRestart () { + this.setState({ + newerVersion: null, + recheckRestart: true + }) + this.recheckLater() + }, + + /** + * Opens the download link + */ + downloadNow () { + shell.openExternal(UPDATE_DOWNLOAD_URL) + this.recheckLater() + }, + + /* **************************************************************************/ + // Rendering + /* **************************************************************************/ + + shouldComponentUpdate (nextProps, nextState) { + return shallowCompare(this, nextProps, nextState) + }, + + render () { + const buttons = [ + (), + (), + () + ] + + return ( + +

+ Version + {this.state.newerVersion} + is now available. Do you want to download it now? +

+
+ ) + } +}) diff --git a/src/scenes/mailboxes/src/ui/Welcome/Welcome.js b/src/scenes/mailboxes/src/ui/Welcome/Welcome.js index 8164211d..bfcc5719 100644 --- a/src/scenes/mailboxes/src/ui/Welcome/Welcome.js +++ b/src/scenes/mailboxes/src/ui/Welcome/Welcome.js @@ -1,46 +1,78 @@ -import './welcome.less' const React = require('react') -const flux = { - google: require('../../stores/google') +const { mailboxWizardActions } = require('../../stores/mailboxWizard') +const { RaisedButton, FontIcon } = require('material-ui') +const Colors = require('material-ui/styles/colors') + +const styles = { + icon: { + height: 80, + width: 80, + display: 'inline-block', + marginLeft: 10, + marginRight: 10, + backgroundSize: 'contain', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + backgroundImage: 'url("../../icons/app_512.png")' + }, + container: { + textAlign: 'center', + overflow: 'auto' + }, + heading: { + backgroundColor: Colors.red400, + color: 'white', + paddingTop: 40, + paddingBottom: 20 + }, + headingTitle: { + fontWeight: '200', + fontSize: '30px', + marginBottom: 0 + }, + headingSubtitle: { + fontWeight: '200', + fontSize: '18px' + }, + setupItem: { + marginTop: 32 + }, + setupItemExtended: { + fontSize: '85%', + color: Colors.grey600 + } } module.exports = React.createClass({ - displayName: 'Welcome', /* **************************************************************************/ - // Events + // Class /* **************************************************************************/ - handleAddInbox (evt) { - evt.preventDefault() - flux.google.A.authInboxMailbox() - }, - - handleAddGmail (evt) { - evt.preventDefault() - flux.google.A.authGmailMailbox() - }, + displayName: 'Welcome', /* **************************************************************************/ // Rendering /* **************************************************************************/ - /** - * Renders the app - */ render () { return ( -
-

Add your first mailbox

-

- To get started you need to add a mailbox. You can add your Gmail or Google Inbox account. -
- To add more mailboxes later on just tap the plus icon in the toolbar on the left -

-
-
- - +
+
+
+

Welcome to WMail

+

...the open-source desktop client for Gmail and Google Inbox

+
+
+ mail_outline)} + primary + onClick={() => mailboxWizardActions.openAddMailbox()} /> +

+ Get started by adding your first Gmail or Google Inbox accout +

+
) } diff --git a/src/scenes/mailboxes/src/ui/Welcome/welcome.less b/src/scenes/mailboxes/src/ui/Welcome/welcome.less deleted file mode 100644 index f712e51d..00000000 --- a/src/scenes/mailboxes/src/ui/Welcome/welcome.less +++ /dev/null @@ -1,37 +0,0 @@ -.welcome { - text-align:center; - - * { - font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; - font-weight: 300; - } - - .btn { - margin: 20px auto; - display: block; - background-color: #4D90FE; - background-image: linear-gradient(top,#4d90fe,#4787ed); - color: white; - - border: 1px solid #3079ED; - border-radius: 2px; - - cursor: pointer; - font-size: 18px; - text-align: center; - height: 47px; - line-height: 47px; - min-width: 54px; - padding: 0 16px; - text-decoration: none; - - &:hover { - background-color: #357AE8; - background-image: linear-gradient(top,#4d90fe,#357ae8); - - border: 1px solid #2F5BB7; - - box-shadow: 0px 1px 1px rgba(0,0,0,.1); - } - } -} \ No newline at end of file diff --git a/src/scenes/mailboxes/src/ui/appContent.less b/src/scenes/mailboxes/src/ui/appContent.less index ebb573f4..31299528 100644 --- a/src/scenes/mailboxes/src/ui/appContent.less +++ b/src/scenes/mailboxes/src/ui/appContent.less @@ -1,28 +1,30 @@ #app { - .master, .detail { - position: absolute; + &, .master, .detail, .titlebar { + position: fixed; top: 0px; left: 0px; right: 0px; bottom: 0px; + } + & { + overflow: hidden; + } - &.master { - width: 70px; - right: auto; - z-index: 100; - -webkit-app-region: drag; - } + .titlebar { + bottom: auto; + height: 16px; + -webkit-app-region: drag; + z-index: 100; + } - &.detail { - left: 70px; - } + .master { + width: 70px; + right: auto; + z-index: 100; + -webkit-app-region: drag; } - .mailboxes { - position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; + .detail { + left: 70px; } -} \ No newline at end of file +} diff --git a/src/scenes/mailboxes/src/ui/layout.less b/src/scenes/mailboxes/src/ui/layout.less new file mode 100644 index 00000000..199cc1df --- /dev/null +++ b/src/scenes/mailboxes/src/ui/layout.less @@ -0,0 +1,8 @@ +html, body { + position: fixed; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + overflow: visible; +} diff --git a/src/scenes/mailboxes/webpack.config.js b/src/scenes/mailboxes/webpack.config.js index 23bff5ba..535707e7 100644 --- a/src/scenes/mailboxes/webpack.config.js +++ b/src/scenes/mailboxes/webpack.config.js @@ -23,6 +23,12 @@ const options = { filename: 'mailboxes.js' }, plugins: [ + !isProduction ? undefined : new webpack.DefinePlugin({ + 'process.env': { + 'NODE_ENV': JSON.stringify('production') + } + }), + // Clean out our bin dir new CleanWebpackPlugin([path.relative(BIN_DIR, OUT_DIR)], { root: BIN_DIR, @@ -35,7 +41,9 @@ const options = { // Copy our static assets new CopyWebpackPlugin([ - { from: path.join(__dirname, 'src/mailboxes.html'), to: 'mailboxes.html', force: true } + { from: path.join(__dirname, 'src/mailboxes.html'), to: 'mailboxes.html', force: true }, + { from: path.join(__dirname, 'src/offline.html'), to: 'offline.html', force: true }, + { from: path.join(__dirname, 'src/notification.html'), to: 'notification.html', force: true } ], { ignore: [ '.DS_Store' ] }), @@ -62,7 +70,10 @@ const options = { test: /(\.jsx|\.js)$/, loader: 'babel', exclude: /node_modules/, - include: __dirname, + include: [ + __dirname, + path.resolve(path.join(__dirname, '../../shared')) + ], query: { cacheDirectory: true, presets: ['react', 'stage-0', 'es2015'] @@ -71,6 +82,10 @@ const options = { { test: /(\.less|\.css)$/, loaders: ['style', 'css', 'less'] + }, + { + test: /(\.json)$/, + loader: 'json' } ] } diff --git a/src/scenes/platform/src/nativeRequire.js b/src/scenes/platform/src/nativeRequire.js index d90bc7d0..5b7681ec 100644 --- a/src/scenes/platform/src/nativeRequire.js +++ b/src/scenes/platform/src/nativeRequire.js @@ -11,3 +11,7 @@ window.remoteRequire = function (name) { window.appNodeModulesRequire = function (name) { return require('../../app/node_modules/' + name) } + +window.appPackage = function () { + return require('../../app/package.json') +} diff --git a/src/scenes/platform/src/webviewInjection/Browser/Browser.js b/src/scenes/platform/src/webviewInjection/Browser/Browser.js new file mode 100644 index 00000000..b2247693 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Browser/Browser.js @@ -0,0 +1,26 @@ +const KeyboardNavigator = require('./KeyboardNavigator') +const Spellchecker = require('./Spellchecker') +const ContextMenu = require('./ContextMenu') +const { ipcRenderer } = require('electron') + +class Browser { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.keyboardNavigator = new KeyboardNavigator() + this.spellchecker = new Spellchecker() + this.contextMenu = new ContextMenu(this.spellchecker) + + ipcRenderer.on('get-process-memory-info', (evt, data) => { + ipcRenderer.sendToHost({ + data: process.getProcessMemoryInfo(), + type: data.__respond__ + }) + }) + } +} + +module.exports = Browser diff --git a/src/scenes/platform/src/webviewInjection/Browser/ContextMenu.js b/src/scenes/platform/src/webviewInjection/Browser/ContextMenu.js new file mode 100644 index 00000000..5ae991e1 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Browser/ContextMenu.js @@ -0,0 +1,136 @@ +const { remote, ipcRenderer } = require('electron') +const { shell, clipboard, Menu } = remote +const webContents = remote.getCurrentWebContents() +const dictInfo = require('../../../../app/shared/dictionaries.js') + +class ContextMenu { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + /** + * @param spellchecker=undefined: the spellchecker to use for suggestions + */ + constructor (spellchecker = undefined) { + this.spellchecker = spellchecker + + webContents.removeAllListeners('context-menu') // Failure to do this will cause an error on reload + webContents.on('context-menu', this.launchMenu.bind(this)) + } + + /* **************************************************************************/ + // Menu + /* **************************************************************************/ + + /** + * Renders menu items for spelling suggestions + * @param suggestions: a list of text suggestions + * @return a list of menu items + */ + _renderSuggestionMenuItems_ (suggestions) { + const menuItems = [] + if (suggestions.length) { + suggestions.forEach((suggestion) => { + menuItems.push({ + label: suggestion, + click: () => { webContents.replaceMisspelling(suggestion) } + }) + }) + } else { + menuItems.push({ label: 'No Spelling Suggestions', enabled: false }) + } + return menuItems + } + + /** + * Launches the context menu + * @param evt: the event that fired + * @param params: the parameters passed alongisde the event + */ + launchMenu (evt, params) { + const menuTemplate = [] + + // Spelling suggestions + if (params.isEditable && params.misspelledWord && this.spellchecker && this.spellchecker.hasSpellchecker) { + const suggestions = this.spellchecker.suggestions(params.misspelledWord) + if (suggestions.primary && suggestions.secondary) { + menuTemplate.push({ + label: (dictInfo[suggestions.primary.language] || {}).name || suggestions.primary.language, + submenu: this._renderSuggestionMenuItems_(suggestions.primary.suggestions) + }) + menuTemplate.push({ + label: (dictInfo[suggestions.secondary.language] || {}).name || suggestions.secondary.language, + submenu: this._renderSuggestionMenuItems_(suggestions.secondary.suggestions) + }) + } else { + const suggList = (suggestions.primary.suggestions || suggestions.secondary.suggestions || []) + this._renderSuggestionMenuItems_(suggList).forEach((item) => menuTemplate.push(item)) + } + menuTemplate.push({ type: 'separator' }) + } + + // URLS + if (params.linkURL) { + menuTemplate.push({ + label: 'Open Link', + click: () => { shell.openExternal(params.linkURL) } + }) + if (process.platform === 'darwin') { + menuTemplate.push({ + label: 'Open Link in Background', + click: () => { shell.openExternal(params.linkURL, { activate: false }) } + }) + } + menuTemplate.push({ + label: 'Copy link Address', + click: () => { clipboard.writeText(params.linkURL) } + }) + menuTemplate.push({ type: 'separator' }) + } + + // Editing + const { + canUndo, + canRedo, + canCut, + canCopy, + canPaste, + canSelectAll + } = params.editFlags + + // Undo / redo + if (canUndo || canRedo) { + menuTemplate.push({ label: 'Undo', role: 'undo', enabled: canUndo }) + menuTemplate.push({ label: 'Redo', role: 'redo', enabled: canRedo }) + menuTemplate.push({ type: 'separator' }) + } + + // Text editing + const textEditingMenu = [ + canCut ? { label: 'Cut', role: 'cut' } : null, + canCopy ? { label: 'Copy', role: 'copy' } : null, + canPaste ? { label: 'Paste', role: 'paste' } : null, + canPaste ? { label: 'Paste and match style', role: 'pasteandmatchstyle' } : null, + canSelectAll ? { label: 'Select all', role: 'selectall' } : null + ].filter((item) => item !== null) + if (textEditingMenu.length) { + textEditingMenu.forEach((item) => menuTemplate.push(item)) + menuTemplate.push({ type: 'separator' }) + } + + // WMail + menuTemplate.push({ + label: 'WMail Settings', + click: () => { ipcRenderer.sendToHost({ type: 'open-settings' }) } + }) + menuTemplate.push({ + label: 'Inspect', + click: () => { webContents.inspectElement(params.x, params.y) } + }) + const menu = Menu.buildFromTemplate(menuTemplate) + menu.popup(remote.getCurrentWindow()) + } +} + +module.exports = ContextMenu diff --git a/src/scenes/platform/src/webviewInjection/Browser/DictionaryLoad.js b/src/scenes/platform/src/webviewInjection/Browser/DictionaryLoad.js new file mode 100644 index 00000000..2a634fa2 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Browser/DictionaryLoad.js @@ -0,0 +1,92 @@ +const fs = require('fs') +const path = require('path') +const LanguageSettings = require('../../../../app/shared/Models/Settings/LanguageSettings') +const enUS = require('../../../../app/node_modules/dictionary-en-us') +const pkg = require('../../../../app/package.json') +const AppDirectory = require('../../../../app/node_modules/appdirectory') + +const appDirectory = new AppDirectory(pkg.name).userData() +const userDictionariesPath = LanguageSettings.userDictionariesPath(appDirectory) + +class DictionaryLoad { + /* **************************************************************************/ + // Loader Utils + /* **************************************************************************/ + + /** + * Loads a custom dictionary from disk + * @param language: the language to load + * @return promise + */ + static _loadCustomDictionary_ (language) { + return new Promise((resolve, reject) => { + const tasks = [ + { path: path.join(userDictionariesPath, language + '.aff'), type: 'aff' }, + { path: path.join(userDictionariesPath, language + '.dic'), type: 'dic' } + ].map((desc) => { + return new Promise((resolve, reject) => { + fs.readFile(desc.path, (err, data) => { + err ? reject(Object.assign({ error: err }, desc)) : resolve(Object.assign({ data: data }, desc)) + }) + }) + }) + + Promise.all(tasks) + .then((loaded) => { + const loadObj = loaded.reduce((acc, load) => { + acc[load.type] = load.data + return acc + }, {}) + resolve(loadObj) + }, (err) => { + reject(err) + }) + }) + } + + /** + * Loads an inbuilt language + * @param language: the language to load + * @return promise + */ + static _loadInbuiltDictionary_ (language) { + if (language === 'en_US') { + return new Promise((resolve, reject) => { + enUS((err, load) => { + if (err) { + reject(err) + } else { + resolve({ aff: load.aff, dic: load.dic }) + } + }) + }) + } else { + return Promise.reject(new Error('Unknown Dictionary')) + } + } + + /* **************************************************************************/ + // Loaders + /* **************************************************************************/ + + /** + * Loads a dictionary + * @param language: the language to load + * @return promise + */ + static load (language) { + return new Promise((resolve, reject) => { + DictionaryLoad._loadInbuiltDictionary_(language).then( + (dic) => resolve(dic), + (_err) => { + DictionaryLoad._loadCustomDictionary_(language).then( + (dic) => resolve(dic), + (_err) => reject(new Error('Unknown Dictionary')) + ) + } + ) + }) + } +} + +module.exports = DictionaryLoad diff --git a/src/scenes/platform/src/webviewInjection/Browser/KeyboardNavigator.js b/src/scenes/platform/src/webviewInjection/Browser/KeyboardNavigator.js new file mode 100644 index 00000000..b02fa2ac --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Browser/KeyboardNavigator.js @@ -0,0 +1,30 @@ +const injector = require('../injector') + +class KeyboardNavigator { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + injector.injectBodyEvent('keydown', this._handleKeydown_) + } + + /* **************************************************************************/ + // Event handlers + /* **************************************************************************/ + + _handleKeydown_ (evt) { + if (evt.keyCode === 8) { // Backspace + // Look for reasons to cancel + if (evt.target.tagName === 'INPUT') { return } + if (evt.target.tagName === 'TEXTAREA') { return } + if (evt.target.tagName === 'SELECT') { return } + if (evt.target.tagName === 'OPTION') { return } + if (evt.path.findIndex((e) => e.getAttribute && e.getAttribute('contentEditable') === 'true') !== -1) { return } + + window.history.back() + } + } +} + +module.exports = KeyboardNavigator diff --git a/src/scenes/platform/src/webviewInjection/Browser/Spellchecker.js b/src/scenes/platform/src/webviewInjection/Browser/Spellchecker.js new file mode 100644 index 00000000..29457d55 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Browser/Spellchecker.js @@ -0,0 +1,146 @@ +const { webFrame, ipcRenderer } = require('electron') +const DictionaryLoad = require('./DictionaryLoad') +const dictionaryExcludes = require('../../../../app/shared/dictionaryExcludes') +const elconsole = require('../elconsole') + +let Nodehun +try { + Nodehun = require('../../../../app/node_modules/wmail-spellchecker') +} catch (ex) { + elconsole.error('Failed to initialize spellchecker', ex) + throw ex +} + +class Spellchecker { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this._spellcheckers_ = { + primary: { nodehun: null, language: null }, + secondary: { nodehun: null, language: null } + } + + ipcRenderer.on('start-spellcheck', (evt, data) => { + this.updateSpellchecker(data.language, data.secondaryLanguage) + }) + } + + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + + get hasPrimarySpellchecker () { return this._spellcheckers_.primary.nodehun !== null } + get hasSecondarySpellchecker () { return this._spellcheckers_.secondary.nodehun !== null } + get hasSpellchecker () { return this.hasPrimarySpellchecker || this.hasSecondarySpellchecker } + + /* **************************************************************************/ + // Checking & Suggestions + /* **************************************************************************/ + + /** + * Checks if a word is spelt correctly in one spellchecker + * @param spellchecker: the reference to the spellchecker + * @param text: the word to check + * @return true if the work is correct + */ + checkSpellcheckerWord (spellchecker, text) { + if (spellchecker.language) { + if (dictionaryExcludes[spellchecker.language] && dictionaryExcludes[spellchecker.language].has(text)) { + return true + } else { + return spellchecker.nodehun.isCorrectSync(text) + } + } else { + return true + } + } + + /** + * Checks if a word is spelt correctly + * @param text: the word to check + * @return true if the work is correct + */ + checkWord (text) { + if (this.hasPrimarySpellchecker && this.hasSecondarySpellchecker) { + return this.checkSpellcheckerWord(this._spellcheckers_.primary, text) || + this.checkSpellcheckerWord(this._spellcheckers_.secondary, text) + } else if (this.hasPrimarySpellchecker) { + return this.checkSpellcheckerWord(this._spellcheckers_.primary, text) + } else if (this.hasSecondarySpellchecker) { + return this.checkSpellcheckerWord(this._spellcheckers_.secondary, text) + } else { + return true + } + } + + /** + * Gets a list of spelling suggestions + * @param text: the text to get suggestions for + * @return a list of words + */ + suggestions (text) { + return { + primary: this.hasPrimarySpellchecker ? { + language: this._spellcheckers_.primary.language, + suggestions: this._spellcheckers_.primary.nodehun.spellSuggestionsSync(text) + } : null, + secondary: this.hasSecondarySpellchecker ? { + language: this._spellcheckers_.secondary.language, + suggestions: this._spellcheckers_.secondary.nodehun.spellSuggestionsSync(text) + } : null + } + } + + /* **************************************************************************/ + // Updating spellchecker + /* **************************************************************************/ + + /** + * Updates the provider by giving the languages as the primary language + */ + updateProvider () { + const language = this._spellcheckers_.primary.language || window.navigator.language + webFrame.setSpellCheckProvider(language, true, { + spellCheck: (text) => { return this.checkWord(text) } + }) + } + + /** + * Updates the spellchecker with the correct language + * @param primaryLanguage: the language to change the spellcheck to + * @param secondaryLanguage: the secondary language to change the spellcheck to + */ + updateSpellchecker (primaryLanguage, secondaryLanguage) { + if (!Nodehun) { return } + + if (this._spellcheckers_.primary.language !== primaryLanguage) { + if (!primaryLanguage) { + this._spellcheckers_.primary.language = null + this._spellcheckers_.primary.nodehun = undefined + } else { + this._spellcheckers_.primary.language = primaryLanguage + DictionaryLoad.load(primaryLanguage).then((dic) => { + this._spellcheckers_.primary.nodehun = new Nodehun(dic.aff, dic.dic) + this.updateProvider() + }, (err) => elconsole.error('Failed to load dictionary', err)) + } + } + + if (this._spellcheckers_.secondary.language !== secondaryLanguage) { + if (!secondaryLanguage) { + this._spellcheckers_.secondary.language = null + this._spellcheckers_.secondary.nodehun = undefined + } else { + this._spellcheckers_.secondary.language = secondaryLanguage + DictionaryLoad.load(secondaryLanguage).then((dic) => { + this._spellcheckers_.secondary.nodehun = new Nodehun(dic.aff, dic.dic) + }, (err) => elconsole.error('Failed to load dictionary', err)) + } + } + } +} + +module.exports = Spellchecker diff --git a/src/scenes/platform/src/webviewInjection/Google/GinboxApi.js b/src/scenes/platform/src/webviewInjection/Google/GinboxApi.js new file mode 100644 index 00000000..9d82f98c --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GinboxApi.js @@ -0,0 +1,97 @@ +const escapeHTML = require('../../../../app/node_modules/escape-html') + +class GinboxApi { + + /** + * @return true if the API is ready + */ + static isReady () { return document.readyState === 'complete' } + + /** + * Gets the visible unread count. Ensures that clusters are only counted once/ + * May throw a dom exception if things go wrong + * @return the unread count + */ + static getVisibleUnreadCount () { + const unread = Array.from(document.querySelectorAll('[data-item-id] [email]')).reduce((acc, elm) => { + const isUnread = elm.tagName !== 'IMG' && window.getComputedStyle(elm).fontWeight === 'bold' + if (isUnread) { + const clusterElm = elm.closest('[data-item-id^="#clusters"]') + if (clusterElm) { + acc.openClusters.add(clusterElm) + } else { + acc.messages.add(elm) + } + } + return acc + }, { messages: new Set(), openClusters: new Set() }) + return unread.messages.size + unread.openClusters.size + } + + /** + * Checks if the inbox tab is visble + * May throw a dom exception if things go wrong + * @return true or false + */ + static isInboxTabVisible () { + const elm = document.querySelector('nav [role="menuitem"]') // The first item + return window.getComputedStyle(elm).backgroundColor.substr(-4) !== ', 0)' + } + + /** + * Checks if the pinned setting is toggled + * May throw a dom exception if things go wrong + * @return true or false + */ + static isInboxPinnedToggled () { + const elm = document.querySelector('[jsaction="global.toggle_pinned"]') + return elm ? elm.getAttribute('aria-pressed') === 'true' : false + } + + /** + * Handles opening the compose ui and prefills relevant items + * @param data: the data that was sent with the event + */ + static composeMessage (data) { + const composeButton = document.querySelector('button.y.hC') || document.querySelector('[jsaction="jsl._"]') + if (!composeButton) { return } + composeButton.click() + + setTimeout(() => { + // Grab elements + const bodyEl = document.querySelector('[g_editable="true"][role="textbox"]') + if (!bodyEl) { return } + const dialogEl = bodyEl.closest('[role="dialog"]') + if (!dialogEl) { return } + const recipientEl = dialogEl.querySelector('input') // first input + const subjectEl = dialogEl.querySelector('[jsaction*="subject"]') + let focusableEl + + // Recipient + if (data.recipient && recipientEl) { + recipientEl.value = escapeHTML(data.recipient) + focusableEl = subjectEl + } + + // Subject + if (data.subject && subjectEl) { + subjectEl.value = escapeHTML(data.subject) + focusableEl = bodyEl + } + + // Body + if (data.body && bodyEl) { + bodyEl.innerHTML = escapeHTML(data.body) + bodyEl.innerHTML + const labelEl = bodyEl.parentElement.querySelector('label') + if (labelEl) { labelEl.style.display = 'none' } + focusableEl = bodyEl + } + + if (focusableEl) { + setTimeout(() => focusableEl.focus(), 500) + } + }) + } +} + +module.exports = GinboxApi diff --git a/src/scenes/platform/src/webviewInjection/Google/GinboxChangeEmitter.js b/src/scenes/platform/src/webviewInjection/Google/GinboxChangeEmitter.js new file mode 100644 index 00000000..ca0ffe90 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GinboxChangeEmitter.js @@ -0,0 +1,71 @@ +const {ipcRenderer} = require('electron') +const injector = require('../injector') +const GinboxApi = require('./GinboxApi') +const MAX_MESSAGE_HASH_TIME = 1000 * 60 * 10 // 10 mins + +class GinboxChangeEmitter { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.state = { + messageHash: this.currentMessageHash, + messageHashTime: new Date().getTime() + } + + this.latestMessageInterval = setInterval(this.recheckMessageHash.bind(this), 2000) + this.clickThrottle = null + injector.injectBodyEvent('click', this.handleBodyClick) + } + + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + + get currentMessageHash () { + const topItem = document.querySelector('[data-item-id]') + const topHash = topItem ? topItem.getAttribute('data-item-id') : '_' + const unreadCount = GinboxApi.getVisibleUnreadCount() + return unreadCount + ':' + topHash + } + + /* **************************************************************************/ + // Event Handlers + /* **************************************************************************/ + + /** + * Re-checks the top message id and fires an unread-count-changed event when + * changed + */ + recheckMessageHash () { + const now = new Date().getTime() + const nextMessageHash = this.currentMessageHash + if (this.state.messageHash !== nextMessageHash || now - this.state.messageHashTime > MAX_MESSAGE_HASH_TIME) { + this.state.messageHash = nextMessageHash + this.state.messageHashTime = now + clearTimeout(this.clickThrottle) + ipcRenderer.sendToHost({ + type: 'unread-count-changed', + data: { trigger: 'periodic-diff' } + }) + } + } + + /** + * Adds a click event into the body which fires off an unread-count-changed + * event rather lazily to catch the message hash not working correctly + */ + handleBodyClick () { + clearTimeout(this.clickThrottle) + this.clickThrottle = setTimeout(() => { + ipcRenderer.sendToHost({ + type: 'unread-count-changed', + data: { trigger: 'delayed-click' } + }) + }, 10000) + } +} + +module.exports = GinboxChangeEmitter diff --git a/src/scenes/platform/src/webviewInjection/Google/GmailApiExtras.js b/src/scenes/platform/src/webviewInjection/Google/GmailApiExtras.js new file mode 100644 index 00000000..fdedbbc7 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GmailApiExtras.js @@ -0,0 +1,51 @@ +const escapeHTML = require('../../../../app/node_modules/escape-html') + +class GmailApiExtras { + /** + * Handles opening the compose ui and prefills relevant items + * @param gmailApi: the gmail api + * @param data: the data that was sent with the event + */ + static composeMessage (gmailApi, data) { + if (!gmailApi) { return } + + gmailApi.compose.start_compose() + + if (data.recipient || data.subject || data.body) { + setTimeout(() => { + // Grab elements + const subjectEl = Array.from(document.querySelectorAll('[name="subjectbox"]')).slice(-1)[0] + if (!subjectEl) { return } + const dialogEl = subjectEl.closest('[role="dialog"]') + if (!dialogEl) { return } + const bodyEl = dialogEl.querySelector('[g_editable="true"][role="textbox"]') + const recipientEl = dialogEl.querySelector('[name="to"]') + let focusableEl + + // Recipient + if (data.recipient && recipientEl) { + recipientEl.value = escapeHTML(data.recipient) + focusableEl = subjectEl + } + + // Subject + if (data.subject && subjectEl) { + subjectEl.value = escapeHTML(data.subject) + focusableEl = bodyEl + } + + // Body + if (data.body && bodyEl) { + bodyEl.innerHTML = escapeHTML(data.body) + bodyEl.innerHTML + focusableEl = bodyEl + } + + if (focusableEl) { + setTimeout(() => focusableEl.focus(), 500) + } + }) + } + } +} + +module.exports = GmailApiExtras diff --git a/src/scenes/platform/src/webviewInjection/Google/GmailChangeEmitter.js b/src/scenes/platform/src/webviewInjection/Google/GmailChangeEmitter.js new file mode 100644 index 00000000..ba5b0f13 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GmailChangeEmitter.js @@ -0,0 +1,45 @@ +const {ipcRenderer} = require('electron') +const MAX_MESSAGE_HASH_TIME = 1000 * 60 * 10 // 10 mins + +class GmailChangeEmitter { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + /** + * @param gmailApi: the gmail api instance + */ + constructor (gmailApi) { + this.gmailApi = gmailApi + this.state = { + count: this.gmailApi.get.unread_inbox_emails(), + countTime: new Date().getTime() + } + + this.gmailApi.observe.on('http_event', this.handleHTTPEvent.bind(this)) + } + + /* **************************************************************************/ + // Event Handlers + /* **************************************************************************/ + + /** + * Handles gmail firing a http event by checking if the unread count has changed + * and passing this event up across the bridge + */ + handleHTTPEvent () { + const now = new Date().getTime() + const nextCount = this.gmailApi.get.unread_inbox_emails() + if (this.state.count !== nextCount || now - this.state.messageHashTime > MAX_MESSAGE_HASH_TIME) { + this.state.count = nextCount + this.state.countTime = now + ipcRenderer.sendToHost({ + type: 'unread-count-changed', + data: { trigger: 'http-event' } + }) + } + } +} + +module.exports = GmailChangeEmitter diff --git a/src/scenes/platform/src/webviewInjection/Google/GoogleMail.js b/src/scenes/platform/src/webviewInjection/Google/GoogleMail.js new file mode 100644 index 00000000..7e103201 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GoogleMail.js @@ -0,0 +1,178 @@ +const injector = require('../injector') +const {ipcRenderer} = require('electron') +const GoogleWindowOpen = require('./GoogleWindowOpen') +const path = require('path') +const fs = require('fs') +const GmailChangeEmitter = require('./GmailChangeEmitter') +const GinboxChangeEmitter = require('./GinboxChangeEmitter') +const GinboxApi = require('./GinboxApi') +const GmailApiExtras = require('./GmailApiExtras') +const elconsole = require('../elconsole') +const GoogleService = require('./GoogleService') + +class GoogleMail extends GoogleService { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + super() + this.googleWindowOpen = new GoogleWindowOpen() + + this.sidebarStylesheet = document.createElement('style') + this.sidebarStylesheet.innerHTML = ` + [href="#inbox"][data-ved]>* { + max-height:33px !important; + margin-top: 22px; + background-position-x: center; + } + [jsaction="global.toggle_main_menu"] { + margin-top: 5px; + } + [jsaction="global.toggle_main_menu"] ~ [data-action-data] { + margin-top: 21px; + } + ` + + // Inject some styles + injector.injectStyle(` + a[href*="/SignOutOptions"] { + visibility: hidden !important; + } + `) + + // Bind our listeners + ipcRenderer.on('window-icons-in-screen', this.handleWindowIconsInScreenChange.bind(this)) + ipcRenderer.on('open-message', this.handleOpenMesage.bind(this)) + ipcRenderer.on('get-google-unread-count', this.handleFetchUnreadCount.bind(this)) + + if (this.isGmail) { + this.loadGmailAPI() + ipcRenderer.on('compose-message', this.handleComposeMessageGmail.bind(this)) + } + if (this.isGinbox) { + this.loadInboxAPI() + ipcRenderer.on('compose-message', this.handleComposeMessageGinbox.bind(this)) + } + } + + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + + get isGmail () { return window.location.host.indexOf('mail.google') !== -1 } + get isGinbox () { return window.location.host.indexOf('inbox.google') !== -1 } + + /* **************************************************************************/ + // Loaders + /* **************************************************************************/ + + /** + * Loads the GMail API + */ + loadGmailAPI () { + this.gmailApi = undefined + + const jqueryPath = path.join(__dirname, '../../../../app/node_modules/jquery/dist/jquery.min.js') + const apiPath = path.join(__dirname, '../../../../app/node_modules/gmail-js/src/gmail.js') + + injector.injectJavaScript(fs.readFileSync(jqueryPath, 'utf8')) + injector.injectJavaScript(fs.readFileSync(apiPath, 'utf8'), () => { + const unloadedApi = new window.Gmail() + unloadedApi.observe.on('load', () => { + this.gmailApi = unloadedApi + this.googleWindowOpen.gmailApi = unloadedApi + this.changeEmitter = new GmailChangeEmitter(unloadedApi) + }) + }) + } + + /** + * Loads the inbox API + */ + loadInboxAPI () { + this.changeEmitter = new GinboxChangeEmitter() + } + + /* **************************************************************************/ + // Event handlers + /* **************************************************************************/ + + /** + * Handles the window icons in the screen chaning + * @param evt: the event that fired + * @param data: the data sent with the event + */ + handleWindowIconsInScreenChange (evt, data) { + if (data.inscreen) { + if (!this.sidebarStylesheet.parentElement) { + document.head.appendChild(this.sidebarStylesheet) + } + } else { + if (this.sidebarStylesheet.parentElement) { + this.sidebarStylesheet.parentElement.removeChild(this.sidebarStylesheet) + } + } + } + + /** + * Handles a message open call + * @param evt: the event that fired + * @param data: the data sent with the event + */ + handleOpenMesage (evt, data) { + if (this.isGmail) { + window.location.hash = 'inbox/' + data.messageId + } + } + + /** + * Handles fetching the unread count out the dom + * @param evt: the event that fired + * @param data: the data that was sent with the event + */ + handleFetchUnreadCount (evt, data) { + if (this.isGmail) { + const info = { + available: this.gmailApi !== undefined, + count: this.gmailApi ? this.gmailApi.get.unread_inbox_emails() : undefined + } + ipcRenderer.sendToHost({ type: data.__respond__, data: info }) + } + if (this.isGinbox) { + const info = { available: false, count: undefined } + if (GinboxApi.isReady()) { + try { + if (GinboxApi.isInboxTabVisible() && !GinboxApi.isInboxPinnedToggled()) { + info.count = GinboxApi.getVisibleUnreadCount() + info.available = true + } + } catch (ex) { + elconsole.error('Failed to read Google Inbox Unread count from Dom', ex) + } + } + ipcRenderer.sendToHost({ type: data.__respond__, data: info }) + } + } + + /** + * Handles opening the compose ui and prefills relevant items + * @param evt: the event that fired + * @param data: the data that was sent with the event + */ + handleComposeMessageGmail (evt, data) { + GmailApiExtras.composeMessage(this.gmailApi, data) + } + + /** + * Handles opening the compose ui and prefills relevant items + * @param evt: the event that fired + * @param data: the data that was sent with the event + */ + handleComposeMessageGinbox (evt, data) { + GinboxApi.composeMessage(data) + } +} + +module.exports = GoogleMail diff --git a/src/scenes/platform/src/webviewInjection/Google/GoogleService.js b/src/scenes/platform/src/webviewInjection/Google/GoogleService.js new file mode 100644 index 00000000..d46af04d --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GoogleService.js @@ -0,0 +1,23 @@ +const injector = require('../injector') +const Browser = require('../Browser/Browser') +const WMail = require('../WMail/WMail') + +class GoogleService { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.browser = new Browser() + this.wmail = new WMail() + + injector.injectStyle(` + a[href*="/SignOutOptions"] { + visibility: hidden !important; + } + `) + } +} + +module.exports = GoogleService diff --git a/src/scenes/platform/src/webviewInjection/Google/GoogleWindowOpen.js b/src/scenes/platform/src/webviewInjection/Google/GoogleWindowOpen.js new file mode 100644 index 00000000..9931cf92 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/Google/GoogleWindowOpen.js @@ -0,0 +1,86 @@ +const ipcRenderer = require('electron').ipcRenderer + +class GoogleWindowOpen { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.gmailApi = undefined + + // Inject into the main window + this.injectWindow(window) + document.addEventListener('DOMContentLoaded', function () { + const frames = document.querySelectorAll('iframe') + for (let i = 0; i < frames.length; i++) { + try { + this.injectWindow(frames[i].contentWindow) + } catch (ex) { } + } + }, false) + } + + /* **************************************************************************/ + // Utils + /* **************************************************************************/ + + /** + * @param url: the url to get the qs argument from + * @param key: the key of the value to get + * @param defaultValue=undefined: the default value to return + * @return the string value + */ + getQSArg (url, key, defaultValue = undefined) { + const regex = new RegExp('[\\?&]' + key + '=([^&#]*)') + const results = regex.exec(url) + return results === null ? defaultValue : results[1] + } + + /* **************************************************************************/ + // Injection + /* **************************************************************************/ + + /** + * Injects the window.open polyfill. Gmail opens new windows. They do.... + * w = window.open("", "_blank", "") + * w.document.write('') + * this proxies that request and sends it up to be opened + * @param w: the window to inject into + */ + injectWindow (w) { + const defaultfn = w.open + const handlerInst = this + w.open = function () { + // Open message in new window -- old style + if (arguments[0] === '' && arguments[1] === '_blank') { + return { + document: { + write: function (value) { + const parser = new window.DOMParser() + const xml = parser.parseFromString(value, 'text/xml') + if (xml.firstChild && xml.firstChild.getAttribute('HTTP-EQUIV') === 'refresh') { + const content = xml.firstChild.getAttribute('content') + const url = content.replace('0; url=', '') + ipcRenderer.sendToHost({ type: 'js-new-window', url: url }) + } + } + } + } + } else if (handlerInst.gmailApi && arguments[0].indexOf('ui=2') !== -1 && arguments[0].indexOf('view=btop') !== -1) { + const ik = handlerInst.gmailApi.tracker.ik + const currentUrlMsgId = window.location.hash.split('/').pop().replace(/#/, '').split('?')[0] + const th = handlerInst.getQSArg(arguments[0], 'th', currentUrlMsgId) + + ipcRenderer.sendToHost({ + type: 'js-new-window', + url: 'https://mail.google.com/mail?ui=2&view=lg&ik=' + ik + '&msg=' + th + }) + return { closed: false, focus: function () { } } + } else { + return defaultfn.apply(this, Array.from(arguments)) + } + } + } +} + +module.exports = GoogleWindowOpen diff --git a/src/scenes/platform/src/webviewInjection/WMail/CustomCode.js b/src/scenes/platform/src/webviewInjection/WMail/CustomCode.js new file mode 100644 index 00000000..3d60808e --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/WMail/CustomCode.js @@ -0,0 +1,19 @@ +const ipcRenderer = require('electron').ipcRenderer +const injector = require('../injector') + +class CustomCode { + constructor () { + ipcRenderer.on('inject-custom-content', this._handleInject_.bind(this)) + } + + _handleInject_ (evt, data) { + if (data.js) { + injector.injectJavaScript(data.js) + } + if (data.css) { + injector.injectStyle(data.css) + } + } +} + +module.exports = CustomCode diff --git a/src/scenes/platform/src/webviewInjection/WMail/WMail.js b/src/scenes/platform/src/webviewInjection/WMail/WMail.js new file mode 100644 index 00000000..3f2ce581 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/WMail/WMail.js @@ -0,0 +1,9 @@ +const CustomCode = require('./CustomCode') + +class WMail { + constructor () { + this.customCode = new CustomCode() + } +} + +module.exports = WMail diff --git a/src/scenes/platform/src/webviewInjection/clickReport.js b/src/scenes/platform/src/webviewInjection/clickReport.js deleted file mode 100644 index a8d7b862..00000000 --- a/src/scenes/platform/src/webviewInjection/clickReport.js +++ /dev/null @@ -1,27 +0,0 @@ -;(function () { - 'use strict' - - const ipcRenderer = require('electron').ipcRenderer - let throttle = null - let throttleCount = 1 - const loader = setInterval(function () { - if (document.body) { - clearInterval(loader) - document.body.addEventListener('click', function (evt) { - if (throttle !== null) { - clearTimeout(throttle) - throttle = null - throttleCount += 0.5 - } - throttle = setTimeout(function () { - ipcRenderer.sendToHost({ - type: 'page-click', - throttled: true, - throttle: 1500 / throttleCount - }) - throttleCount = 1 - }, 1500 / throttleCount) - }, false) - } - }, 500) -})() diff --git a/src/scenes/platform/src/webviewInjection/contextMenu.js b/src/scenes/platform/src/webviewInjection/contextMenu.js deleted file mode 100644 index fdde088e..00000000 --- a/src/scenes/platform/src/webviewInjection/contextMenu.js +++ /dev/null @@ -1,99 +0,0 @@ -module.exports = function (spellChecker) { - 'use strict' - - const electron = require('electron') - const remote = electron.remote - const Menu = remote.Menu - const shell = remote.shell - - const textOnlyRE = new RegExp(/[^a-z]+/gi) - - /** - * @param evt: the event that triggered - * @return true if the target is in a text editor - */ - const isTexteditorTarget = function (evt) { - if (evt.target.tagName === 'INPUT' || evt.target.tagName === 'TEXTAREA') { return true } - if (evt.path.findIndex((e) => e.getAttribute && e.getAttribute('contentEditable') === 'true') !== -1) { return true } - return false - } - - /** - * @param evt: the event that triggered - * @return the url if the event is in a link false otherwise - */ - const isLinkTarget = function (evt) { - if (evt.target.tagName === 'A') { return evt.target.getAttribute('href') } - const parentLink = evt.path.find((e) => e.tagName === 'A') - if (parentLink) { - return parentLink.getAttribute('href') - } - return false - } - - document.addEventListener('contextmenu', (evt) => { - const x = evt.clientX - const y = evt.clientY - const selection = window.getSelection() - const textSelection = selection.toString().trim() - const menu = [] - - // Spell check suggestions - if (spellChecker) { - if (isTexteditorTarget(evt)) { - if (textOnlyRE.exec(textSelection) === null) { - if (!spellChecker.isCorrectSync(textSelection)) { - const suggestions = spellChecker.spellSuggestionsSync(textSelection) - if (suggestions.length) { - suggestions.forEach((s) => { - menu.push({ - label: s, - click: () => { - const range = selection.getRangeAt(0) - range.deleteContents() - range.insertNode(document.createTextNode(s)) - } - }) - }) - } else { - menu.push({ label: 'No Spelling Suggestions', enabled: false }) - } - menu.push({ type: 'separator' }) - } - } - } - } - - // Link - const linkTarget = isLinkTarget(evt) - if (linkTarget && linkTarget.indexOf('://') !== -1) { - menu.push({ label: 'Open Link', click: () => { - shell.openExternal(linkTarget) - }}) - menu.push({ label: 'Open Link in Background', click: () => { - shell.openExternal(linkTarget, { activate: false }) - }}) - menu.push({ type: 'separator' }) - } - - // Undo / redo - menu.push({ label: 'Undo', role: 'undo' }) - menu.push({ label: 'Redo', role: 'redo' }) - menu.push({ type: 'separator' }) - - // Text editor / text selection - if (isTexteditorTarget(evt)) { - menu.push({ label: 'Cut', role: 'cut' }) - menu.push({ label: 'Copy', role: 'copy' }) - menu.push({ label: 'Paste', role: 'paste' }) - menu.push({ type: 'separator' }) - } else if (textSelection) { - menu.push({ label: 'Copy', role: 'copy' }) - menu.push({ type: 'separator' }) - } - - // Misc - menu.push({ label: 'Select all', role: 'selectall' }) - Menu.buildFromTemplate(menu).popup(remote.getCurrentWindow(), x, y) - }, false) -} diff --git a/src/scenes/platform/src/webviewInjection/elconsole.js b/src/scenes/platform/src/webviewInjection/elconsole.js new file mode 100644 index 00000000..40a07964 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/elconsole.js @@ -0,0 +1,27 @@ +const ipcRenderer = require('electron').ipcRenderer + +class ELConsole { + /** + * Logs the supplied arguments and also logs them to the parent frame + */ + log () { + ipcRenderer.sendToHost({ + type: 'elevated-log', + messages: Array.from(arguments) + }) + console.log.apply(this, Array.from(arguments)) + } + + /** + * Logs the supplied arguments as errors and also logs them to the parent frame + */ + error () { + ipcRenderer.sendToHost({ + type: 'elevated-error', + messages: Array.from(arguments) + }) + console.error.apply(this, Array.from(arguments)) + } +} + +module.exports = new ELConsole() diff --git a/src/scenes/platform/src/webviewInjection/google.js b/src/scenes/platform/src/webviewInjection/google.js deleted file mode 100644 index cc764ee0..00000000 --- a/src/scenes/platform/src/webviewInjection/google.js +++ /dev/null @@ -1,45 +0,0 @@ -;(function () { - 'use strict' - - require('./keyboardNavigation') - require('./clickReport') - require('./zoomLevel') - require('./googleWindowOpen') - - const ipc = require('electron').ipcRenderer - const enUS = require('../../../app/node_modules/dictionary-en-us') - const Spellchecker = require('../../../app/node_modules/nodehun') - - enUS((err, result) => { - if (!err) { - const spellchecker = new Spellchecker(result.aff, result.dic) - require('./spellchecker')(spellchecker) - require('./contextMenu')(spellchecker) - } else { - require('./contextMenu')(null) - } - }) - - // Inject some styles - ;(() => { - const stylesheet = document.createElement('style') - stylesheet.innerHTML = ` - a[href*="/SignOutOptions"] { - visibility: hidden !important; - } - ` - const interval = setInterval(() => { - if (document.head) { - document.head.appendChild(stylesheet) - clearInterval(interval) - } - }, 500) - })() - - // Listen for open message - ipc.on('open-message', (evt, data) => { - if (window.location.host.indexOf('mail.google') !== -1) { - window.location.hash = 'inbox/' + data.messageId - } - }) -})() diff --git a/src/scenes/platform/src/webviewInjection/googleMail.js b/src/scenes/platform/src/webviewInjection/googleMail.js new file mode 100644 index 00000000..a60399e2 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/googleMail.js @@ -0,0 +1,9 @@ +const elconsole = require('./elconsole') +try { + const GoogleMail = require('./Google/GoogleMail') + /*eslint-disable */ + const googleMail = new GoogleMail() + /*eslint-enable */ +} catch (ex) { + elconsole.error('Error', ex) +} diff --git a/src/scenes/platform/src/webviewInjection/googleService.js b/src/scenes/platform/src/webviewInjection/googleService.js new file mode 100644 index 00000000..77aa0bc3 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/googleService.js @@ -0,0 +1,9 @@ +const elconsole = require('./elconsole') +try { + const GoogleService = require('./Google/GoogleService') + /*eslint-disable */ + const googleService = new GoogleService() + /*eslint-enable */ +} catch (ex) { + elconsole.error('Error', ex) +} diff --git a/src/scenes/platform/src/webviewInjection/googleWindowOpen.js b/src/scenes/platform/src/webviewInjection/googleWindowOpen.js deleted file mode 100644 index 8f863152..00000000 --- a/src/scenes/platform/src/webviewInjection/googleWindowOpen.js +++ /dev/null @@ -1,48 +0,0 @@ -/** -* Interfaces with the way gmail opens new windows. They do.... -* w = window.open("", "_blank", "") -* w.document.write('') -* this proxies that request and sends it up to be opened -*/ -;(function () { - 'use strict' - - const ipcRenderer = require('electron').ipcRenderer - - const injectWindow = function (w) { - const defaultfn = w.open - - w.open = function () { - if (arguments[0] === '' && arguments[1] === '_blank') { - return { - document: { - write: function (value) { - const parser = new window.DOMParser() - const xml = parser.parseFromString(value, 'text/xml') - if (xml.firstChild && xml.firstChild.getAttribute('HTTP-EQUIV') === 'refresh') { - const content = xml.firstChild.getAttribute('content') - const url = content.replace('0; url=', '') - ipcRenderer.sendToHost({ type: 'js-new-window', url: url }) - } - } - } - } - } else { - return defaultfn.apply(this, Array.from(arguments)) - } - } - } - - // Inject into the main window - injectWindow(window) - - // Inject into the subframes. This is used for opening pdfs for example - document.addEventListener('DOMContentLoaded', function () { - const frames = document.querySelectorAll('iframe') - for (let i = 0; i < frames.length; i++) { - try { - injectWindow(frames[i].contentWindow) - } catch (ex) { } - } - }, false) -})() diff --git a/src/scenes/platform/src/webviewInjection/injector.js b/src/scenes/platform/src/webviewInjection/injector.js new file mode 100644 index 00000000..eb0dd6f5 --- /dev/null +++ b/src/scenes/platform/src/webviewInjection/injector.js @@ -0,0 +1,95 @@ +class Injector { + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + constructor () { + this.scripts = { pending: [], interval: null } + this.bodyEvents = { pending: [], interval: null } + } + + /* **************************************************************************/ + // Script injection + /* **************************************************************************/ + + /** + * Injects an element into the dom + * @param el: the element to inject + * @param callback=undefined: executed when injected + */ + injectScriptElement (el, callback) { + if (document.head) { + document.head.appendChild(el) + if (callback) { callback() } + } else { + this.scripts.pending.push([el, callback]) + if (this.scripts.interval === null) { + this.scripts.interval = setInterval(() => { + if (document.head) { + clearInterval(this.scripts.interval) + this.scripts.interval = null + this.scripts.pending.forEach((inf) => { + document.head.appendChild(inf[0]) + if (inf[1]) { inf[1]() } + }) + this.scripts.pending = [] + } + }, 10) + } + } + } + + /** + * Injects javascript into the head + * @param js: the js code to inject + * @param callback=undefined: executed when injected + */ + injectJavaScript (js, callback) { + const el = document.createElement('script') + el.type = 'text/javascript' + el.innerHTML = js + this.injectScriptElement(el, callback) + } + + /** + * Injects a stylesheet into the head + * @param css: the css code to inject + * @param callback=undefined: executed when injected + */ + injectStyle (css, callback) { + const el = document.createElement('style') + el.innerHTML = css + this.injectScriptElement(el, callback) + } + + /* **************************************************************************/ + // Event injection + /* **************************************************************************/ + + /** + * Injects a body event listener + * @param eventName: the name of the event + * @param fn: the function to call + */ + injectBodyEvent (eventName, fn) { + if (document.body) { + document.body.addEventListener(eventName, fn, false) + } else { + this.bodyEvents.pending.push([eventName, fn]) + if (this.bodyEvents.interval === null) { + this.bodyEvents.interval = setInterval(() => { + if (document.body) { + clearInterval(this.bodyEvents.interval) + this.bodyEvents.interval = null + this.bodyEvents.pending.forEach((inf) => { + document.body.addEventListener(inf[0], inf[1], false) + }) + this.bodyEvents.pending = [] + } + }, 100) + } + } + } +} + +module.exports = new Injector() diff --git a/src/scenes/platform/src/webviewInjection/keyboardNavigation.js b/src/scenes/platform/src/webviewInjection/keyboardNavigation.js deleted file mode 100644 index 6b503cc7..00000000 --- a/src/scenes/platform/src/webviewInjection/keyboardNavigation.js +++ /dev/null @@ -1,16 +0,0 @@ -;(function () { - 'use strict' - - document.addEventListener('keydown', function (evt) { - if (evt.keyCode === 8) { // Backspace - // Look for reasons to cancel - if (evt.target.tagName === 'INPUT') { return } - if (evt.target.tagName === 'TEXTAREA') { return } - if (evt.target.tagName === 'SELECT') { return } - if (evt.target.tagName === 'OPTION') { return } - if (evt.path.findIndex((e) => e.getAttribute && e.getAttribute('contentEditable') === 'true') !== -1) { return } - - window.history.back() - } - }, false) -})() diff --git a/src/scenes/platform/src/webviewInjection/spellchecker.js b/src/scenes/platform/src/webviewInjection/spellchecker.js deleted file mode 100644 index 61ce6485..00000000 --- a/src/scenes/platform/src/webviewInjection/spellchecker.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = function (spellchecker) { - 'use strict' - - const electron = require('electron') - const webFrame = electron.webFrame - const ipc = electron.ipcRenderer - - ipc.on('start-spellcheck', (evt, data) => { - if (!data.enabled) { return } - - webFrame.setSpellCheckProvider('en-us', true, { - spellCheck: (text) => { - return spellchecker.isCorrectSync(text) - } - }) - }) -} diff --git a/src/scenes/platform/src/webviewInjection/zoomLevel.js b/src/scenes/platform/src/webviewInjection/zoomLevel.js deleted file mode 100644 index 30e594a9..00000000 --- a/src/scenes/platform/src/webviewInjection/zoomLevel.js +++ /dev/null @@ -1,11 +0,0 @@ -;(function () { - 'use strict' - - const electron = require('electron') - const webFrame = electron.webFrame - const ipc = electron.ipcRenderer - - ipc.on('zoom-factor-set', (evt, data) => { - webFrame.setZoomFactor(data.value) - }) -})() diff --git a/src/shared/Models/Mailbox/Google.js b/src/shared/Models/Mailbox/Google.js index e7e9a561..98edaac5 100644 --- a/src/shared/Models/Mailbox/Google.js +++ b/src/shared/Models/Mailbox/Google.js @@ -1,12 +1,29 @@ const Model = require('../Model') -const { GMAIL_NOTIFICATION_MAX_MESSAGE_AGE_MS } = require('../../constants') +const SERVICES = require('./MailboxServices') +const TYPES = require('./MailboxTypes') const UNREAD_MODES = { INBOX: 'inbox', INBOX_UNREAD: 'inbox_unread', - PRIMARY_INBOX_UNREAD: 'primary_inbox_unread' + PRIMARY_INBOX_UNREAD: 'primary_inbox_unread', + INBOX_UNREAD_IMPORTANT: 'inbox_unread_important', + GINBOX_DEFAULT: 'ginbox_default' } +const SERVICE_URLS = { } +SERVICE_URLS[SERVICES.STORAGE] = 'https://drive.google.com' +SERVICE_URLS[SERVICES.CONTACTS] = 'https://contacts.google.com' +SERVICE_URLS[SERVICES.NOTES] = 'https://keep.google.com' +SERVICE_URLS[SERVICES.CALENDAR] = 'https://calendar.google.com' +SERVICE_URLS[SERVICES.COMMUNICATION] = 'https://hangouts.google.com' + +const SERVICE_NAMES = { } +SERVICE_NAMES[SERVICES.STORAGE] = 'Drive' +SERVICE_NAMES[SERVICES.CONTACTS] = 'Contacts' +SERVICE_NAMES[SERVICES.NOTES] = 'Notes' +SERVICE_NAMES[SERVICES.CALENDAR] = 'Calendar' +SERVICE_NAMES[SERVICES.COMMUNICATION] = 'Hangouts' + class Google extends Model { /* **************************************************************************/ @@ -14,19 +31,31 @@ class Google extends Model { /* **************************************************************************/ static get UNREAD_MODES () { return UNREAD_MODES } + static get SUPPORTED_SERVICES () { return Object.keys(SERVICES).map((k) => SERVICES[k]) } + static get DEFAULT_SERVICES () { return [SERVICES.CALENDAR, SERVICES.STORAGE, SERVICES.NOTES] } + static get SERVICE_URLS () { return SERVICE_URLS } + static get SERVICE_NAMES () { return SERVICE_NAMES } /* **************************************************************************/ // Lifecycle /* **************************************************************************/ - constructor (auth, config, unread) { + constructor (type, auth, config, labelInfo, unreadMessageInfo) { super({ auth: auth || {}, config: config || {}, - unread: unread || {} + labelInfo: labelInfo, + unreadMessages: unreadMessageInfo || {} }) + this.__type__ = type } + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + + get type () { return this.__type__ } + /* **************************************************************************/ // Properties : GoogleAuth /* **************************************************************************/ @@ -36,17 +65,32 @@ class Google extends Model { get accessToken () { return this.__data__.auth.access_token } get refreshToken () { return this.__data__.auth.refresh_token } get authExpiryTime () { return (this.__data__.auth.date || 0) + (this.__data__.auth.expires_in || 0) } + get authHasGrantError () { + return this.__data__.auth.invalidGrant === undefined ? false : this.__data__.auth.invalidGrant + } /* **************************************************************************/ // Properties : Google Config /* **************************************************************************/ - get unreadMode () { return this.__data__.config.unreadMode || UNREAD_MODES.INBOX_UNREAD } + get unreadMode () { + if (this.__data__.config.unreadMode) { + return this.__data__.config.unreadMode + } else { + if (this.type === TYPES.GMAIL) { + return UNREAD_MODES.INBOX_UNREAD + } else if (this.type === TYPES.GINBOX) { + return UNREAD_MODES.GINBOX_DEFAULT + } + } + } get unreadQuery () { switch (this.unreadMode) { case UNREAD_MODES.INBOX: return 'label:inbox' case UNREAD_MODES.INBOX_UNREAD: return 'label:inbox label:unread' case UNREAD_MODES.PRIMARY_INBOX_UNREAD: return 'label:inbox label:unread category:primary' + case UNREAD_MODES.INBOX_UNREAD_IMPORTANT: return 'label:inbox label:unread label:important' + case UNREAD_MODES.GINBOX_DEFAULT: return 'label:inbox label:unread -has:userlabels -category:promotions -category:forums -category:social' } } get unreadLabel () { @@ -54,45 +98,86 @@ class Google extends Model { case UNREAD_MODES.INBOX: return 'INBOX' case UNREAD_MODES.INBOX_UNREAD: return 'INBOX' case UNREAD_MODES.PRIMARY_INBOX_UNREAD: return 'CATEGORY_PERSONAL' // actually primary + case UNREAD_MODES.INBOX_UNREAD_IMPORTANT: return 'IMPORTANT' + case UNREAD_MODES.GINBOX_DEFAULT: return 'INBOX' } } - get unreadLabelField () { + get unreadCountIncludesReadMessages () { switch (this.unreadMode) { - case UNREAD_MODES.INBOX: return 'threadsTotal' - case UNREAD_MODES.INBOX_UNREAD: return 'threadsUnread' - case UNREAD_MODES.PRIMARY_INBOX_UNREAD: return 'threadsUnread' + case UNREAD_MODES.INBOX: return true + case UNREAD_MODES.INBOX_UNREAD: return false + case UNREAD_MODES.PRIMARY_INBOX_UNREAD: return false + case UNREAD_MODES.INBOX_UNREAD_IMPORTANT: return false + case UNREAD_MODES.GINBOX_DEFAULT: return false + } + } + get takeLabelCountFromUI () { + if (this.canChangeTakeLabelCountFromUI) { + if (this.__data__.config.takeLabelCountFromUI === undefined) { + return false + } else { + return this.__data__.config.takeLabelCountFromUI + } + } else { + if (this.type === TYPES.GMAIL) { + if (this.unreadMode === UNREAD_MODES.PRIMARY_INBOX_UNREAD || this.unreadMode === UNREAD_MODES.INBOX_UNREAD_IMPORTANT) { + return true + } + } + } + return false + } + get canChangeTakeLabelCountFromUI () { + if (this.type === TYPES.GMAIL) { + return this.unreadMode === UNREAD_MODES.INBOX || this.unreadMode === UNREAD_MODES.INBOX_UNREAD + } else { + return false } } /* **************************************************************************/ - // Properties : Google Unread + // Properties : Label info /* **************************************************************************/ - get unreadMessages () { - return Object.keys(this.__data__.unread) - .reduce((acc, k) => { - if (this.__data__.unread[k].unread) { - acc[k] = this.__data__.unread[k] - } - return acc - }, {}) + get labelInfo () { return this.__data__.labelInfo || {} } + get messagesTotal () { return this.labelInfo.messagesTotal || 0 } + get messagesUnread () { return this.labelInfo.messagesUnread || 0 } + get threadsTotal () { return this.labelInfo.threadsTotal || 0 } + get threadsUnread () { return this.labelInfo.threadsUnread || 0 } + get unreadCount () { + if (this.unreadMode === UNREAD_MODES.GINBOX_DEFAULT) { + return this.__data__.unreadMessages.resultSizeEstimate || 0 + } else { + return this.unreadCountIncludesReadMessages ? this.threadsTotal : this.threadsUnread + } } - get unreadUnotifiedMessages () { - const unotified = {} - const unread = this.unreadMessages - const now = new Date().getTime() - - for (var k in unread) { - const info = unread[k] - if (info.notified === undefined && info.message) { - const messageDate = new Date(parseInt(info.message.internalDate, 10)).getTime() - if (now - messageDate < GMAIL_NOTIFICATION_MAX_MESSAGE_AGE_MS) { - unotified[k] = info - } + /* **************************************************************************/ + // Properties : Unread Messages + /* **************************************************************************/ + + get latestUnreadThreads () { + return this.__data__.unreadMessages.latestUnreadThreads || [] + } + + get latestUnreadMessages () { + return this.latestUnreadThreads.map((thread) => { + const messages = thread.messages || [] + for (var i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + const wasSent = (message.labelIds || []).findIndex((label) => label === 'SENT') !== -1 + if (!wasSent) { return message } } - } - return unotified + return undefined + }).filter((m) => !!m) + } + get unnotifiedMessages () { + return this.latestUnreadMessages.filter((message) => { + return parseInt(message.internalDate) > this.lastNotifiedInternalDate + }) + } + get lastNotifiedInternalDate () { + return this.__data__.unreadMessages.lastNotifiedInternalDate || 0 } } diff --git a/src/shared/Models/Mailbox/Mailbox.js b/src/shared/Models/Mailbox/Mailbox.js index 852fc62a..2b35ea00 100644 --- a/src/shared/Models/Mailbox/Mailbox.js +++ b/src/shared/Models/Mailbox/Mailbox.js @@ -1,6 +1,8 @@ const Model = require('../Model') const uuid = require('uuid') const Google = require('./Google') +const SERVICES = require('./MailboxServices') +const TYPES = require('./MailboxTypes') class Mailbox extends Model { @@ -9,6 +11,11 @@ class Mailbox extends Model { /* **************************************************************************/ static provisionId () { return uuid.v4() } + static get TYPES () { return Object.assign({}, TYPES) } + static get SERVICES () { return Object.assign({}, SERVICES) } + + static get TYPE_GMAIL () { return TYPES.GMAIL } + static get TYPE_GINBOX () { return TYPES.GINBOX } /* **************************************************************************/ // Lifecycle @@ -18,22 +25,26 @@ class Mailbox extends Model { super(data) this.__id__ = id - this.__google__ = new Google(this.__data__.googleAuth, this.__data__.googleConf, this.__data__.googleUnreadMessages) + this.__google__ = new Google( + this.type, + this.__data__.googleAuth, + this.__data__.googleConf, + this.__data__.googleLabelInfo_v2, + this.__data__.googleUnreadMessageInfo_v2 + ) } /* **************************************************************************/ - // Constants + // Properties /* **************************************************************************/ - static get TYPE_GMAIL () { return 'gmail' } - static get TYPE_GINBOX () { return 'ginbox' } + get id () { return this.__id__ } + get type () { return this._value_('type', Mailbox.TYPE_GINBOX) } /* **************************************************************************/ - // Properties + // Properties: Constants /* **************************************************************************/ - get id () { return this.__id__ } - get type () { return this._value_('type', Mailbox.TYPE_GINBOX) } get typeName () { switch (this.type) { case Mailbox.TYPE_GINBOX: return 'Google Inbox' @@ -49,6 +60,71 @@ class Mailbox extends Model { } } + /* **************************************************************************/ + // Properties : Services + /* **************************************************************************/ + + get supportedServices () { + switch (this.type) { + case Mailbox.TYPE_GINBOX: + case Mailbox.TYPE_GMAIL: + return Array.from(Google.SUPPORTED_SERVICES) + default: + return [] + } + } + get defaultServices () { + switch (this.type) { + case Mailbox.TYPE_GINBOX: + case Mailbox.TYPE_GMAIL: + return Array.from(Google.DEFAULT_SERVICES) + default: + return [] + } + } + get enabledServies () { return this._value_('services', this.defaultServices) } + get hasEnabledServices () { return this.enabledServies.length !== 0 } + get sleepableServices () { return this._value_('sleepableServices', this.supportedServices) } + get compactServicesUI () { return this._value_('compactServicesUI', false) } + + /** + * Resolves the url for a service + * @param service: the type of service to resolve for + * @return the url for the service or undefined + */ + resolveServiceUrl (service) { + if (service === SERVICES.DEFAULT) { + return this.url + } else { + switch (this.type) { + case Mailbox.TYPE_GINBOX: + case Mailbox.TYPE_GMAIL: + return Google.SERVICE_URLS[service] + default: + return undefined + } + } + } + + /** + * Resolves the human name for a service + * @param service: the type of service to resolve for + * @return the url for the service or undefined + */ + resolveServiceName (service) { + if (service === SERVICES.DEFAULT) { + return this.typeName + } else { + switch (this.type) { + case Mailbox.TYPE_GINBOX: + case Mailbox.TYPE_GMAIL: + return Google.SERVICE_NAMES[service] + default: + return undefined + } + } + } + /* **************************************************************************/ // Properties : Options /* **************************************************************************/ @@ -57,6 +133,7 @@ class Mailbox extends Model { get showUnreadBadge () { return this._value_('showUnreadBadge', true) } get unreadCountsTowardsAppUnread () { return this._value_('unreadCountsTowardsAppUnread', true) } get showNotifications () { return this._value_('showNotifications', true) } + get artificiallyPersistCookies () { return this._value_('artificiallyPersistCookies', false) } /* **************************************************************************/ // Properties : Account Details @@ -77,13 +154,23 @@ class Mailbox extends Model { } get email () { return this.__data__.email } get name () { return this.__data__.name } - get unread () { return this.__data__.unread } + get unread () { return this.__google__.unreadCount } /* **************************************************************************/ // Properties : Auth types /* **************************************************************************/ get google () { return this.__google__ } + + /* **************************************************************************/ + // Properties : Custom injectables + /* **************************************************************************/ + + get customCSS () { return this.__data__.customCSS } + get hasCustomCSS () { return !!this.customCSS } + + get customJS () { return this.__data__.customJS } + get hasCustomJS () { return !!this.customJS } } module.exports = Mailbox diff --git a/src/shared/Models/Mailbox/MailboxServices.js b/src/shared/Models/Mailbox/MailboxServices.js new file mode 100644 index 00000000..920b6847 --- /dev/null +++ b/src/shared/Models/Mailbox/MailboxServices.js @@ -0,0 +1,9 @@ +// Try to keep these generic to support n-types of account +module.exports = Object.freeze({ + DEFAULT: 'mail', + STORAGE: 'storage', // google drive + CONTACTS: 'contacts', // google contacts + NOTES: 'notes', // google keep + CALENDAR: 'calendar', // google calendar + COMMUNICATION: 'communication' // google hangouts +}) diff --git a/src/shared/Models/Mailbox/MailboxTypes.js b/src/shared/Models/Mailbox/MailboxTypes.js new file mode 100644 index 00000000..cfd91572 --- /dev/null +++ b/src/shared/Models/Mailbox/MailboxTypes.js @@ -0,0 +1,4 @@ +module.exports = Object.freeze({ + GMAIL: 'gmail', + GINBOX: 'ginbox' +}) diff --git a/src/shared/Models/Settings/AppSettings.js b/src/shared/Models/Settings/AppSettings.js new file mode 100644 index 00000000..2425c163 --- /dev/null +++ b/src/shared/Models/Settings/AppSettings.js @@ -0,0 +1,11 @@ +const Model = require('../Model') + +class AppSettings extends Model { + get ignoreGPUBlacklist () { return this._value_('ignoreGPUBlacklist', true) } + get disableSmoothScrolling () { return this._value_('disableSmoothScrolling', false) } + get enableUseZoomForDSF () { return this._value_('enableUseZoomForDSF', true) } + get checkForUpdates () { return this._value_('checkForUpdates', true) } + get hasSeenAppWizard () { return this._value_('hasSeenAppWizard', false) } +} + +module.exports = AppSettings diff --git a/src/shared/Models/Settings/LanguageSettings.js b/src/shared/Models/Settings/LanguageSettings.js index e8a957d7..a151daed 100644 --- a/src/shared/Models/Settings/LanguageSettings.js +++ b/src/shared/Models/Settings/LanguageSettings.js @@ -1,7 +1,28 @@ const Model = require('../Model') +const path = require('path') class LanguageSettings extends Model { + + /* ****************************************************************************/ + // Class + /* ****************************************************************************/ + + /** + * @param root: the root directory of the app + * @return the paths to add user dictionaries + */ + static userDictionariesPath (root) { return path.join(root, 'user_dictionaries') } + static get defaultSpellcheckerLanguage () { return 'en_US' } + + /* ****************************************************************************/ + // Properties: Spellchecker + /* ****************************************************************************/ + get spellcheckerEnabled () { return this._value_('spellcheckerEnabled', true) } + get spellcheckerLanguage () { return this._value_('spellcheckerLanguage', LanguageSettings.defaultSpellcheckerLanguage) } + get hasSecondarySpellcheckerLanguage () { return this.secondarySpellcheckerLanguage === null } + get secondarySpellcheckerLanguage () { return this._value_('secondarySpellcheckerLanguage', null) } + } module.exports = LanguageSettings diff --git a/src/shared/Models/Settings/NewsSettings.js b/src/shared/Models/Settings/NewsSettings.js new file mode 100644 index 00000000..745efff2 --- /dev/null +++ b/src/shared/Models/Settings/NewsSettings.js @@ -0,0 +1,14 @@ +const Model = require('../Model') + +class NewsSettings extends Model { + get newsId () { return this._value_('newsId', 0) } + get newsLevel () { return this._value_('newsLevel', 'none') } + get newsFeed () { return this._value_('newsFeed', undefined) } + get openedNewsId () { return this._value_('openedNewsId', 0) } + + get hasUpdateInfo () { return !!this.newsFeed } + get hasUnopenedNewsId () { return this.openedNewsId < this.newsId } + get showNewsInSidebar () { return this._value_('showNewsInSidebar', true) } +} + +module.exports = NewsSettings diff --git a/src/shared/Models/Settings/SettingsIdent.js b/src/shared/Models/Settings/SettingsIdent.js index 791423ee..cc6d89c6 100644 --- a/src/shared/Models/Settings/SettingsIdent.js +++ b/src/shared/Models/Settings/SettingsIdent.js @@ -1,6 +1,8 @@ module.exports = { SEGMENTS: { + APP: 'app', LANGUAGE: 'language', + NEWS: 'news', OS: 'os', PROXY: 'proxy', TRAY: 'tray', diff --git a/src/shared/Models/Settings/TraySettings.js b/src/shared/Models/Settings/TraySettings.js index 5e609f9c..cbd663ed 100644 --- a/src/shared/Models/Settings/TraySettings.js +++ b/src/shared/Models/Settings/TraySettings.js @@ -1,11 +1,38 @@ const Model = require('../Model') class TraySettings extends Model { + + /* **************************************************************************/ + // Lifecycle + /* **************************************************************************/ + + /** + * @param data: the tray data + * @param themedDefaults: the default values for the tray + */ + constructor (data, themedDefaults = {}) { + super(data) + this.__themedDefaults__ = Object.freeze(Object.assign({}, themedDefaults)) + } + + /* **************************************************************************/ + // Properties + /* **************************************************************************/ + get show () { return this._value_('show', true) } get showUnreadCount () { return this._value_('showUnreadCount', true) } - get readColor () { return this._value_('readColor', '#000000') } - get isReadColorDefault () { return !!this._value_.readColor } - get unreadColor () { return this._value_('unreadColor', '#C82018') } + get readColor () { return this._value_('readColor', this.__themedDefaults__.readColor) } + get readBackgroundColor () { return this._value_('readBackgroundColor', this.__themedDefaults__.readBackgroundColor) } + get unreadColor () { return this._value_('unreadColor', this.__themedDefaults__.unreadColor) } + get unreadBackgroundColor () { return this._value_('unreadBackgroundColor', this.__themedDefaults__.unreadBackgroundColor) } + + get dpiMultiplier () { + let defaultValue = 1 + try { + defaultValue = window.devicePixelRatio + } catch (ex) { } + return this._value_('dpiMultiplier', defaultValue) + } } module.exports = TraySettings diff --git a/src/shared/Models/Settings/UISettings.js b/src/shared/Models/Settings/UISettings.js index ffaf3905..63537f18 100644 --- a/src/shared/Models/Settings/UISettings.js +++ b/src/shared/Models/Settings/UISettings.js @@ -3,8 +3,10 @@ const Model = require('../Model') class UISettings extends Model { get showTitlebar () { return this._value_('showTitlebar', false) } get showAppBadge () { return this._value_('showAppBadge', true) } + get showTitlebarCount () { return this._value_('showTitlebarCount', true) } get showAppMenu () { return this._value_('showAppMenu', process.platform !== 'win32') } get sidebarEnabled () { return this._value_('sidebarEnabled', true) } + get openHidden () { return this._value_('openHidden', false) } } module.exports = UISettings diff --git a/src/shared/Models/Settings/index.js b/src/shared/Models/Settings/index.js index 3bf5b2ce..41554f06 100644 --- a/src/shared/Models/Settings/index.js +++ b/src/shared/Models/Settings/index.js @@ -1,5 +1,7 @@ module.exports = { + AppSettings: require('./AppSettings'), LanguageSettings: require('./LanguageSettings'), + NewsSettings: require('./NewsSettings'), OSSettings: require('./OSSettings'), ProxySettings: require('./ProxySettings'), TraySettings: require('./TraySettings'), diff --git a/src/shared/constants.js b/src/shared/constants.js index 32e27786..c7c156be 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -2,18 +2,24 @@ module.exports = Object.freeze({ APP_ID: 'tombeverley.wmail', MAILBOX_INDEX_KEY: '__index__', + MAILBOX_SLEEP_WAIT: 1000 * 30, // 30 seconds + WEB_URL: 'https://thomas101.github.io/wmail/', GITHUB_URL: 'https://github.com/Thomas101/wmail/', GITHUB_ISSUE_URL: 'https://github.com/Thomas101/wmail/issues', - GITHUB_RELEASES_URL: 'http://thomas101.github.io/wmail/download', - UPDATE_CHECK_URL: 'https://api.github.com/repos/Thomas101/wmail/releases', + UPDATE_DOWNLOAD_URL: 'http://thomas101.github.io/wmail/download', + UPDATE_CHECK_URL: 'https://thomas101.github.io/wmail/version.json', + PRIVACY_URL: 'https://thomas101.github.io/wmail/privacy', + USER_SCRIPTS_WEB_URL: 'https://github.com/Thomas101/wmail-user-scripts', + UPDATE_CHECK_INTERVAL: 1000 * 60 * 60 * 24, // 24 hours GMAIL_PROFILE_SYNC_MS: 1000 * 60 * 60, // 60 mins GMAIL_UNREAD_SYNC_MS: 1000 * 60, // 60 seconds - GMAIL_NOTIFICATION_SYNC_MS: 1000 * 60 * 5, // 5 minutes - GMAIL_NOTIFICATION_MAX_MESSAGE_AGE_MS: 1000 * 60 * 60 * 24, // 1 day - GMAIL_NOTIFICATION_MESSAGE_CLEANUP_AGE_MS: 1000 * 60 * 60 * 24 * 7, // 7 days + GMAIL_NOTIFICATION_MAX_MESSAGE_AGE_MS: 1000 * 60 * 60 * 2, // 2 hours GMAIL_NOTIFICATION_FIRST_RUN_GRACE_MS: 1000 * 30, // 30 seconds - REFOCUS_MAILBOX_INTERVAL_MS: 300 + REFOCUS_MAILBOX_INTERVAL_MS: 300, + + DB_EXTENSION: 'wmaildb', + DB_WRITE_DELAY_MS: 500 // 0.5secs }) diff --git a/src/shared/dictionaries.js b/src/shared/dictionaries.js new file mode 100644 index 00000000..c0f17cf5 --- /dev/null +++ b/src/shared/dictionaries.js @@ -0,0 +1,508 @@ +/** +Full list of character sets thanks to apple +https://opensource.apple.com/source/WebCore/WebCore-7601.3.8/platform/text/LocaleToScriptMappingDefault.cpp +static const LocaleScript localeScriptList[] = { + { "aa", USCRIPT_LATIN }, + { "ab", USCRIPT_CYRILLIC }, + { "ady", USCRIPT_CYRILLIC }, + { "af", USCRIPT_LATIN }, + { "ak", USCRIPT_LATIN }, + { "am", USCRIPT_ETHIOPIC }, + { "ar", USCRIPT_ARABIC }, + { "as", USCRIPT_BENGALI }, + { "ast", USCRIPT_LATIN }, + { "av", USCRIPT_CYRILLIC }, + { "ay", USCRIPT_LATIN }, + { "az", USCRIPT_LATIN }, + { "ba", USCRIPT_CYRILLIC }, + { "be", USCRIPT_CYRILLIC }, + { "bg", USCRIPT_CYRILLIC }, + { "bi", USCRIPT_LATIN }, + { "bn", USCRIPT_BENGALI }, + { "bo", USCRIPT_TIBETAN }, + { "bs", USCRIPT_LATIN }, + { "ca", USCRIPT_LATIN }, + { "ce", USCRIPT_CYRILLIC }, + { "ceb", USCRIPT_LATIN }, + { "ch", USCRIPT_LATIN }, + { "chk", USCRIPT_LATIN }, + { "cs", USCRIPT_LATIN }, + { "cy", USCRIPT_LATIN }, + { "da", USCRIPT_LATIN }, + { "de", USCRIPT_LATIN }, + { "dv", USCRIPT_THAANA }, + { "dz", USCRIPT_TIBETAN }, + { "ee", USCRIPT_LATIN }, + { "efi", USCRIPT_LATIN }, + { "el", USCRIPT_GREEK }, + { "en", USCRIPT_LATIN }, + { "es", USCRIPT_LATIN }, + { "et", USCRIPT_LATIN }, + { "eu", USCRIPT_LATIN }, + { "fa", USCRIPT_ARABIC }, + { "fi", USCRIPT_LATIN }, + { "fil", USCRIPT_LATIN }, + { "fj", USCRIPT_LATIN }, + { "fo", USCRIPT_LATIN }, + { "fr", USCRIPT_LATIN }, + { "fur", USCRIPT_LATIN }, + { "fy", USCRIPT_LATIN }, + { "ga", USCRIPT_LATIN }, + { "gaa", USCRIPT_LATIN }, + { "gd", USCRIPT_LATIN }, + { "gil", USCRIPT_LATIN }, + { "gl", USCRIPT_LATIN }, + { "gn", USCRIPT_LATIN }, + { "gsw", USCRIPT_LATIN }, + { "gu", USCRIPT_GUJARATI }, + { "ha", USCRIPT_LATIN }, + { "haw", USCRIPT_LATIN }, + { "he", USCRIPT_HEBREW }, + { "hi", USCRIPT_DEVANAGARI }, + { "hil", USCRIPT_LATIN }, + { "ho", USCRIPT_LATIN }, + { "hr", USCRIPT_LATIN }, + { "ht", USCRIPT_LATIN }, + { "hu", USCRIPT_LATIN }, + { "hy", USCRIPT_ARMENIAN }, + { "id", USCRIPT_LATIN }, + { "ig", USCRIPT_LATIN }, + { "ii", USCRIPT_YI }, + { "ilo", USCRIPT_LATIN }, + { "inh", USCRIPT_CYRILLIC }, + { "is", USCRIPT_LATIN }, + { "it", USCRIPT_LATIN }, + { "iu", USCRIPT_CANADIAN_ABORIGINAL }, + { "ja", USCRIPT_KATAKANA_OR_HIRAGANA }, + { "jv", USCRIPT_LATIN }, + { "ka", USCRIPT_GEORGIAN }, + { "kaj", USCRIPT_LATIN }, + { "kam", USCRIPT_LATIN }, + { "kbd", USCRIPT_CYRILLIC }, + { "kha", USCRIPT_LATIN }, + { "kk", USCRIPT_CYRILLIC }, + { "kl", USCRIPT_LATIN }, + { "km", USCRIPT_KHMER }, + { "kn", USCRIPT_KANNADA }, + { "ko", USCRIPT_HANGUL }, + { "kok", USCRIPT_DEVANAGARI }, + { "kos", USCRIPT_LATIN }, + { "kpe", USCRIPT_LATIN }, + { "krc", USCRIPT_CYRILLIC }, + { "ks", USCRIPT_ARABIC }, + { "ku", USCRIPT_ARABIC }, + { "kum", USCRIPT_CYRILLIC }, + { "ky", USCRIPT_CYRILLIC }, + { "la", USCRIPT_LATIN }, + { "lah", USCRIPT_ARABIC }, + { "lb", USCRIPT_LATIN }, + { "lez", USCRIPT_CYRILLIC }, + { "ln", USCRIPT_LATIN }, + { "lo", USCRIPT_LAO }, + { "lt", USCRIPT_LATIN }, + { "lv", USCRIPT_LATIN }, + { "mai", USCRIPT_DEVANAGARI }, + { "mdf", USCRIPT_CYRILLIC }, + { "mg", USCRIPT_LATIN }, + { "mh", USCRIPT_LATIN }, + { "mi", USCRIPT_LATIN }, + { "mk", USCRIPT_CYRILLIC }, + { "ml", USCRIPT_MALAYALAM }, + { "mn", USCRIPT_CYRILLIC }, + { "mr", USCRIPT_DEVANAGARI }, + { "ms", USCRIPT_LATIN }, + { "mt", USCRIPT_LATIN }, + { "my", USCRIPT_MYANMAR }, + { "myv", USCRIPT_CYRILLIC }, + { "na", USCRIPT_LATIN }, + { "nb", USCRIPT_LATIN }, + { "ne", USCRIPT_DEVANAGARI }, + { "niu", USCRIPT_LATIN }, + { "nl", USCRIPT_LATIN }, + { "nn", USCRIPT_LATIN }, + { "nr", USCRIPT_LATIN }, + { "nso", USCRIPT_LATIN }, + { "ny", USCRIPT_LATIN }, + { "oc", USCRIPT_LATIN }, + { "om", USCRIPT_LATIN }, + { "or", USCRIPT_ORIYA }, + { "os", USCRIPT_CYRILLIC }, + { "pa", USCRIPT_GURMUKHI }, + { "pag", USCRIPT_LATIN }, + { "pap", USCRIPT_LATIN }, + { "pau", USCRIPT_LATIN }, + { "pl", USCRIPT_LATIN }, + { "pon", USCRIPT_LATIN }, + { "ps", USCRIPT_ARABIC }, + { "pt", USCRIPT_LATIN }, + { "qu", USCRIPT_LATIN }, + { "rm", USCRIPT_LATIN }, + { "rn", USCRIPT_LATIN }, + { "ro", USCRIPT_LATIN }, + { "ru", USCRIPT_CYRILLIC }, + { "rw", USCRIPT_LATIN }, + { "sa", USCRIPT_DEVANAGARI }, + { "sah", USCRIPT_CYRILLIC }, + { "sat", USCRIPT_LATIN }, + { "sd", USCRIPT_ARABIC }, + { "se", USCRIPT_LATIN }, + { "sg", USCRIPT_LATIN }, + { "si", USCRIPT_SINHALA }, + { "sid", USCRIPT_LATIN }, + { "sk", USCRIPT_LATIN }, + { "sl", USCRIPT_LATIN }, + { "sm", USCRIPT_LATIN }, + { "so", USCRIPT_LATIN }, + { "sq", USCRIPT_LATIN }, + { "sr", USCRIPT_CYRILLIC }, + { "ss", USCRIPT_LATIN }, + { "st", USCRIPT_LATIN }, + { "su", USCRIPT_LATIN }, + { "sv", USCRIPT_LATIN }, + { "sw", USCRIPT_LATIN }, + { "ta", USCRIPT_TAMIL }, + { "te", USCRIPT_TELUGU }, + { "tet", USCRIPT_LATIN }, + { "tg", USCRIPT_CYRILLIC }, + { "th", USCRIPT_THAI }, + { "ti", USCRIPT_ETHIOPIC }, + { "tig", USCRIPT_ETHIOPIC }, + { "tk", USCRIPT_LATIN }, + { "tkl", USCRIPT_LATIN }, + { "tl", USCRIPT_LATIN }, + { "tn", USCRIPT_LATIN }, + { "to", USCRIPT_LATIN }, + { "tpi", USCRIPT_LATIN }, + { "tr", USCRIPT_LATIN }, + { "trv", USCRIPT_LATIN }, + { "ts", USCRIPT_LATIN }, + { "tt", USCRIPT_CYRILLIC }, + { "tvl", USCRIPT_LATIN }, + { "tw", USCRIPT_LATIN }, + { "ty", USCRIPT_LATIN }, + { "tyv", USCRIPT_CYRILLIC }, + { "udm", USCRIPT_CYRILLIC }, + { "ug", USCRIPT_ARABIC }, + { "uk", USCRIPT_CYRILLIC }, + { "und", USCRIPT_LATIN }, + { "ur", USCRIPT_ARABIC }, + { "uz", USCRIPT_CYRILLIC }, + { "ve", USCRIPT_LATIN }, + { "vi", USCRIPT_LATIN }, + { "wal", USCRIPT_ETHIOPIC }, + { "war", USCRIPT_LATIN }, + { "wo", USCRIPT_LATIN }, + { "xh", USCRIPT_LATIN }, + { "yap", USCRIPT_LATIN }, + { "yo", USCRIPT_LATIN }, + { "za", USCRIPT_LATIN }, + { "zh", USCRIPT_SIMPLIFIED_HAN }, + { "zh_hk", USCRIPT_TRADITIONAL_HAN }, + { "zh_tw", USCRIPT_TRADITIONAL_HAN }, + { "zu", USCRIPT_LATIN } +}; +*/ + +module.exports = { + 'bg_BG': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/bg_BG/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/bg_BG/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/bg_BG/index.dic', + name: 'Български - Bulgarian', + charset: 'USCRIPT_CYRILLIC' + }, + 'ca_ES-valencia': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES-valencia/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES-valencia/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES-valencia/index.dic', + name: 'Català (València) - Catalan (Valencia)', + charset: 'USCRIPT_LATIN' + }, + 'ca_ES': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ca_ES/index.dic', + name: 'Català - Catalan', + charset: 'USCRIPT_LATIN' + }, + 'cs_CZ': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/cs_CZ/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/cs_CZ/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/cs_CZ/index.dic', + name: 'Česky - Czech', + charset: 'USCRIPT_LATIN' + }, + 'da_DK': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/da_DK/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/da_DK/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/da_DK/index.dic', + name: 'Dansk - Danish', + charset: 'USCRIPT_LATIN' + }, + 'de_AT': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_AT/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_AT/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_AT/index.dic', + name: 'Deutsch (Österreich) - German (Austria)', + charset: 'USCRIPT_LATIN' + }, + 'de_CH': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_CH/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_CH/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_CH/index.dic', + name: 'Deutsch (Schweiz) - German (Switzerland)', + charset: 'USCRIPT_LATIN' + }, + 'de_DE': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_DE/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_DE/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/de_DE/index.dic', + name: 'Deutsch - German', + charset: 'USCRIPT_LATIN' + }, + 'el_GR': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/el_GR/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/el_GR/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/el_GR/index.dic', + name: 'Ελληνικά - Greek', + charset: 'USCRIPT_GREEK' + }, + 'en_AU': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_AU/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_AU/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_AU/index.dic', + name: 'English (Australia) - English (Australia)', + charset: 'USCRIPT_LATIN' + }, + 'en_CA': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_CA/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_CA/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_CA/index.dic', + name: 'English (Canada) - English (Canada)', + charset: 'USCRIPT_LATIN' + }, + 'en_GB': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_GB/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_GB/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_GB/index.dic', + name: 'English (UK) - English (UK)', + charset: 'USCRIPT_LATIN' + }, + 'en_US': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_US/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_US/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_US/index.dic', + name: 'English (US) - English (US)', + charset: 'USCRIPT_LATIN' + }, + 'en_ZA': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_ZA/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_ZA/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/en_ZA/index.dic', + name: 'English (South Africa) - English (South Africa)', + charset: 'USCRIPT_LATIN' + }, + 'es_ES': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/es_ES/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/es_ES/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/es_ES/index.dic', + name: 'Español - Spanish', + charset: 'USCRIPT_LATIN' + }, + 'et_EE': { + license: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Estonian.txt', + aff: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Estonian.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Estonian.dic', + name: 'Eesti - Estonian', + charset: 'USCRIPT_LATIN' + }, + 'eu_ES': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/eu_ES/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/eu_ES/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/eu_ES/index.dic', + name: 'Euskara - Basque', + charset: 'USCRIPT_LATIN' + }, + 'fr_FR': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/fr_FR/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/fr_FR/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/fr_FR/index.dic', + name: 'Français - French', + charset: 'USCRIPT_LATIN' + }, + 'gl_ES': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/gl_ES/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/gl_ES/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/gl_ES/index.dic', + name: 'Galego - Galician', + charset: 'USCRIPT_LATIN' + }, + 'hr_HR': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/hr_HR/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/hr_HR/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/hr_HR/index.dic', + name: 'Hrvatski - Croatian', + charset: 'USCRIPT_LATIN' + }, + 'hu_HU': { + license: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Hungarian.txt', + aff: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Hungarian.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Hungarian.dic', + name: 'Magyar - Hungarian', + charset: 'USCRIPT_LATIN' + }, + 'it_IT': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/it_IT/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/it_IT/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/it_IT/index.dic', + name: 'Italiano - Italian', + charset: 'USCRIPT_LATIN' + }, + 'lb_LU': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/lb_LU/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/lb_LU/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/lb_LU/index.dic', + name: 'Lëtzebuergesch - Luxembourgish', + charset: 'USCRIPT_LATIN' + }, + 'lt_LT': { + license: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Lithuanian.txt', + aff: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Lithuanian.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Lithuanian.dic', + name: 'Lietuvos - Lithuanian', + charset: 'USCRIPT_LATIN' + }, + 'lv_LV': { + license: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Latvian.txt', + aff: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Latvian.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Latvian.dic', + name: 'Latvijas - Latvian', + charset: 'USCRIPT_LATIN' + }, + 'mn_MN': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/mn_MN/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/mn_MN/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/mn_MN/index.dic', + name: 'Монгол - Mongolian', + charset: 'USCRIPT_CYRILLIC' + }, + 'ms_MY': { + license: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Malays.txt', + aff: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Malays.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/Dictionaries-1/master/Malays.dic', + name: 'Melayu - Malay', + charset: 'USCRIPT_LATIN' + }, + 'nb_NO': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nb_NO/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nb_NO/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nb_NO/index.dic', + name: 'Norsk (bokmål) - Norwegian', + charset: 'USCRIPT_LATIN' + }, + 'nl_NL': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nl_NL/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nl_NL/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nl_NL/index.dic', + name: 'Nederlands - Dutch', + charset: 'USCRIPT_LATIN' + }, + 'nn_NO': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nn_NO/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nn_NO/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/nn_NO/index.dic', + name: 'Norsk (nynorsk) - Norwegian Nynorsk', + charset: 'USCRIPT_LATIN' + }, + 'pl_PL': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pl_PL/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pl_PL/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pl_PL/index.dic', + name: 'Polski - Polish', + charset: 'USCRIPT_LATIN' + }, + 'pt_BR': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_BR/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_BR/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_BR/index.dic', + name: 'Português (Brasil) - Portuguese (Brazil)', + charset: 'USCRIPT_LATIN' + }, + 'pt_PT': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_PT/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_PT/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/pt_PT/index.dic', + name: 'Português (Portugal) - Portuguese (Portugal)', + charset: 'USCRIPT_LATIN' + }, + 'ro_RO': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ro_RO/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ro_RO/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ro_RO/index.dic', + name: 'Română - Romanian', + charset: 'USCRIPT_LATIN' + }, + 'ru_RU': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ru_RU/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ru_RU/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/ru_RU/index.dic', + name: 'Русский - Russian', + charset: 'USCRIPT_CYRILLIC' + }, + 'sk_SK': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sk_SK/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sk_SK/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sk_SK/index.dic', + name: 'Slovenčina - Slovak', + charset: 'USCRIPT_LATIN' + }, + 'sl_SI': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sl_SI/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sl_SI/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sl_SI/index.dic', + name: 'Slovenščina - Slovenian', + charset: 'USCRIPT_LATIN' + }, + 'sr_RS-Latn': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS-Latn/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS-Latn/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS-Latn/index.dic', + name: 'Српски (латински) - Serbian (Latin)', + charset: 'USCRIPT_LATIN' + }, + 'sr_RS': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sr_RS/index.dic', + name: 'Српски - Serbian', + charset: 'USCRIPT_CYRILLIC' + }, + 'sv_SE': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sv_SE/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sv_SE/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/sv_SE/index.dic', + name: 'Svenska - Swedish', + charset: 'USCRIPT_LATIN' + }, + 'tr_TR': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/tr-TR/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/tr-TR/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/tr-TR/index.dic', + name: 'Türkçe - Turkish', + charset: 'USCRIPT_LATIN' + }, + 'uk_UA': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/uk_UA/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/uk_UA/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/uk_UA/index.dic', + name: 'Українська - Ukrainian', + charset: 'USCRIPT_CYRILLIC' + }, + 'vi_VN': { + license: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/vi_VN/LICENSE', + aff: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/vi_VN/index.aff', + dic: 'https://raw.githubusercontent.com/Thomas101/dictionaries/master/dictionaries/vi_VN/index.dic', + name: 'Tiếng Việt - Vietnamese', + charset: 'USCRIPT_LATIN' + } +} diff --git a/src/shared/dictionaryExcludes.js b/src/shared/dictionaryExcludes.js new file mode 100644 index 00000000..1b8a934e --- /dev/null +++ b/src/shared/dictionaryExcludes.js @@ -0,0 +1,30 @@ +const EN = new Set([ + 'ain', + 'can', + 'couldn', + 'didn', + 'don', + 'doesn', + 'hadn', + 'hasn', + 'havn', + 'isn', + 'mightn', + 'mustn', + 'needn', + 'oughtn', + 'shan', + 'shouldn', + 'wasn', + 'weren', + 'won', + 'wouldn' +]) + +module.exports = { + en_AU: EN, + en_CA: EN, + en_GB: EN, + en_US: EN, + en_ZA: EN +} diff --git a/src/shared/index.js b/src/shared/index.js index 347c2139..45993b7a 100644 --- a/src/shared/index.js +++ b/src/shared/index.js @@ -2,5 +2,6 @@ module.exports = { b64Assets: require('./b64Assets'), credentials: require('./credentials'), constants: require('./constants'), - Models: require('./Models') + Models: require('./Models'), + dictionaries: require('./dictionaries.js') } diff --git a/webpack.config.js b/webpack.config.js index 1f6c3509..f485bd5a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -23,24 +23,16 @@ if (getArg('--fast') !== undefined) { const task = getArg('--task=', '--task=all').substr(7) if (task === 'app') { console.log('[TASK=app]') - module.exports = [ - require('./src/app/webpack.config.js') - ] + module.exports = [ require('./src/app/webpack.config.js') ] } else if (task === 'mailboxes') { console.log('[TASK=mailboxes]') - module.exports = [ - require('./src/scenes/mailboxes/webpack.config.js') - ] + module.exports = [ require('./src/scenes/mailboxes/webpack.config.js') ] } else if (task === 'platform') { console.log('[TASK=platform]') - module.exports = [ - require('./src/scenes/platform/webpack.config.js') - ] + module.exports = [ require('./src/scenes/platform/webpack.config.js') ] } else if (task === 'assets') { console.log('[TASK=assets]') - module.exports = [ - require('./assets/webpack.config.js') - ] + module.exports = [ require('./assets/webpack.config.js') ] } else { console.log('[TASK=all]') module.exports = [