├── .babelrc ├── .env.default ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENCE.GPL-3.md ├── LICENCE.NOHARM-draft.md ├── LICENCE.md ├── README.md ├── assets ├── fonts │ ├── Roboto │ │ ├── LICENSE.txt │ │ ├── Roboto-Light.ttf │ │ └── Roboto-Medium.ttf │ └── Roboto_Mono │ │ ├── LICENSE.txt │ │ └── RobotoMono-Medium.ttf ├── icon-16.png ├── icon-32.png ├── icon-64.png ├── icon.png └── logo.png ├── manifest.json ├── package-lock.json ├── package.json ├── scripts ├── build-dev.sh └── build.sh ├── src ├── background.html ├── frame.html ├── js │ ├── background │ │ ├── addresses.js │ │ ├── index.js │ │ ├── menus.js │ │ └── scores.js │ ├── browser │ │ ├── chrome │ │ │ ├── constants.js │ │ │ ├── index.js │ │ │ ├── messages.js │ │ │ └── storage.js │ │ └── firefox │ │ │ ├── constants.js │ │ │ ├── index.js │ │ │ ├── messages.js │ │ │ └── storage.js │ ├── components │ │ ├── alerts │ │ │ ├── alerts.module.scss │ │ │ └── index.js │ │ ├── button │ │ │ ├── button.module.scss │ │ │ └── index.js │ │ ├── copy-to-clipboard │ │ │ └── index.js │ │ ├── domain-score │ │ │ ├── domain-score.module.scss │ │ │ └── index.js │ │ ├── form │ │ │ ├── form.module.scss │ │ │ └── index.js │ │ ├── icons │ │ │ ├── icons.module.scss │ │ │ └── index.js │ │ ├── radio │ │ │ ├── index.js │ │ │ └── radio.module.scss │ │ ├── rank │ │ │ ├── index.js │ │ │ └── rank.module.scss │ │ ├── score │ │ │ ├── index.js │ │ │ └── score.module.scss │ │ └── text │ │ │ ├── index.js │ │ │ └── text.module.scss │ ├── constants.js │ ├── content │ │ ├── frame │ │ │ ├── frame.scss │ │ │ ├── index.js │ │ │ └── popup.module.scss │ │ ├── gmail │ │ │ ├── gmail.module.scss │ │ │ ├── index.js │ │ │ ├── renderer.js │ │ │ └── utils.js │ │ ├── index.js │ │ ├── modal.js │ │ ├── onload.js │ │ └── patch.js │ ├── hooks │ │ ├── use-background.js │ │ ├── use-badge.js │ │ ├── use-countdown.js │ │ ├── use-current-url.js │ │ ├── use-domain-score.js │ │ ├── use-graphql.js │ │ ├── use-new-tab.js │ │ └── use-storage.js │ ├── options │ │ ├── app.scss │ │ ├── index.js │ │ ├── loading.js │ │ ├── loading.module.scss │ │ └── pages │ │ │ ├── about │ │ │ ├── about.module.scss │ │ │ └── index.js │ │ │ ├── account │ │ │ ├── account.module.scss │ │ │ ├── index.js │ │ │ └── invite.js │ │ │ ├── appearance │ │ │ ├── appearance.module.scss │ │ │ └── index.js │ │ │ ├── emails │ │ │ ├── emails.module.scss │ │ │ ├── index.js │ │ │ └── list.js │ │ │ ├── feedback │ │ │ ├── feedback.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ │ ├── help │ │ │ ├── help.module.scss │ │ │ └── index.js │ │ │ ├── layout.js │ │ │ ├── layout.module.scss │ │ │ └── preferences │ │ │ ├── index.js │ │ │ └── preferences.module.scss │ ├── popup │ │ ├── footer │ │ │ ├── footer.module.scss │ │ │ └── index.js │ │ ├── index.js │ │ ├── popup.module.scss │ │ └── reset.scss │ ├── providers │ │ ├── alert-provider.js │ │ ├── connect-provider.js │ │ ├── user-provider.js │ │ └── user-reducer.js │ └── utils │ │ ├── alarms.js │ │ ├── cache.js │ │ ├── classnames.js │ │ ├── digest.js │ │ ├── is-excluded-domain.js │ │ ├── is-mail-provider.js │ │ ├── logger.js │ │ ├── preferences.js │ │ ├── ranks.js │ │ ├── request.js │ │ ├── social │ │ ├── facebook.js │ │ ├── index.js │ │ ├── linkedin.js │ │ ├── twitter.js │ │ └── window.js │ │ └── storage.js ├── manifest.chrome.json ├── manifest.firefox.json ├── options.html ├── popup.html └── styles │ ├── common │ └── colors.scss │ ├── global │ └── layout.scss │ └── mixins │ ├── ranks.scss │ └── themes.scss ├── store-assets └── chrome │ ├── app store 1.png │ ├── app store 2.png │ ├── app store 3.png │ ├── app store 4.png │ ├── app store 5.png │ ├── app store 6.png │ ├── app store 7.png │ ├── icon_128x128.png │ ├── large-tile.png │ ├── marquee.png │ ├── old │ ├── Chrome Screenshot 1.png │ ├── Chrome Screenshot 2.png │ ├── Chrome Screenshot 3.png │ ├── Chrome Screenshot 4.png │ └── Chrome Screenshot 5.png │ └── small-tile.png ├── tests └── blocking │ ├── delayed-form │ └── index.html │ ├── event │ └── index.html │ ├── form-action │ └── index.html │ ├── handler-and-action │ └── index.html │ ├── handler │ └── index.html │ ├── styles.css │ └── submit.html ├── webpack.chrome.js ├── webpack.config.js ├── webpack.firefox.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-transform-regenerator", 5 | "@babel/plugin-syntax-dynamic-import", 6 | "@babel/plugin-proposal-class-properties", 7 | "@babel/plugin-syntax-async-generators", 8 | "@babel/plugin-proposal-async-generator-functions" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | GRAPHQL_URL=https://api.leavemealone.app/graphql 2 | REFERRAL_URL=https://subscriptionscore.com/r/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | "plugins": ["react-hooks"], 9 | "env": { 10 | "browser": true, 11 | "node": true, 12 | "es6": true 13 | }, 14 | "globals": { 15 | "chrome": true, 16 | "browser": true 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "16.7.0-alpha.2" 21 | } 22 | }, 23 | "rules": { 24 | "prettier/prettier": ["error", { "singleQuote": true }], 25 | "no-console": 0, 26 | "react/prop-types": 0, 27 | "react/no-unescaped-entities": 0, 28 | "react/display-name": 1, 29 | "react-hooks/rules-of-hooks": "error", 30 | "react-hooks/exhaustive-deps": "warn" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | with: 15 | fetch-depth: 1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.9.x 19 | - name: Install dependencies 20 | run: yarn 21 | - name: Build 22 | env: 23 | NODE_ENV: production 24 | GRAPHQL_URL: https://api.leavemealone.app/graphql 25 | REFERRAL_URL: https://subscriptionscore.com/r/ 26 | run: ./scripts/build.sh 27 | - name: Archive production artifacts 28 | uses: actions/upload-artifact@v1 29 | with: 30 | name: releases 31 | path: releases 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 37 | with: 38 | tag_name: build-${{ github.sha }} 39 | release_name: Latest 40 | prerelease: true 41 | draft: false 42 | - name: Upload Chrome Asset 43 | id: upload-chrome-asset 44 | uses: actions/upload-release-asset@v1.0.1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 49 | asset_path: ./releases/chrome-latest.zip 50 | asset_name: chrome-latest.zip 51 | asset_content_type: application/zip 52 | - name: Upload Firefox Asset 53 | id: upload-firefox-asset 54 | uses: actions/upload-release-asset@v1.0.1 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 59 | asset_path: ./releases/firefox-latest.zip 60 | asset_name: firefox-latest.zip 61 | asset_content_type: application/zip 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .keys 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | releases 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # dotenv environment variable files 57 | .env 58 | .env.* 59 | 60 | # gatsby files 61 | .cache/ 62 | public 63 | 64 | # Mac files 65 | .DS_Store 66 | 67 | # Yarn 68 | yarn-error.log 69 | .pnp/ 70 | .pnp.js 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | build/ 75 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.9.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | yarn.lock 4 | build 5 | releases 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "bracketSpacing": true, 5 | "singleQuote": true, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /LICENCE.NOHARM-draft.md: -------------------------------------------------------------------------------- 1 | Do No Harm License 2 | 3 | Preamble 4 | 5 | Most software today is developed with little to no thought of how it will be used, or the consequences for our society and planet. 6 | 7 | As software developers, we engineer the infrastructure of the 21st century. We recognise that our infrastructure has great power to shape the world and the lives of those we share it with, and we choose to consciously take responsibility for the social and environmental impacts of what we build. 8 | 9 | We envisage a world free from injustice, inequality, and the reckless destruction of lives and our planet. We reject slavery in all its forms, whether by force, indebtedness, or by algorithms that hack human vulnerabilities. We seek a world where humankind is at peace with our neighbours, nature, and ourselves. We want our work to enrich the physical, mental and spiritual wellbeing of all society. 10 | 11 | We build software to further this vision of a just world, or at the very least, to not put that vision further from reach. 12 | 13 | Terms 14 | 15 | Copyright (c) (year) (owner). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 16 | 17 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 18 | 19 | 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. 20 | 21 | Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 22 | 23 | This software must not be used by any organisation, website, product or service that: 24 | 25 | a) lobbies for, promotes, or derives a majority of income from actions that support or contribute to: 26 | sex trafficking 27 | human trafficking 28 | slavery 29 | indentured servitude 30 | gambling 31 | tobacco 32 | adversely addictive behaviours 33 | nuclear energy 34 | warfare 35 | weapons manufacturing 36 | war crimes 37 | violence (except when required to protect public safety) 38 | burning of forests 39 | deforestation 40 | hate speech or discrimination based on age, gender, gender identity, race, sexuality, religion, nationality 41 | 42 | b) lobbies against, or derives a majority of income from actions that discourage or frustrate: 43 | peace 44 | access to the rights set out in the Universal Declaration of Human Rights and the Convention on the Rights of the Child 45 | peaceful assembly and association (including worker associations) 46 | a safe environment or action to curtail the use of fossil fuels or prevent climate change 47 | democratic processes 48 | 49 | All redistribution of source code or binary form, including any modifications must be under these terms. You must inform recipients that the code is governed by these conditions, and how they can obtain a copy of this license. You may not attempt to alter the conditions of who may/may not use this software. 50 | 51 | We define: 52 | 53 | Forests to be 0.5 or more hectares of trees that were either planted more than 50 years ago or were not planted by humans or human made equipment. 54 | 55 | Deforestation to be the clearing, burning or destruction of 0.5 or more hectares of forests within a 1 year period. 56 | 57 | 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. 58 | 59 | Attribution 60 | 61 | Do No Harm License Contributor Covenant, (pre 1.0), available at https://github.com/raisely/NoHarm 62 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | ## License 2 | 3 | This work is dual-licensed under GPL 3.0 and NOHARM-draft (or any later versions). 4 | You must refer to both of them to use this work. 5 | 6 | `SPDX-License-Identifier: GPL-3.0-or-later AND NOHARM-draft-or-later` 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subscription Score Extension 2 | 3 | This is the code for building the [Subscription Score][2] browser extension. 4 | 5 | [![](https://github.com/subscriptionscore/extension/workflows/Build/badge.svg)](https://github.com/subscriptionscore/extension/actions) 6 | 7 | ## Bugs or Feature requests 8 | 9 | Please submit an [issue][3]. 10 | 11 | ## Setup 12 | 13 | ### Prerequisites 14 | 15 | The extension requires `node` and `npm` or `yarn` to build (tested up to `node v12.9.1`) and the latest version of Chrome or Firefox to run. 16 | 17 | ### Install dependencies 18 | 19 | ``` 20 | yarn 21 | ``` 22 | 23 | or 24 | 25 | ``` 26 | npm install 27 | ``` 28 | 29 | ### Environment 30 | 31 | You will need to specify a connection to our API in your `.env` file. This is the endpoint that the built extension will use to fetch subscription scores. 32 | 33 | Currently we don't have a development endpoint, so you will need to use the production one at `https://api.leavemealone.app/graphql`. 34 | 35 | Simply copy the `.env.default` to use our default envirnoment values. 36 | 37 | ``` 38 | $ cp .env.default .env 39 | ``` 40 | 41 | You will also need to [purchase an API key](#API-Key) in order to make requests. 42 | 43 | ### Build new release 44 | 45 | The following command will build for all release targets; 46 | 47 | ``` 48 | npm run build 49 | ``` 50 | 51 | Zipped releases can be found in the `/releases` directory. 52 | 53 | ### Run development version 54 | 55 | ``` 56 | npm run build:dev 57 | ``` 58 | 59 | eg. developing Chrome extension; 60 | 61 | ``` 62 | npm run build:dev chrome 63 | ``` 64 | 65 | eg. developing Firefox extension; 66 | 67 | ``` 68 | npm run build:dev firefox 69 | ``` 70 | 71 | Development `manifest.json` files can be found in `/build/{target}` directory. 72 | 73 | ## Usage 74 | 75 | Once you've built a version of the extension and installed it into your browser of choice you can connect it to our API. You will still need a valid licence key in order to connect. You can buy a key from our [website][2], or [contact us][1] for a development key. 76 | 77 | ### TODO 78 | 79 | - Set up test environment for development contributions. 80 | 81 | ## API Key 82 | 83 | An API key is required to run the extension and make requests to the API, you can purchase a key from our [website][2], or [contact us][1] for a development key. 84 | 85 | ## Licence 86 | 87 | GNU General Public License v3.0 88 | 89 | ## Contact 90 | 91 | [hi@subscriptionscore.com][1] 92 | 93 | [1]: mailto:hi@subscriptionscore.com 94 | [2]: https://subscriptionscore.com 95 | [3]: https://github.com/squarecat/subscriptionscore-extension/issues 96 | -------------------------------------------------------------------------------- /assets/fonts/Roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/fonts/Roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/fonts/Roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf -------------------------------------------------------------------------------- /assets/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/icon-16.png -------------------------------------------------------------------------------- /assets/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/icon-32.png -------------------------------------------------------------------------------- /assets/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/icon-64.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/icon.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subscriptionscore/extension/79b6587c1b6a874165c98121f68b9a5598bdce7d/assets/logo.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Subscription Score", 3 | "description": "Never regret subscribing to a mailing list again", 4 | "version": "0.4.4", 5 | "background": { 6 | "page": "background.html" 7 | }, 8 | "browser_action": { 9 | "default_popup": "popup.html", 10 | "default_icon": "assets/logo.png" 11 | }, 12 | "icons": { 13 | "16": "assets/icon-16.png", 14 | "32": "assets/icon-32.png", 15 | "64": "assets/icon-64.png", 16 | "128": "assets/icon.png" 17 | }, 18 | "web_accessible_resources": [ 19 | "onload.bundle.js", 20 | "frame.html", 21 | "frame.bundle.js", 22 | "frame.css" 23 | ], 24 | "permissions": ["tabs", "storage"], 25 | "optional_permissions": [""], 26 | "manifest_version": 2, 27 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 28 | "content_scripts": [ 29 | { 30 | "matches": ["https://mail.google.com/*/*"], 31 | "js": ["/gmail.bundle.js"], 32 | "css": ["/gmail.bundle.css"], 33 | "run_at": "document_idle" 34 | }, 35 | { 36 | "matches": [""], 37 | "js": ["/content.bundle.js"], 38 | "run_at": "document_start" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subscriptionscore-extension", 3 | "version": "0.4.4", 4 | "private": true, 5 | "main": "index.js", 6 | "repository": "git@github.com:squarecat/subscriptionscore-extension.git", 7 | "author": "Squarecat ", 8 | "license": "MIT", 9 | "scripts": { 10 | "lint": "eslint ./src/js", 11 | "build": "./scripts/build.sh", 12 | "build:dev": "./scripts/build-dev.sh", 13 | "verify": "npx lockfile-lint --path yarn.lock --type yarn --validate-https --allowed-hosts yarnpkg.org registry.yarnpkg.com" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.7.4", 17 | "@babel/plugin-proposal-async-generator-functions": "^7.7.4", 18 | "@babel/plugin-proposal-class-properties": "^7.7.4", 19 | "@babel/plugin-proposal-object-rest-spread": "^7.7.4", 20 | "@babel/plugin-syntax-async-generators": "^7.7.4", 21 | "@babel/plugin-syntax-dynamic-import": "^7.7.4", 22 | "@babel/plugin-transform-async-to-generator": "^7.7.4", 23 | "@babel/plugin-transform-regenerator": "^7.7.4", 24 | "@babel/polyfill": "^7.7.0", 25 | "@babel/preset-env": "^7.7.4", 26 | "@babel/preset-react": "^7.7.4", 27 | "@welldone-software/why-did-you-render": "^3.3.9", 28 | "babel": "^6.23.0", 29 | "babel-eslint": "^10.0.3", 30 | "babel-loader": "^8.0.6", 31 | "babel-polyfill": "^6.26.0", 32 | "clean-webpack-plugin": "^3.0.0", 33 | "copy-webpack-plugin": "5.1.1", 34 | "crx3": "^1.1.2", 35 | "css-loader": "^3.2.1", 36 | "dotenv-webpack": "^1.7.0", 37 | "eslint": "^6.7.2", 38 | "eslint-config-prettier": "^6.7.0", 39 | "eslint-plugin-prettier": "^3.1.1", 40 | "eslint-plugin-react": "^7.17.0", 41 | "eslint-plugin-react-hooks": "^2.3.0", 42 | "file-loader": "^5.0.2", 43 | "fs-extra": "^8.1.0", 44 | "html-loader": "^0.5.5", 45 | "html-webpack-plugin": "^3.2.0", 46 | "mini-css-extract-plugin": "^0.9.0", 47 | "node-sass": "^4.13.0", 48 | "prettier": "^1.19.1", 49 | "react": "^16.12.0", 50 | "react-dom": "^16.12.0", 51 | "sass-loader": "^8.0.0", 52 | "style-loader": "^1.0.1", 53 | "webpack": "4.41.5", 54 | "webpack-cli": "^3.3.10", 55 | "webpack-dev-server": "^3.9.0", 56 | "write-file-webpack-plugin": "^4.5.1", 57 | "zip-webpack-plugin": "^3.0.0" 58 | }, 59 | "dependencies": {} 60 | } 61 | -------------------------------------------------------------------------------- /scripts/build-dev.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | TARGET=$1; 4 | BUILD_CHROME=0; 5 | BUILD_FF=0; 6 | webpack=node_modules/webpack-cli/bin/cli.js 7 | 8 | if [ -z $TARGET ]; then 9 | echo "Specify target 'npm run build:dev {chrome,firefox}."; 10 | fi; 11 | 12 | case "${TARGET[@]}" in *"chrome"*) 13 | BUILD_CHROME=1; 14 | esac 15 | 16 | case "${TARGET[@]}" in *"firefox"*) 17 | BUILD_FF=1; 18 | esac 19 | 20 | if [ $BUILD_CHROME -eq 1 ]; then 21 | echo "Building Chrome Plugin..."; 22 | NODE_ENV=development node ./node_modules/webpack-cli/bin/cli.js -p --config webpack.chrome.js --watch 23 | echo "OK"; 24 | fi 25 | 26 | if [ $BUILD_FF -eq 1 ]; then 27 | echo "Building Firefox Extension..."; 28 | NODE_ENV=development node ./node_modules/webpack-cli/bin/cli.js -p --config webpack.firefox.js --watch 29 | echo "OK"; 30 | fi -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | TARGET=$1; 4 | BUILD_CHROME=0; 5 | BUILD_FF=0; 6 | webpack=node_modules/webpack-cli/bin/cli.js 7 | 8 | echo "NODE_ENV: $NODE_ENV" 9 | echo "REFERRAL_URL: $REFERRAL_URL"; 10 | echo "GRAPHQL_URL: $GRAPHQL_URL"; 11 | 12 | if [ -z $TARGET ]; then 13 | echo "Building all targets."; 14 | BUILD_CHROME=1; 15 | BUILD_FF=1; 16 | fi; 17 | 18 | case "${TARGET[@]}" in *"chrome"*) 19 | BUILD_CHROME=1; 20 | esac 21 | 22 | case "${TARGET[@]}" in *"firefox"*) 23 | BUILD_FF=1; 24 | esac 25 | 26 | if [ $BUILD_CHROME -eq 1 ]; then 27 | echo "Building Chrome Plugin..."; 28 | NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js -p --mode=production --config webpack.chrome.js --display errors-only 29 | echo "OK"; 30 | fi 31 | 32 | if [ $BUILD_FF -eq 1 ]; then 33 | echo "Building Firefox Extension..."; 34 | NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js -p --mode=production --config webpack.firefox.js --display errors-only; 35 | echo "OK"; 36 | fi -------------------------------------------------------------------------------- /src/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Popup | Subscription Score 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/js/background/addresses.js: -------------------------------------------------------------------------------- 1 | import { graphqlRequest } from '../utils/request'; 2 | import { 3 | getAddressScores as getAll, 4 | putAddressScore as put 5 | } from '../utils/cache'; 6 | import digest from '../utils/digest'; 7 | 8 | // just get the rank to show in the 9 | // subscription score icon 10 | const gql = ` 11 | query SearchAddresses($emails: [String]!) { 12 | emailScores(emails: $emails) { 13 | scores { 14 | rank 15 | email 16 | } 17 | } 18 | } 19 | `; 20 | 21 | export async function* getAddressScores(addresses) { 22 | let errors = []; 23 | // hash all the addresses before we send, we 24 | // dont want to send plaintext email addresses 25 | const hashedAddressMap = await Promise.all( 26 | addresses.map(async a => ({ 27 | hash: await digest(a.toLowerCase(), 'SHA-1'), 28 | email: a.toLowerCase() 29 | })) 30 | ); 31 | const hashedAddresses = hashedAddressMap.map(a => a.hash); 32 | const cachedScores = await getAll(hashedAddresses); 33 | const uncached = hashedAddresses.filter(address => 34 | cachedScores.every(cs => cs.address !== address) 35 | ); 36 | 37 | yield mapHashedAddress( 38 | hashedAddressMap, 39 | cachedScores.reduce( 40 | (out, { address, rank }) => 41 | rank ? [...out, { email: address, rank }] : out, 42 | [] 43 | ) 44 | ); 45 | 46 | console.log( 47 | `[subscriptionscore]: fetching scores for ${hashedAddresses.length} emails` 48 | ); 49 | for (let i = 0; i < uncached.length; i = i + 20) { 50 | const emails = uncached.slice(i, i + 20); 51 | try { 52 | const options = { 53 | variables: { 54 | emails 55 | } 56 | }; 57 | const { emailScores } = await graphqlRequest(gql, options); 58 | if (emailScores && emailScores.scores) { 59 | // cache the hits and return them 60 | emailScores.scores.forEach(({ rank, email }) => put(email, rank)); 61 | yield mapHashedAddress(hashedAddressMap, emailScores.scores); 62 | } 63 | // cache the misses so we don't re-fetch them for a while 64 | emails 65 | .filter(uc => emailScores.scores.every(s => s.email !== uc)) 66 | .forEach(email => put(email, null)); 67 | } catch (err) { 68 | errors = [...errors, err]; 69 | } 70 | } 71 | return errors; 72 | } 73 | 74 | function mapHashedAddress(addressHashes, hashes) { 75 | return hashes.map(({ email, ...rest }) => ({ 76 | email: addressHashes.find(ah => ah.hash === email).email, 77 | ...rest 78 | })); 79 | } 80 | -------------------------------------------------------------------------------- /src/js/background/index.js: -------------------------------------------------------------------------------- 1 | // import './menus'; 2 | 3 | import { 4 | addIgnoreEmail, 5 | addIgnoreSite, 6 | addSignupAllowedRequest, 7 | addSignupBlockedRequest, 8 | getDomainScore 9 | } from './scores'; 10 | 11 | import browser from 'browser'; 12 | import { getAddressScores } from './addresses'; 13 | import logger from '../utils/logger'; 14 | import { respondToMessage } from 'browser/messages'; 15 | 16 | const popupUrl = browser.runtime.getURL('/popup.html'); 17 | 18 | let currentPage = { 19 | rank: null, 20 | domain: null, 21 | url: null 22 | }; 23 | 24 | browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 25 | if (changeInfo && changeInfo.status === 'complete') { 26 | const { url } = tab; 27 | return onPageChange(url, { inject: true }); 28 | } 29 | }); 30 | 31 | browser.tabs.onActivated.addListener(() => { 32 | browser.tabs.query({ active: true, currentWindow: true }, function(tabs) { 33 | const { url } = tabs[0]; 34 | onPageChange(url); 35 | }); 36 | }); 37 | 38 | browser.runtime.onInstalled.addListener(details => { 39 | if (details.reason === 'install') { 40 | // first install, launch the settings page 41 | const url = '/options.html?welcome=true'; 42 | browser.tabs.create({ url }); 43 | } 44 | }); 45 | 46 | browser.runtime.onMessage.addListener(async (request, sender, sendResponse) => { 47 | if (request.action == 'signup-allowed') { 48 | return addSignupAllowedRequest(currentPage.domain); 49 | } 50 | if (request.action == 'signup-blocked') { 51 | return addSignupBlockedRequest(currentPage.domain); 52 | } 53 | if (request.action === 'get-current-rank') { 54 | return respondToMessage(sendResponse, currentPage); 55 | } 56 | if (request.action === 'ignore-email') { 57 | const emails = request.data; 58 | return addIgnoreEmail(emails); 59 | } 60 | if (request.action === 'ignore-site') { 61 | const domain = request.data; 62 | return addIgnoreSite(domain); 63 | } 64 | if (request.action === 'get-current-url') { 65 | return respondToMessage(sendResponse, currentPage.url); 66 | } 67 | if (request.action === 'fetch-scores') { 68 | const scoresIter = getAddressScores(request.data); 69 | let nextScores = await scoresIter.next(); 70 | while (!nextScores.done) { 71 | browser.tabs.sendMessage(sender.tab.id, { 72 | action: 'fetched-scores', 73 | data: { scores: nextScores.value, emails: request.data } 74 | }); 75 | nextScores = await scoresIter.next(); 76 | } 77 | } 78 | if (request.action === 'log') { 79 | if (sender.id === browser.runtime.id) { 80 | return logger(request.data); 81 | } 82 | } 83 | }); 84 | 85 | // call when the page changes and we need to 86 | // fetch a new rank for the current url 87 | async function onPageChange(url) { 88 | currentPage = { 89 | url 90 | }; 91 | browser.browserAction.setBadgeText({ text: '' }); 92 | if (url.includes('mail.google.com')) { 93 | browser.browserAction.disable(); 94 | browser.browserAction.setPopup({ popup: '' }); 95 | } else if (!/http(s)?:\/\//.test(url)) { 96 | browser.browserAction.disable(); 97 | } else { 98 | browser.browserAction.enable(); 99 | 100 | browser.browserAction.setPopup({ popup: popupUrl }); 101 | try { 102 | logger('fetching score'); 103 | const domainScore = await getDomainScore(url); 104 | if (domainScore) { 105 | const { rank, domain } = domainScore; 106 | currentPage = { 107 | url, 108 | domain, 109 | rank 110 | }; 111 | if (rank) { 112 | browser.browserAction.setBadgeText({ text: rank }); 113 | } 114 | } 115 | } catch (err) { 116 | console.error(err); 117 | } 118 | } 119 | browser.browserAction.setBadgeBackgroundColor({ 120 | color: '#666666' 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /src/js/background/menus.js: -------------------------------------------------------------------------------- 1 | // import { 2 | // getPreference, 3 | // onStorageChange, 4 | // setPreference 5 | // } from '../utils/storage'; 6 | 7 | // import browser from 'browser'; 8 | // import { updateUserPreferences } from '../utils/preferences'; 9 | 10 | // (async () => { 11 | // // intial state 12 | // const alertOnSubmit = await getPreference('alertOnSubmit'); 13 | // const gmailEnabled = await getPreference('gmailEnabled'); 14 | 15 | // browser.contextMenus.create({ 16 | // id: 'show-gmail-ranks', 17 | // type: 'checkbox', 18 | // title: 'Show ranks in Gmail', 19 | // contexts: ['browser_action'], 20 | // checked: gmailEnabled, 21 | // async onclick({ checked }) { 22 | // const prefs = await setPreference('gmailEnabled', checked); 23 | // updateUserPreferences(prefs); 24 | // } 25 | // }); 26 | // browser.contextMenus.create({ 27 | // id: 'show-alerts', 28 | // type: 'checkbox', 29 | // title: 'Show form submit alerts', 30 | // contexts: ['browser_action'], 31 | // checked: alertOnSubmit, 32 | // async onclick({ checked }) { 33 | // if (checked) { 34 | // browser.permissions.request( 35 | // { 36 | // permissions: [], 37 | // origins: [''] 38 | // }, 39 | // async granted => { 40 | // // The callback argument will be true if the user granted the permissions. 41 | // if (granted) { 42 | // const prefs = await setPreference('alertOnSubmit', checked); 43 | // updateUserPreferences(prefs); 44 | // } 45 | // } 46 | // ); 47 | // } else { 48 | // const prefs = await setPreference('alertOnSubmit', checked); 49 | // updateUserPreferences(prefs); 50 | // } 51 | // } 52 | // }); 53 | // })(); 54 | 55 | // onStorageChange(storage => { 56 | // if (storage.preferences && hasChanged('alertOnSubmit', storage)) { 57 | // browser.contextMenus.update('show-alerts', { 58 | // checked: storage.preferences.newValue.alertOnSubmit 59 | // }); 60 | // } 61 | // if (storage.preferences && hasChanged('gmailEnabled', storage)) { 62 | // browser.contextMenus.update('show-gmail-ranks', { 63 | // checked: storage.preferences.newValue.gmailEnabled 64 | // }); 65 | // } 66 | // }); 67 | 68 | // function hasChanged(prop, storage) { 69 | // return ( 70 | // storage.preferences.newValue[prop] !== storage.preferences.oldValue[prop] 71 | // ); 72 | // } 73 | -------------------------------------------------------------------------------- /src/js/background/scores.js: -------------------------------------------------------------------------------- 1 | import { getDomainScore as get, putDomainScore as put } from '../utils/cache'; 2 | import { getItem, pushPreference } from '../utils/storage'; 3 | 4 | import { graphqlRequest } from '../utils/request'; 5 | import isMailProvider from '../utils/is-mail-provider'; 6 | import { updateUserPreferences } from '../utils/preferences'; 7 | 8 | // just get the rank to show in the 9 | // subscription score icon 10 | const gql = ` 11 | query Search($domain: String!) { 12 | searchDomain(domain: $domain) { 13 | rank 14 | domain 15 | } 16 | } 17 | `; 18 | 19 | export async function getDomainScore(url) { 20 | const { hostname: domain } = new URL(url); 21 | if (isMailProvider(domain)) { 22 | return null; 23 | } 24 | const cachedResult = await get(domain); 25 | if (cachedResult && cachedResult.normalizedDomain) { 26 | return { 27 | ...cachedResult, 28 | domain: cachedResult.normalizedDomain 29 | }; 30 | } 31 | const options = { 32 | variables: { 33 | domain 34 | } 35 | }; 36 | const d = await graphqlRequest(gql, options); 37 | const rank = d.searchDomain ? d.searchDomain.rank : null; 38 | // this should always be returned by the gql search query but check in case 39 | const normalizedDomain = d.searchDomain ? d.searchDomain.domain : domain; 40 | put(domain, rank, normalizedDomain); 41 | return d.searchDomain; 42 | } 43 | 44 | const gqlBlocked = ` 45 | mutation SignupRequest($domain: String!, $allowed: Boolean!) { 46 | addSignupRequest(domain: $domain, allowed: $allowed) { 47 | success 48 | } 49 | } 50 | `; 51 | export function addSignupBlockedRequest(domain) { 52 | return graphqlRequest(gqlBlocked, { 53 | variables: { 54 | domain, 55 | allowed: false 56 | } 57 | }); 58 | } 59 | 60 | export function addSignupAllowedRequest(domain) { 61 | return graphqlRequest(gqlBlocked, { 62 | variables: { 63 | domain, 64 | allowed: true 65 | } 66 | }); 67 | } 68 | 69 | export async function addIgnoreEmail(emails) { 70 | let ignoreEmails = emails.length ? emails : [emails]; 71 | const preferences = await getItem('preferences'); 72 | ignoreEmails = ignoreEmails.filter(email => { 73 | return !preferences.ignoredEmailAddresses.includes(email); 74 | }); 75 | if (!ignoreEmails.length) { 76 | return null; 77 | } 78 | pushPreference('ignoredEmailAddresses', ignoreEmails); 79 | const newArr = [...preferences.ignoredEmailAddresses, ...ignoreEmails]; 80 | return updateUserPreferences({ 81 | ...preferences, 82 | ignoredEmailAddresses: newArr 83 | }); 84 | } 85 | export async function addIgnoreSite(domain) { 86 | const preferences = await getItem('preferences'); 87 | if (preferences.ignoredSites.some(d => d === domain)) { 88 | return null; 89 | } 90 | pushPreference('ignoredSites', domain); 91 | const newArr = [...preferences.ignoredSites, domain]; 92 | return updateUserPreferences({ ...preferences, ignoredSites: newArr }); 93 | } 94 | -------------------------------------------------------------------------------- /src/js/browser/chrome/constants.js: -------------------------------------------------------------------------------- 1 | export const VERSION = chrome.app.getDetails().version; 2 | export const VERSION_NAME = chrome.app.getDetails().version_name; 3 | -------------------------------------------------------------------------------- /src/js/browser/chrome/index.js: -------------------------------------------------------------------------------- 1 | const b = chrome; 2 | export default b; 3 | -------------------------------------------------------------------------------- /src/js/browser/chrome/messages.js: -------------------------------------------------------------------------------- 1 | export async function respondToMessage(fn, val) { 2 | return fn(val); 3 | } 4 | -------------------------------------------------------------------------------- /src/js/browser/chrome/storage.js: -------------------------------------------------------------------------------- 1 | chrome.storage.onChanged.addListener(function(changes, namespace) { 2 | chrome.runtime.sendMessage({ 3 | action: 'log', 4 | data: `Changes to storage ${namespace}` 5 | }); 6 | chrome.runtime.sendMessage({ 7 | action: 'log', 8 | data: changes 9 | }); 10 | }); 11 | 12 | export function onStorageChange(fn) { 13 | chrome.storage.onChanged.addListener(fn); 14 | } 15 | 16 | export function removeOnStorageChange(fn) { 17 | chrome.storage.onChanged.removeListener(fn); 18 | } 19 | 20 | export function setItem(data) { 21 | return new Promise(resolve => { 22 | chrome.storage.sync.set(data, () => { 23 | return resolve(data); 24 | }); 25 | }); 26 | } 27 | 28 | export function getItem(key) { 29 | return new Promise(resolve => { 30 | chrome.storage.sync.get([key], result => { 31 | const data = result[key]; 32 | return resolve(data); 33 | }); 34 | }); 35 | } 36 | 37 | export function getItems() { 38 | return new Promise(resolve => { 39 | // pass in null to get the entire contents of storage. 40 | chrome.storage.sync.get(null, result => { 41 | const data = result; 42 | return resolve(data); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/js/browser/firefox/constants.js: -------------------------------------------------------------------------------- 1 | export const VERSION = browser.runtime.getManifest().version; 2 | export const VERSION_NAME = browser.runtime.getManifest().version; 3 | -------------------------------------------------------------------------------- /src/js/browser/firefox/index.js: -------------------------------------------------------------------------------- 1 | const b = browser; 2 | export default b; 3 | -------------------------------------------------------------------------------- /src/js/browser/firefox/messages.js: -------------------------------------------------------------------------------- 1 | export async function respondToMessage(fn, val) { 2 | return val; 3 | } 4 | -------------------------------------------------------------------------------- /src/js/browser/firefox/storage.js: -------------------------------------------------------------------------------- 1 | browser.storage.onChanged.addListener(function(changes, namespace) { 2 | browser.runtime.sendMessage({ 3 | action: 'log', 4 | data: `Changes to storage ${namespace}` 5 | }); 6 | browser.runtime.sendMessage({ 7 | action: 'log', 8 | data: changes 9 | }); 10 | }); 11 | 12 | export function onStorageChange(fn) { 13 | browser.storage.onChanged.addListener(fn); 14 | } 15 | 16 | export function removeOnStorageChange(fn) { 17 | browser.storage.onChanged.removeListener(fn); 18 | } 19 | 20 | export function setItem(data) { 21 | return browser.storage.sync.set(data); 22 | } 23 | 24 | export async function getItem(key) { 25 | const result = await browser.storage.sync.get(key); 26 | return result[key]; 27 | } 28 | 29 | export async function getItems() { 30 | const result = await browser.storage.sync.get(null); 31 | return result; 32 | } 33 | -------------------------------------------------------------------------------- /src/js/components/alerts/alerts.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/common/colors.scss'; 2 | 3 | .alert { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | right: 0; 8 | z-index: 10; 9 | width: 100%; 10 | height: 34px; 11 | padding: 5px 20px; 12 | font-size: 16px; 13 | line-height: 22px; 14 | text-align: center; 15 | margin: 0; 16 | 17 | p { 18 | margin: 0; 19 | } 20 | 21 | &.success { 22 | color: white; 23 | border: 1px solid darken($color-bg-success, 10%); 24 | background-color: $color-bg-success; 25 | } 26 | &.error { 27 | color: white; 28 | border-bottom: 1px solid darken($color-bg-error, 10%); 29 | background-color: $color-bg-error; 30 | } 31 | } 32 | 33 | .content { 34 | max-width: 100%; 35 | text-overflow: ellipsis; 36 | overflow: hidden; 37 | display: block; 38 | white-space: nowrap; 39 | } 40 | 41 | .close { 42 | position: absolute; 43 | right: 10px; 44 | top: 4px; 45 | cursor: pointer; 46 | padding: 2px 3px; 47 | } 48 | -------------------------------------------------------------------------------- /src/js/components/alerts/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from '../../utils/classnames'; 3 | import styles from './alerts.module.scss'; 4 | 5 | const Alert = ({ children, onDismiss = () => {}, success, error }) => { 6 | const classes = cx({ 7 | [styles.alert]: true, 8 | [styles.success]: success, 9 | [styles.error]: error 10 | }); 11 | 12 | return ( 13 |
14 | {children} 15 | 16 | x 17 | 18 |
19 | ); 20 | }; 21 | 22 | export default Alert; 23 | -------------------------------------------------------------------------------- /src/js/components/button/button.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/common/colors.scss'; 2 | @import '../../../styles/mixins/themes.scss'; 3 | 4 | .btn { 5 | @include themify() { 6 | background-color: theme('buttonBackground'); 7 | } 8 | 9 | color: white; 10 | border: 0; 11 | border-radius: 5px; 12 | transition: box-shadow ease-in-out 150ms, transform ease-in-out 150ms, 13 | background-color ease-in-out 50ms; 14 | cursor: pointer; 15 | transform: translateY(0); 16 | padding: 0 20px; 17 | font-size: 14px; 18 | font-weight: bold; 19 | display: inline-flex; 20 | align-items: center; 21 | justify-content: center; 22 | height: 42px; 23 | outline: none; 24 | 25 | &:hover { 26 | @include themify() { 27 | background-color: theme('buttonBackgroundHover'); 28 | } 29 | } 30 | &:focus { 31 | @include themify() { 32 | background-color: theme('buttonBackgroundHover'); 33 | } 34 | } 35 | 36 | &:disabled { 37 | opacity: 0.5; 38 | pointer-events: none; 39 | cursor: no-drop; 40 | } 41 | 42 | &.smaller { 43 | padding: 5px; 44 | height: 20px; 45 | max-height: 100%; 46 | font-size: 12px; 47 | } 48 | 49 | svg { 50 | margin-right: 7px; 51 | } 52 | } 53 | 54 | .muted { 55 | @include themify() { 56 | background-color: transparent; 57 | color: theme('textColor'); 58 | &:hover, 59 | &:focus { 60 | background-color: rgba(theme('buttonBackgroundHover'), 0.1); 61 | } 62 | } 63 | } 64 | 65 | .loading { 66 | pointer-events: none; 67 | 68 | .content { 69 | visibility: hidden; 70 | } 71 | } 72 | 73 | .content { 74 | display: flex; 75 | justify-content: center; 76 | align-items: center; 77 | } 78 | 79 | .pulse { 80 | position: absolute; 81 | display: block; 82 | width: 24px; 83 | height: 24px; 84 | border-radius: 50%; 85 | animation: pulse 1s ease-in-out infinite; 86 | transform: scale(1); 87 | 88 | @include themify() { 89 | background-color: theme('buttonBackgroundPulseFrom'); 90 | } 91 | } 92 | 93 | @keyframes pulse { 94 | 0% { 95 | transform: scale(1); 96 | } 97 | 50% { 98 | transform: scale(0.5); 99 | @include themify() { 100 | background-color: theme('buttonBackgroundPulseTo'); 101 | } 102 | } 103 | 100% { 104 | transform: scale(1); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/js/components/button/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from '../../utils/classnames'; 3 | import styles from './button.module.scss'; 4 | 5 | const Button = ({ 6 | children, 7 | onClick, 8 | as = 'a', 9 | loading = false, 10 | muted = false, 11 | smaller, 12 | outlined = false, 13 | className = '', 14 | ...btnProps 15 | }) => { 16 | const classes = cx({ 17 | [className]: true, 18 | [styles.btn]: true, 19 | [styles.loading]: loading, 20 | [styles.muted]: muted, 21 | [styles.smaller]: smaller, 22 | [styles.outlined]: outlined 23 | }); 24 | if (as === 'a') { 25 | return ( 26 | 27 | {children} 28 | {loading && } 29 | 30 | ); 31 | } 32 | return ( 33 | 37 | ); 38 | }; 39 | 40 | export default Button; 41 | -------------------------------------------------------------------------------- /src/js/components/copy-to-clipboard/index.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState } from 'react'; 2 | 3 | import Button from '../button'; 4 | 5 | const CopyButton = ({ children, string, ...btnProps }) => { 6 | const [isCopied, setCopied] = useState(); 7 | 8 | const onClick = useCallback(() => { 9 | copyToClipboard(string); 10 | setCopied(true); 11 | }, [string]); 12 | 13 | const content = useMemo(() => { 14 | if (isCopied) { 15 | setTimeout(() => { 16 | setCopied(false); 17 | }, 3000); 18 | return Copied!; 19 | } 20 | return children; 21 | }, [children, isCopied]); 22 | return ( 23 | 26 | ); 27 | }; 28 | 29 | export default CopyButton; 30 | 31 | const copyToClipboard = str => { 32 | const el = document.createElement('textarea'); 33 | el.value = str; 34 | el.setAttribute('readonly', ''); 35 | el.style.position = 'absolute'; 36 | el.style.left = '-9999px'; 37 | document.body.appendChild(el); 38 | el.select(); 39 | document.execCommand('copy'); 40 | document.body.removeChild(el); 41 | }; 42 | -------------------------------------------------------------------------------- /src/js/components/domain-score/domain-score.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/common/colors.scss'; 2 | @import '../../../styles/mixins/themes.scss'; 3 | 4 | .domain-score { 5 | @include themify() { 6 | background-color: theme('backgroundColor'); 7 | } 8 | 9 | h2 { 10 | padding: 10px 15px; 11 | } 12 | } 13 | 14 | .loading { 15 | } 16 | 17 | .content { 18 | min-height: 150px; 19 | @include themify() { 20 | background-color: theme('backgroundColorDropped'); 21 | } 22 | 23 | ul { 24 | list-style: none; 25 | } 26 | } 27 | 28 | .empty { 29 | padding: 15px 20px; 30 | } 31 | 32 | .title { 33 | font-size: 18px; 34 | margin-left: 0.5em; 35 | } 36 | 37 | .spinner-container { 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | height: 140px; 42 | } 43 | 44 | .spinner { 45 | display: inline-block; 46 | width: 20px; 47 | height: 20px; 48 | border: 4px solid $color-highlight; 49 | border-top-color: transparent; 50 | border-radius: 50%; 51 | animation-name: spin; 52 | animation: spin 1s linear 0ms infinite; 53 | } 54 | 55 | @keyframes spin { 56 | from { 57 | transform: rotate(0deg); 58 | } 59 | to { 60 | transform: rotate(360deg); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/js/components/domain-score/index.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import Rank from '../rank'; 4 | import Score from '../score'; 5 | import styles from './domain-score.module.scss'; 6 | import useDomainScore from '../../hooks/use-domain-score'; 7 | 8 | export default function DomainScore({ url, isLoading, colorSet }) { 9 | const content = useMemo(() => { 10 | if (isLoading) { 11 | return ( 12 |
13 |
14 |

15 | 16 | Loading... 17 |

18 |
19 |
20 |
21 | ); 22 | } 23 | return ; 24 | }, [url, isLoading, colorSet]); 25 | 26 | return content; 27 | } 28 | 29 | function Content({ url, colorSet }) { 30 | const { value, loading, error, domain } = useDomainScore(url); 31 | 32 | const { title, content, rank } = useMemo(() => { 33 | if (loading) { 34 | return { 35 | title: 'Loading...', 36 | content: ( 37 | 38 | 39 | 40 | ), 41 | rank: 'unknown' 42 | }; 43 | } 44 | if (error) { 45 | return { 46 | title: domain, 47 | content:
{getErrorMessage(error)}
, 48 | rank: 'unknown' 49 | }; 50 | } 51 | const { searchDomain } = value ? value : {}; 52 | if (!searchDomain || !searchDomain.score) { 53 | return { 54 | title: domain, 55 | content: ( 56 |
57 | We don't have enough data to score this website's subscription 58 | emails yet. 59 |
60 | ), 61 | rank: 'unknown' 62 | }; 63 | } 64 | const { rank, domain: normalizedDomain, ...scoreData } = searchDomain; 65 | return { 66 | title: normalizedDomain || domain, 67 | content: ( 68 | 69 | ), 70 | rank 71 | }; 72 | }, [loading, error, value, domain, colorSet]); 73 | 74 | return ( 75 |
76 |
77 |

78 | 79 | {title} 80 |

81 |
82 |
{content}
83 |
84 | ); 85 | } 86 | 87 | function getErrorMessage(error) { 88 | if (error === 'Not Authorised!') { 89 | return ( 90 | 91 | The provided Licence Key is not valid or has been used on too many 92 | devices. 93 | 94 | ); 95 | } else if (error === 'No key!') { 96 | return No licence key provided.; 97 | } else if (error.message === 'Failed to fetch') { 98 | return ( 99 | 100 | Couldn't connect to Subscription Score servers, please try again later. 101 | 102 | ); 103 | } 104 | console.log(error); 105 | return Something went wrong :(; 106 | } 107 | -------------------------------------------------------------------------------- /src/js/components/form/form.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/common/colors.scss'; 2 | @import '../../../styles/mixins/themes.scss'; 3 | 4 | $input-font-size: 18px; 5 | $input-line-height: 26px; 6 | 7 | .input-wrapper { 8 | display: flex; 9 | flex: 1; 10 | flex-direction: column; 11 | } 12 | 13 | .input { 14 | border-radius: 5px; 15 | height: 42px; 16 | padding: 0 5px; 17 | margin-bottom: 0; 18 | width: 100%; 19 | font-size: $input-font-size; 20 | line-height: $input-line-height; 21 | box-sizing: border-box; 22 | outline: 0; 23 | -webkit-appearance: none; 24 | font-family: 'Roboto-Light'; 25 | 26 | @include themify() { 27 | background-color: theme('inputBackground'); 28 | border: 2px solid theme('inputBorder'); 29 | color: theme('textColor'); 30 | } 31 | 32 | &:focus { 33 | @include themify() { 34 | border-color: theme('inputBorderFocus'); 35 | } 36 | } 37 | 38 | &:disabled { 39 | opacity: 0.5; 40 | pointer-events: none; 41 | cursor: no-drop; 42 | } 43 | } 44 | 45 | .input-label { 46 | font-size: 16px; 47 | opacity: 0.8; 48 | padding-left: 5px; 49 | display: block; 50 | margin-bottom: 5px; 51 | } 52 | 53 | .textarea { 54 | border-radius: 5px; 55 | margin-bottom: 0; 56 | width: 100%; 57 | font-size: $input-font-size; 58 | line-height: $input-line-height; 59 | box-sizing: border-box; 60 | outline: 0; 61 | padding: 5px; 62 | resize: vertical; 63 | min-height: 100px; 64 | font-family: 'Roboto-Light'; 65 | 66 | @include themify() { 67 | background-color: theme('inputBackground'); 68 | border: 2px solid theme('inputBorder'); 69 | color: theme('textColor'); 70 | } 71 | 72 | &:focus { 73 | @include themify() { 74 | border-color: theme('inputBorderFocus'); 75 | } 76 | } 77 | 78 | &:disabled { 79 | opacity: 0.5; 80 | pointer-events: none; 81 | cursor: no-drop; 82 | } 83 | } 84 | 85 | .checkbox-wrapper { 86 | position: relative; 87 | } 88 | 89 | .checkbox { 90 | opacity: 0; 91 | position: absolute; 92 | 93 | + .checkbox-label:before { 94 | content: ''; 95 | height: 16px; 96 | width: 16px; 97 | position: absolute; 98 | left: 0; 99 | top: 2px; 100 | border: 1px solid $color-highlight; 101 | 102 | @include themify() { 103 | border-color: theme('highlightColor'); 104 | } 105 | 106 | background-color: white; 107 | border-radius: 3px; 108 | transition: border-color 200ms ease-in-out; 109 | } 110 | 111 | + .checkbox-label:after { 112 | content: ''; 113 | @include themify() { 114 | color: theme('highlightColor'); 115 | } 116 | border: 3px solid; 117 | width: 7px; 118 | height: 15px; 119 | display: block; 120 | border-top: 0; 121 | border-left: 0; 122 | transform: rotate(45deg); 123 | position: absolute; 124 | left: 6px; 125 | top: 1px; 126 | opacity: 0; 127 | transition: opacity 100ms ease-in-out; 128 | } 129 | 130 | &:checked + .checkbox-label:after { 131 | opacity: 1; 132 | } 133 | &:focus + .checkbox-label { 134 | background-color: rgba(0, 0, 0, 0.1); 135 | } 136 | &:focus + .checkbox-label:before { 137 | @include themify() { 138 | border-color: theme('inputBorderFocus'); 139 | } 140 | } 141 | } 142 | 143 | .checkbox-label { 144 | display: inline; 145 | padding-left: 24px; 146 | position: relative; 147 | cursor: pointer; 148 | font-size: 18px; 149 | line-height: 24px; 150 | user-select: none; 151 | } 152 | 153 | .form-notification { 154 | padding: 5px; 155 | border-radius: 5px; 156 | font-size: 18px; 157 | line-height: 18px; 158 | text-align: center; 159 | margin: 10px auto 0 auto; 160 | 161 | p { 162 | margin: 0; 163 | } 164 | } 165 | 166 | .form-error { 167 | @extend .form-notification; 168 | color: white; 169 | border: 1px solid darken($color-bg-error, 10%); 170 | background-color: $color-bg-error; 171 | } 172 | 173 | .form-success { 174 | @extend .form-notification; 175 | color: white; 176 | border: 1px solid darken($color-bg-success, 10%); 177 | background-color: $color-bg-success; 178 | } 179 | 180 | .input-group { 181 | display: flex; 182 | align-items: flex-end; 183 | 184 | input { 185 | width: auto; 186 | margin-right: 10px; 187 | } 188 | 189 | @media (max-width: 400px) { 190 | flex-direction: column; 191 | 192 | input { 193 | margin: 10px 0 0 0; 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/js/components/form/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from '../../utils/classnames'; 3 | import styles from './form.module.scss'; 4 | 5 | export const FormCheckbox = ({ id, name, label, ...props }) => { 6 | return ( 7 | 18 | ); 19 | }; 20 | 21 | export const FormInput = ({ 22 | id, 23 | name, 24 | className = '', 25 | type = 'text', 26 | label, 27 | ...props 28 | }) => { 29 | const classes = cx({ 30 | [className]: true, 31 | [styles.input]: true 32 | }); 33 | return ( 34 | <> 35 | 36 | {label ? ( 37 | 40 | ) : null} 41 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export const FormTextarea = ({ id, name, label, rows = '2', ...props }) => { 55 | return ( 56 | <> 57 | {label ? ( 58 | 61 | ) : null} 62 |