├── .all-contributorsrc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── browserslist.yml │ ├── checks.yml │ └── transifex.yml ├── .gitignore ├── .tx └── config ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _locales └── en │ └── messages.json ├── eslint.config.js ├── images ├── LICENSE ├── github-light.svg ├── github.svg ├── icon-48.png ├── icon-64.png ├── icon-96.png ├── icon.svg ├── large │ ├── README.md │ ├── alert.png │ ├── ci.png │ ├── comment.png │ ├── commit.png │ ├── git-merge.png │ ├── git-pull-request-closed.png │ ├── git-pull-request-draft.png │ ├── git-pull-request-undefined.png │ ├── git-pull-request.png │ ├── issue-closed.png │ ├── issue-notplanned.png │ ├── issue-open.png │ ├── issue-undefined.png │ ├── mail.png │ └── tag.png └── small │ ├── alert.svg │ ├── ci.svg │ ├── comment.svg │ ├── commit.svg │ ├── git-merge.svg │ ├── git-pull-request-closed.svg │ ├── git-pull-request-draft.svg │ ├── git-pull-request-undefined.svg │ ├── git-pull-request.svg │ ├── issue-closed.svg │ ├── issue-notplanned.svg │ ├── issue-open.svg │ ├── issue-undefined.svg │ ├── mail.svg │ └── tag.svg ├── manifest.json ├── options.html ├── package-lock.json ├── package.json ├── popup.html ├── scripts ├── background.js ├── client-manager.js ├── config.js ├── gitea.js ├── github-enterprise-pat.js ├── github-enterprise.js ├── github-light.js ├── github-user-token.js ├── github.js ├── gitlab.js ├── handler.js ├── http-constants.js ├── l10n.js ├── link-utils.js ├── menu-spec.js ├── options.js ├── popup.js ├── storage-manager.js └── storage.js ├── styles ├── options.css └── popup.css └── test ├── _mocks.js ├── client-manager.js ├── github.js ├── storage-manager.js └── storage.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "advanced-github-notifier", 3 | "projectOwner": "freaktechnik", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "types": { 12 | "translation": { 13 | "symbol": "🌍", 14 | "description": "Translation", 15 | "link": "https://www.transifex.com/<%= options.projectOwner %>/<%= options.projectName %>/dashboard/" 16 | } 17 | }, 18 | "contributors": [ 19 | { 20 | "login": "freaktechnik", 21 | "name": "Martin Giger", 22 | "avatar_url": "https://avatars0.githubusercontent.com/u/640949?v=4", 23 | "profile": "https://humanoids.be", 24 | "contributions": [ 25 | "code", 26 | "translation", 27 | "test", 28 | "doc" 29 | ] 30 | }, 31 | { 32 | "login": "gluons", 33 | "name": "Saran Tanpituckpong", 34 | "avatar_url": "https://avatars3.githubusercontent.com/u/4688092?v=4", 35 | "profile": "https://www.google.com/+SaranTanpituckpong", 36 | "contributions": [ 37 | "code", 38 | "bug", 39 | "translation" 40 | ] 41 | }, 42 | { 43 | "login": "edubxb", 44 | "name": "Eduardo Bellido Bellido", 45 | "avatar_url": "https://avatars1.githubusercontent.com/u/1192339?v=4", 46 | "profile": "https://edubxb.net", 47 | "contributions": [ 48 | "code" 49 | ] 50 | }, 51 | { 52 | "login": "Mte90", 53 | "name": "Daniele Scasciafratte", 54 | "avatar_url": "https://avatars2.githubusercontent.com/u/403283?v=4", 55 | "profile": "https://daniele.tech", 56 | "contributions": [ 57 | "bug", 58 | "translation", 59 | "ideas" 60 | ] 61 | }, 62 | { 63 | "login": "Acid-Crash", 64 | "name": "acid-crash", 65 | "avatar_url": "https://avatars3.githubusercontent.com/u/32600318?v=4", 66 | "profile": "https://github.com/Acid-Crash", 67 | "contributions": [ 68 | "bug", 69 | "test" 70 | ] 71 | }, 72 | { 73 | "login": "sid-kap", 74 | "name": "Sid Kapur", 75 | "avatar_url": "https://avatars0.githubusercontent.com/u/6425077?v=4", 76 | "profile": "http://sid-kap.github.io", 77 | "contributions": [ 78 | "bug" 79 | ] 80 | }, 81 | { 82 | "login": "raskchanky", 83 | "name": "Josh Black", 84 | "avatar_url": "https://avatars1.githubusercontent.com/u/947?v=4", 85 | "profile": "http://raskchanky.com", 86 | "contributions": [ 87 | "ideas" 88 | ] 89 | }, 90 | { 91 | "login": "Keith94", 92 | "name": "keith94", 93 | "avatar_url": "https://avatars3.githubusercontent.com/u/5490615?v=4", 94 | "profile": "https://github.com/Keith94", 95 | "contributions": [ 96 | "ideas" 97 | ] 98 | }, 99 | { 100 | "login": "sergioc", 101 | "name": "Sergio", 102 | "avatar_url": "https://avatars1.githubusercontent.com/u/493451?v=4", 103 | "profile": "https://github.com/sergioc", 104 | "contributions": [ 105 | "bug", 106 | "ideas", 107 | "test" 108 | ] 109 | }, 110 | { 111 | "login": "vl.maksime", 112 | "name": "Vladimir Maksimenko", 113 | "avatar_url": "https://secure.gravatar.com/avatar/4feb84897d4178746e4b0a63a79a7dff?s=100&d=identicon", 114 | "profile": "https://www.transifex.com/user/profile/vl.maksime/", 115 | "contributions": [ 116 | "translation" 117 | ] 118 | }, 119 | { 120 | "login": "yfdyh000", 121 | "name": "YFdyh000", 122 | "avatar_url": "https://avatars0.githubusercontent.com/u/1769875?v=4", 123 | "profile": "http://wiki.mozilla.org/User:YFdyh000", 124 | "contributions": [ 125 | "translation" 126 | ] 127 | }, 128 | { 129 | "login": "tw0517tw", 130 | "name": "東曄 吳", 131 | "avatar_url": "https://secure.gravatar.com/avatar/5ede715d039ef2ff3e747ae6ce2a9ff5?s=100&d=identicon", 132 | "profile": "https://www.transifex.com/user/profile/tw0517tw/", 133 | "contributions": [ 134 | "translation" 135 | ] 136 | }, 137 | { 138 | "login": "tooomm", 139 | "name": "tooomm", 140 | "avatar_url": "https://avatars1.githubusercontent.com/u/9874850?v=4", 141 | "profile": "https://github.com/tooomm", 142 | "contributions": [ 143 | "doc", 144 | "bug", 145 | "ideas", 146 | "code" 147 | ] 148 | }, 149 | { 150 | "login": "AlexDafonte", 151 | "name": "Alejandro Dafonte", 152 | "avatar_url": "https://secure.gravatar.com/avatar/0598a2be942c96cbc8fe77232d95389d?s=128&d=identicon", 153 | "profile": "https://www.transifex.com/user/profile/AlexDafonte/", 154 | "contributions": [ 155 | "translation" 156 | ] 157 | }, 158 | { 159 | "login": "Vistaus", 160 | "name": "Heimen Stoffels", 161 | "avatar_url": "https://avatars1.githubusercontent.com/u/1716229?v=4", 162 | "profile": "https://github.com/Vistaus", 163 | "contributions": [ 164 | "translation" 165 | ] 166 | }, 167 | { 168 | "login": "Doryan", 169 | "name": "Doryan R", 170 | "avatar_url": "https://secure.gravatar.com/avatar/22de3450962f68fefa85cfe4d65148e7?s=128&d=identicon", 171 | "profile": "https://www.transifex.com/user/profile/Doryan/", 172 | "contributions": [ 173 | "translation" 174 | ] 175 | }, 176 | { 177 | "login": "domoritz", 178 | "name": "Dominik Moritz", 179 | "avatar_url": "https://avatars2.githubusercontent.com/u/589034?v=4", 180 | "profile": "https://www.domoritz.de", 181 | "contributions": [ 182 | "ideas" 183 | ] 184 | }, 185 | { 186 | "login": "peter-kehl", 187 | "name": "Peter Kehl", 188 | "avatar_url": "https://avatars.githubusercontent.com/u/4270240?v=4", 189 | "profile": "https://www.linkedin.com/in/PeterKehl", 190 | "contributions": [ 191 | "bug" 192 | ] 193 | } 194 | ], 195 | "contributorsPerLine": 7, 196 | "commitConvention": "none" 197 | } 198 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | # C-style doc comments for eclint 13 | block_comment_start = /* 14 | block_comment = * 15 | block_comment_end = */ 16 | 17 | [{package.json,*.yml,package-lock.json,.all-contributorsrc}] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Firefox (please complete the following information):** 28 | 29 | - OS: [e.g. Linux] 30 | - Firefox Version: [e.g. 22] 31 | - Extension version: [e.g. 2.1.0] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore:" 9 | open-pull-requests-limit: 5 10 | versioning-strategy: "increase" 11 | groups: 12 | eslint-configs: 13 | dependency-type: "development" 14 | patterns: 15 | - "@freaktechnik/eslint-config*" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | commit-message: 21 | prefix: "chore:" 22 | -------------------------------------------------------------------------------- /.github/workflows/browserslist.yml: -------------------------------------------------------------------------------- 1 | name: Update browserslist 2 | on: 3 | schedule: 4 | - cron: "15 14 * * 3" 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | update: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: latest 19 | cache: 'npm' 20 | - name: Update browsers list 21 | run: npx --yes browserslist --update-db 22 | - name: Save updated db 23 | run: | 24 | git config user.name github-actions 25 | git config user.email github-actions@github.com 26 | git add package-lock.json 27 | git commit -m "chore: bump browserslist" 28 | git push origin HEAD:main 29 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: [push, pull_request] 3 | jobs: 4 | lint-js: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-node@v4 9 | with: 10 | node-version: 'lts/*' 11 | cache: 'npm' 12 | - run: npm ci --no-audit 13 | - run: npm run lint:js 14 | lint-css: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 'lts/*' 21 | cache: 'npm' 22 | - run: npm ci --no-audit 23 | - run: npm run lint:css 24 | lint-webext: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: freaktechnik/web-ext-lint@v1 29 | test: 30 | runs-on: ubuntu-latest 31 | needs: [ lint-js, lint-css, lint-webext ] 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 'lts/*' 37 | cache: 'npm' 38 | - run: npm ci --no-audit 39 | - run: npm run test:js 40 | - run: npm run coverage 41 | - uses: codecov/codecov-action@v5 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/transifex.yml: -------------------------------------------------------------------------------- 1 | name: transifex 2 | on: 3 | push: 4 | paths: 5 | - _locales/en/messages.json 6 | branches: 7 | - main 8 | jobs: 9 | push-messages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: transifex/cli-action@v2 14 | with: 15 | token: ${{ secrets.TX_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Coverage directory used by tools like istanbul 7 | coverage 8 | 9 | # nyc test coverage 10 | .nyc_output 11 | 12 | # Dependency directories 13 | node_modules 14 | jspm_packages 15 | 16 | # Optional npm cache directory 17 | .npm 18 | 19 | 20 | scripts/config.js 21 | web-ext-artifacts 22 | 23 | _locales 24 | !_locales/en/messages.json 25 | *~ 26 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [o:freaktechnik:p:advanced-github-notifier:r:messages] 5 | file_filter = _locales//messages.json 6 | source_file = _locales/en/messages.json 7 | source_lang = en 8 | type = CHROME 9 | 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AGHN 2 | 3 | ## Translations 4 | The strings for this extension can be translated on [Transifex](https://www.transifex.com/freaktechnik/advanced-github-notifier/). 5 | 6 | ## Issues etc. 7 | Feel free to file issues and describe your problem. I'll likely try to solve it 8 | when I find time for it. Or maybe someone else will step up and fix it :) 9 | 10 | ## Writing code 11 | In theory all you should have to do is run `npm ci` (after installing npm and node) 12 | and you should be able to launch a Firefox instance with the extension in debugging 13 | mode with live reloading with `npm start`. 14 | 15 | There are some linters and tests. `npm test` runs all linters and tests. 16 | 17 | ### Updating config.js 18 | 19 | To avoid comitting production values in `config.js`, make sure to ignore it with 20 | ```bash 21 | git update-index --assume-unchanged scripts/config.js 22 | ``` 23 | 24 | If you want to commit a change to the checked in version, you can use the following to 25 | track changes again: 26 | ```bash 27 | git update-index --no-assume-unchanged scripts/config.js 28 | ``` 29 | 30 | ### License 31 | All code should be licensed under the [MPL-2.0](LICENSE). By submitting a pull 32 | request you agree that your code is licensed that way. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![](images/icon-48.png) Advanced GitHub Notifier 2 | 3 | [![Add-On Version](https://img.shields.io/amo/v/advanced-github-notifier.svg)](https://addons.mozilla.org/addon/advanced-github-notifier/?utm_source=github&utm_content=version) [![AMO Rating](https://img.shields.io/amo/stars/advanced-github-notifier.svg)](https://addons.mozilla.org/addon/advanced-github-notifier/?utm_source=github&utm_content=rating) [![AMO User Count](https://img.shields.io/amo/users/advanced-github-notifier.svg)](https://addons.mozilla.org/addon/advanced-github-notifier/?utm_source=github&utm_content=users) [![AMO Download Count](https://img.shields.io/amo/d/advanced-github-notifier.svg)](https://addons.mozilla.org/addon/advanced-github-notifier/?utm_source=ghdownloads)
4 | [![codecov](https://codecov.io/gh/freaktechnik/advanced-github-notifier/graph/badge.svg?token=i1mW9Zwa89)](https://codecov.io/gh/freaktechnik/advanced-github-notifier) 5 | 6 | A Firefox extension, that not only shows a count of notifications, but also 7 | shows notification popups and has a popup that gives direct access to the 8 | notifications. Supports github.com, GitHub Enterprise, GitLab and Gitea. 9 | 10 | ## Installation 11 | 12 | A stable release version is availabe here: 13 | 14 | [![addons.mozilla.org/](https://addons.cdn.mozilla.net/static/img/addons-buttons/AMO-button_2.png)](https://addons.mozilla.org/addon/advanced-github-notifier/?utm_source=github&utm_content=installation) 15 | 16 | To run the in-development version from this repository, you either need to use 17 | about:debugging or the `web-ext` tool. Further the API credentials stored in `config.js` are not 18 | included in this repo. 19 | 20 | ### Pre-configuring a GitHub enterprise OAuth app 21 | 22 | You can pre-configure an OAuth app to authenticate against your enterprise installation using [Firefox Enterprise Policies](https://support.mozilla.org/en-US/kb/enforcing-policies-firefox-enterprise). The policy should look something like this (or equivalent registry keys, however that works): 23 | 24 | ```json 25 | { 26 | "policies": { 27 | "3rdparty": { 28 | "Extensions": { 29 | "{8d4b86c5-64bf-4780-b029-0112386735ab}": { 30 | "enterprise": { 31 | "instanceURL": "Base URL of your GitHub enterprise instance (HTTPS only)", 32 | "clientId": "Client ID of the OAuth app", 33 | "clientSecret": "Client secret of the OAuth app" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | ``` 41 | 42 | The OAuth app's redirect URL should be set to `https://8317bdea4958553dcce6194bd09e3d5a2b504f5b.extensions.allizom.org/login` for the release version of this extension. 43 | 44 | ## Contributing 45 | 46 | Please check the [CONTRIBUTING.md](CONTRIBUTING.md) 47 | 48 | ## License 49 | 50 | This extension is licensed under the [MPL-2.0](LICENSE), the octocat and octicons 51 | are licensed under the [MIT license](images/LICENSE) according to their source. 52 | 53 | This product is not developed or run by GitHub. It is a hobbyist project that 54 | uses the official GitHub API to display information about the notifications 55 | of a user on the GitHub platform. GitHub and the associated imagery are subject 56 | to copyright and trademarks of GitHub, Inc. 57 | 58 | ## Contributors 59 | 60 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |

Martin Giger

💻 🌍 ⚠️ 📖

Saran Tanpituckpong

💻 🐛 🌍

Eduardo Bellido Bellido

💻

Daniele Scasciafratte

🐛 🌍 🤔

acid-crash

🐛 ⚠️

Sid Kapur

🐛

Josh Black

🤔

keith94

🤔

Sergio

🐛 🤔 ⚠️

Vladimir Maksimenko

🌍

YFdyh000

🌍

東曄 吳

🌍

tooomm

📖 🐛 🤔 💻

Alejandro Dafonte

🌍

Heimen Stoffels

🌍

Doryan R

🌍

Dominik Moritz

🤔

Peter Kehl

🐛
91 | 92 | 93 | 94 | 95 | 96 | 97 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 98 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": { 3 | "message": "Advanced GitHub Notifier", 4 | "description": "Name of the extension" 5 | }, 6 | "description": { 7 | "message": "Shows notifications when you get a new notification on GitHub and provides quick access in a popup.", 8 | "description": "Description of the extension" 9 | }, 10 | "actionTitle": { 11 | "message": "GitHub Notifications", 12 | "description": "Button tooltip" 13 | }, 14 | "footer_all": { 15 | "message": "Show All Notifications", 16 | "description": "Button label to open the GitHub notifications page" 17 | }, 18 | "footer_unread": { 19 | "message": "Show Unread Notifications", 20 | "description": "Button label to open the GitHub unread notifications page" 21 | }, 22 | "footer_watched": { 23 | "message": "Show Watched Repositories", 24 | "description": "Button label to open the GitHub watched repositories manager" 25 | }, 26 | "footer_index": { 27 | "message": "Show GitHub", 28 | "description": "Button label to open the GitHub frontpage" 29 | }, 30 | "footer_options": { 31 | "message": "Show Add-on Options", 32 | "description": "Button label to open the add-on options" 33 | }, 34 | "footer_participating": { 35 | "message": "Show Participating Notifications", 36 | "description": "Button label to open the GitHub participating notifications page" 37 | }, 38 | "noNotifications": { 39 | "message": "No notifications", 40 | "description": "Empty list text in popup" 41 | }, 42 | "markAllRead": { 43 | "message": "Mark all as read", 44 | "description": "Button to mark all panel items as read" 45 | }, 46 | "logout": { 47 | "message": "Log out", 48 | "description": "Label of button to log out from the extension" 49 | }, 50 | "showNotifications": { 51 | "message": "Show desktop notifications", 52 | "description": "Label for the settings checkbox to control if the extension shows notifications" 53 | }, 54 | "footerLabel": { 55 | "message": "Footer links to", 56 | "description": "Label for the footer function select in the options" 57 | }, 58 | "footerGithub_label": { 59 | "message": "GitHub", 60 | "description": "Label of the GitHub options group in the footer function select in the options" 61 | }, 62 | "footerLocal_label": { 63 | "message": "Local", 64 | "description": "Label of the Local options group in the footer function select in the options" 65 | }, 66 | "footerSelectAll": { 67 | "message": "All notifications", 68 | "description": "Label for the all notifications option in the footer function select in the options" 69 | }, 70 | "footerSelectUnread": { 71 | "message": "Unread notifications", 72 | "description": "Label for the unread notifications option in the footer function select in the options" 73 | }, 74 | "footerSelectParticipating": { 75 | "message": "Participating notifications", 76 | "description": "Label for the participating notifications option in the footer function select in the options" 77 | }, 78 | "footerSelectWatched": { 79 | "message": "Watched repositories", 80 | "description": "Label for the watched repositories option in the footer function select in the options" 81 | }, 82 | "footerSelectIndex": { 83 | "message": "GitHub frontpage", 84 | "description": "Label for the GitHub frontpage option in the footer function select in the options" 85 | }, 86 | "footerSelectOptions": { 87 | "message": "Add-on Options", 88 | "description": "Label for the add-on options option in the footer function select in the options" 89 | }, 90 | "footerSelectHidden": { 91 | "message": "Hide footer button", 92 | "description": "Label for the hidden button option in the footer function select in the options" 93 | }, 94 | "markAsRead": { 95 | "message": "&Mark as read", 96 | "description": "Mark as read context menu item on individual notifications" 97 | }, 98 | "unwatch": { 99 | "message": "&Unsubscribe", 100 | "description": "Context menu item to unwatch a thread (issue etc.)" 101 | }, 102 | "ignore": { 103 | "message": "&Ignore", 104 | "description": "Context menu item to ignore a thread (don't resubscribe)" 105 | }, 106 | "account_github": { 107 | "message": "GitHub" 108 | }, 109 | "account_enterprise": { 110 | "message": "GitHub Enterprise (OAuth)" 111 | }, 112 | "account_github-light": { 113 | "message": "GitHub (no private repos)" 114 | }, 115 | "account_github-user": { 116 | "message": "GitHub (Personal access token)" 117 | }, 118 | "account_enterprise-preconfig": { 119 | "message": "GitHub Enterprise ($1)" 120 | }, 121 | "account_enterprise-pat": { 122 | "message": "GitHub Enterprise (Personal access token)" 123 | }, 124 | "account_gitlab": { 125 | "message": "GitLab (Token)" 126 | }, 127 | "account_gitea": { 128 | "message": "Gitea/Forgejo (Token)" 129 | }, 130 | "addAccount": { 131 | "message": "Add account" 132 | }, 133 | "allAccounts": { 134 | "message": "All accounts" 135 | }, 136 | "clientID": { 137 | "message": "Client ID" 138 | }, 139 | "clientSecret": { 140 | "message": "Client secret" 141 | }, 142 | "instanceURL": { 143 | "message": "Instance URL" 144 | }, 145 | "userToken": { 146 | "message": "Personal access token" 147 | }, 148 | "generate_token": { 149 | "message": "Create personal access token" 150 | }, 151 | "token_scopes": { 152 | "message": "The token must at least have the \"notifications\" scope. For private repos it needs \"repo\" to function correctly." 153 | }, 154 | "enterprise_redirect": { 155 | "message": "The redirect URL is \"$URL$\"", 156 | "placeholders": { 157 | "url": { 158 | "content": "$1", 159 | "example": "https://asdf.extensions.allizom.org/login" 160 | } 161 | } 162 | }, 163 | "account_type": { 164 | "message": "Account type" 165 | }, 166 | "group_github_label": { 167 | "message": "GitHub.com" 168 | }, 169 | "group_enterprise_label": { 170 | "message": "GitHub Enterprise" 171 | }, 172 | "group_others_label": { 173 | "message": "Others" 174 | }, 175 | "remove": { 176 | "message": "Remove" 177 | }, 178 | "shortcutDescription": { 179 | "message": "Open popup with unread notifications" 180 | }, 181 | "showBadge": { 182 | "message": "Show unread count as badge on button" 183 | }, 184 | "status_open": { 185 | "message": "Open" 186 | }, 187 | "status_closed": { 188 | "message": "Closed" 189 | }, 190 | "status_merged": { 191 | "message": "Merged" 192 | }, 193 | "status_wip": { 194 | "message": "Draft" 195 | }, 196 | "status_undefined": { 197 | "message": "Unknown" 198 | }, 199 | "status_notplanned": { 200 | "message": "Closed as not planned" 201 | }, 202 | "type_invite": { 203 | "message": "Repository invitation" 204 | }, 205 | "type_issue": { 206 | "message": "Issue" 207 | }, 208 | "type_pull": { 209 | "message": "Pull request" 210 | }, 211 | "type_tag": { 212 | "message": "Release" 213 | }, 214 | "type_security": { 215 | "message": "Security Alert" 216 | }, 217 | "type_discussion": { 218 | "message": "Discussion" 219 | }, 220 | "type_commit": { 221 | "message": "Commit" 222 | }, 223 | "type_ci": { 224 | "message": "CI" 225 | }, 226 | "token_scopes_gitlab": { 227 | "message": "The token must at least have the \"read_api\" scope. For marking todos as done \"api\" is required." 228 | }, 229 | "error_host_enterprise": { 230 | "message": "Can not OAuth without host permission for Enterprise instance" 231 | }, 232 | "error_host_gitea": { 233 | "message": "Host permission required to ensure Gitea API can be interacted with" 234 | }, 235 | "username_instance": { 236 | "message": "$USERNAME$ ($URL$)", 237 | "placeholders": { 238 | "username": { 239 | "content": "$1", 240 | "example": "freaktechnik" 241 | }, 242 | "url": { 243 | "content": "$2", 244 | "example": "https://github.com/" 245 | } 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import freaktechnikConfigExtension from "@freaktechnik/eslint-config-extension"; 2 | import freaktechnikConfigTest from "@freaktechnik/eslint-config-test"; 3 | import freaktechnikConfigNode from "@freaktechnik/eslint-config-node"; 4 | 5 | const LAST_ITEM = -1; 6 | 7 | export default [ 8 | ...freaktechnikConfigExtension, 9 | ...freaktechnikConfigTest, 10 | freaktechnikConfigNode.at(LAST_ITEM), 11 | { 12 | files: [ "scripts/**/*.js" ], 13 | rules: { 14 | "one-var": "off", 15 | }, 16 | }, 17 | { 18 | ignores: [ "scripts/config.js" ], 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /images/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GitHub Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /images/github-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/icon-48.png -------------------------------------------------------------------------------- /images/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/icon-64.png -------------------------------------------------------------------------------- /images/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/icon-96.png -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 76 | -------------------------------------------------------------------------------- /images/large/README.md: -------------------------------------------------------------------------------- 1 | These icons are 128px renders of the 24px SVG octicons. 2 | -------------------------------------------------------------------------------- /images/large/alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/alert.png -------------------------------------------------------------------------------- /images/large/ci.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/ci.png -------------------------------------------------------------------------------- /images/large/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/comment.png -------------------------------------------------------------------------------- /images/large/commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/commit.png -------------------------------------------------------------------------------- /images/large/git-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/git-merge.png -------------------------------------------------------------------------------- /images/large/git-pull-request-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/git-pull-request-closed.png -------------------------------------------------------------------------------- /images/large/git-pull-request-draft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/git-pull-request-draft.png -------------------------------------------------------------------------------- /images/large/git-pull-request-undefined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/git-pull-request-undefined.png -------------------------------------------------------------------------------- /images/large/git-pull-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/git-pull-request.png -------------------------------------------------------------------------------- /images/large/issue-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/issue-closed.png -------------------------------------------------------------------------------- /images/large/issue-notplanned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/issue-notplanned.png -------------------------------------------------------------------------------- /images/large/issue-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/issue-open.png -------------------------------------------------------------------------------- /images/large/issue-undefined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/issue-undefined.png -------------------------------------------------------------------------------- /images/large/mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/mail.png -------------------------------------------------------------------------------- /images/large/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/advanced-github-notifier/a49c0786589801563f0edbe2916e462144a381aa/images/large/tag.png -------------------------------------------------------------------------------- /images/small/alert.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/ci.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/comment.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/commit.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/git-merge.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/git-pull-request-closed.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/git-pull-request-draft.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/git-pull-request-undefined.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/git-pull-request.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/issue-closed.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/issue-notplanned.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/issue-open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/issue-undefined.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/small/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/small/tag.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "strict_min_version": "112.0", 5 | "id": "{8d4b86c5-64bf-4780-b029-0112386735ab}" 6 | } 7 | }, 8 | "manifest_version": 2, 9 | "background": { 10 | "scripts": [ 11 | "scripts/background.js" 12 | ], 13 | "type": "module" 14 | }, 15 | "browser_action": { 16 | "browser_style": true, 17 | "default_title": "__MSG_actionTitle__", 18 | "default_popup": "popup.html", 19 | "default_icon": "images/github.svg", 20 | "theme_icons": [ 21 | { 22 | "dark": "images/github.svg", 23 | "light": "images/github-light.svg", 24 | "size": 19 25 | } 26 | ] 27 | }, 28 | "content_security_policy": "default-src 'self'; connect-src https://api.github.com https://github.com https://*; object-src 'none'; img-src 'self' data:", 29 | "default_locale": "en", 30 | "description": "__MSG_description__", 31 | "name": "__MSG_name__", 32 | "permissions": [ 33 | "identity", 34 | "notifications", 35 | "alarms", 36 | "storage", 37 | "https://github.com/login/oauth/access_token", 38 | "menus", 39 | "menus.overrideContext" 40 | ], 41 | "optional_permissions": [ 42 | "https://*/*" 43 | ], 44 | "version": "1.10.2", 45 | "icons": { 46 | "48": "images/icon-48.png", 47 | "64": "images/icon-64.png", 48 | "96": "images/icon-96.png" 49 | }, 50 | "options_ui": { 51 | "page": "options.html", 52 | "browser_style": true 53 | }, 54 | "commands": { 55 | "_execute_browser_action": { 56 | "suggested_key": { 57 | "default": "Alt+G" 58 | }, 59 | "description": "__MSG_shortcutDescription__" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Manage accounts

14 |
15 | 16 | 31 | 46 | 54 | 65 | 76 | 86 | 87 | 88 |
89 | 91 |
92 |
93 |

Global settings

94 |
95 | 96 | 97 | 98 | 99 |
100 |
101 | 102 | 103 | 104 | 105 |
106 |
107 | 108 | 121 |
122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advanced-github-notifier", 3 | "version": "1.10.1", 4 | "description": "A Firefox extension, that not only shows a count of notifications, but also shows notification popups and has a popup that gives direct access to the notifications.", 5 | "main": "manifest.json", 6 | "scripts": { 7 | "lint:js": "eslint scripts/ test/ manifest.json", 8 | "lint:css": "stylelint \"styles/*.css\"", 9 | "lint:webext": "web-ext lint", 10 | "lint": "npm run lint:js && npm run lint:css && npm run lint:webext", 11 | "test:js": "c8 ava", 12 | "test": "npm run lint && npm run test:js", 13 | "start": "web-ext run", 14 | "build": "tx pull && web-ext build", 15 | "coverage": "c8 report -r lcov" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/freaktechnik/advanced-github-notifier.git" 20 | }, 21 | "author": "Martin Giger", 22 | "license": "MPL-2.0", 23 | "bugs": { 24 | "url": "https://github.com/freaktechnik/advanced-github-notifier/issues" 25 | }, 26 | "files": [ 27 | "scripts/*", 28 | "styles/*", 29 | "images/*", 30 | "manifest.json", 31 | "*.html" 32 | ], 33 | "homepage": "https://github.com/freaktechnik/advanced-github-notifier#readme", 34 | "devDependencies": { 35 | "@freaktechnik/eslint-config-extension": "^10.2.0", 36 | "@freaktechnik/eslint-config-node": "^10.2.0", 37 | "@freaktechnik/eslint-config-test": "^10.2.0", 38 | "ava": "^6.3.0", 39 | "c8": "^10.1.3", 40 | "eslint": "^9.28.0", 41 | "sinon": "^20.0.0", 42 | "sinon-chrome": "^3.0.1", 43 | "stylelint": "^16.20.0", 44 | "stylelint-config-standard": "^38.0.0", 45 | "stylelint-no-unsupported-browser-features": "^8.0.4", 46 | "web-ext": "^8.7.1" 47 | }, 48 | "stylelint": { 49 | "extends": "stylelint-config-standard", 50 | "plugins": [ 51 | "stylelint-no-unsupported-browser-features" 52 | ], 53 | "rules": { 54 | "color-named": "always-where-possible", 55 | "plugin/no-unsupported-browser-features": true 56 | } 57 | }, 58 | "nyc": { 59 | "reporter": [ 60 | "lcov", 61 | "text" 62 | ] 63 | }, 64 | "engines": { 65 | "node": ">= 21" 66 | }, 67 | "private": true, 68 | "browserslist": [ 69 | "last 1 Firefox versions", 70 | "last 1 FirefoxAndroid versions" 71 | ], 72 | "webExt": { 73 | "ignoreFiles": [ 74 | "test", 75 | "coverage", 76 | "package*.json", 77 | "node_modules", 78 | ".*", 79 | "web-ext-artifacts", 80 | "*.md", 81 | "eslint.config.js" 82 | ] 83 | }, 84 | "ava": { 85 | "timeout": "2m" 86 | }, 87 | "type": "module" 88 | } 89 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 14 |
15 |
16 |
17 |
18 | No notifications 19 |
20 |
21 |
22 | 23 | 24 |
25 |
26 | Mark all as read 27 |
28 |
29 |
30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /scripts/background.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import ClientManager from "./client-manager.js"; 8 | import { MENU_SPEC } from "./menu-spec.js"; 9 | import GitHub from "./github.js"; 10 | 11 | const manager = new ClientManager(), 12 | MISSING_AUTH = '?', 13 | BASE = 10; 14 | 15 | browser.notifications.onShown.addListener(() => { 16 | browser.runtime.sendMessage("@notification-sound", "new-notification"); 17 | }); 18 | 19 | //TODO open latest comment? 20 | 21 | const updateBadge = async (count) => { 22 | const { disableBadge = false } = await browser.storage.local.get('disableBadge'); 23 | let text = MISSING_AUTH; 24 | if(count !== undefined) { 25 | if(disableBadge) { 26 | text = ""; 27 | } 28 | else { 29 | text = count ? count.toString(BASE) : ""; 30 | } 31 | } 32 | 33 | return browser.browserAction.setBadgeText({ 34 | text, 35 | }); 36 | }; 37 | 38 | const getNotifications = async (alarm) => { 39 | if(navigator.onLine) { 40 | const handler = manager.getClientById(alarm.name); 41 | try { 42 | const update = await handler.check(); 43 | if(update) { 44 | await updateBadge(await manager.getCount()); 45 | } 46 | } 47 | catch(error) { 48 | console.error(error); 49 | } 50 | finally { 51 | browser.alarms.create(handler.STORE_PREFIX, { 52 | when: handler.getNextCheckTime(), 53 | }); 54 | } 55 | } 56 | else { 57 | globalThis.addEventListener('online', () => getNotifications(alarm), { 58 | once: true, 59 | capture: false, 60 | passive: true, 61 | }); 62 | } 63 | }; 64 | 65 | const setupNotificationWorker = (handler) => { 66 | browser.alarms.onAlarm.addListener(getNotifications); 67 | return getNotifications({ 68 | name: handler.STORE_PREFIX, 69 | }); 70 | }; 71 | 72 | const setupNotificationWorkers = () => Promise.all(Array.from(manager.getClients(), setupNotificationWorker)); 73 | 74 | const openNotification = async (id) => { 75 | const handler = manager.getClientForNotificationID(id); 76 | const url = await handler.getNotificationURL(id); 77 | if(url) { 78 | const tab = await browser.tabs.create({ 79 | url, 80 | }); 81 | await browser.windows.update(tab.windowId, { 82 | focused: true, 83 | }); 84 | if(await handler.willAutoMarkAsRead(id)) { 85 | await handler.markAsRead(id, false); 86 | } 87 | const newCount = await manager.getCount(); 88 | await updateBadge(newCount); 89 | } 90 | }; 91 | browser.notifications.onClicked.addListener(openNotification); 92 | 93 | const needsAuth = () => { 94 | browser.browserAction.setPopup({ 95 | popup: "", 96 | }); 97 | updateBadge(); 98 | browser.browserAction.onClicked.addListener(() => { 99 | browser.runtime.openOptionsPage(); 100 | }); 101 | }; 102 | 103 | const afterAdd = async (handler) => { 104 | setupNotificationWorker(handler); 105 | 106 | const popupURL = await browser.browserAction.getPopup({}); 107 | if(popupURL === "") { 108 | browser.browserAction.setPopup({ 109 | popup: browser.runtime.getURL('popup.html'), 110 | }); 111 | await updateBadge(); 112 | } 113 | }; 114 | 115 | const createHandler = async (type, details) => { 116 | const handler = await ClientManager.createClient(type, undefined, details); 117 | if(await handler.login()) { 118 | manager.addClient(handler); 119 | await afterAdd(handler); 120 | } 121 | }; 122 | 123 | /** 124 | * A runtime.onMessage handler that runs asynchronously, without sending a response. 125 | * 126 | * @param {any} message - Message from another part of the extension. 127 | * @returns {undefined} 128 | */ 129 | const handleMessage = async (message) => { 130 | switch(message.topic) { 131 | case "open-notification": 132 | await openNotification(message.notificationId); 133 | break; 134 | case "open-notifications": { 135 | const { footer } = await browser.storage.local.get({ 136 | "footer": "all", 137 | }); 138 | if(footer == "options") { 139 | await browser.runtime.openOptionsPage(); 140 | break; 141 | } 142 | else if(footer in GitHub.FOOTER_URLS) { 143 | await browser.tabs.create({ url: GitHub.FOOTER_URLS[footer] }); 144 | break; 145 | } 146 | throw new Error(`No matching footer action implemented for '${footer}'`); 147 | } 148 | case "mark-all-read": 149 | await Promise.all(Array.from(manager.getClients(), (handler) => handler.markAsRead())); 150 | await updateBadge(''); 151 | break; 152 | case "mark-notification-read": { 153 | const handler = manager.getClientForNotificationID(message.notificationId); 154 | await handler.markAsRead(message.notificationId); 155 | const count = await manager.getCount(); 156 | await updateBadge(count); 157 | break; 158 | } 159 | case "unsubscribe-notification": { 160 | const handler = manager.getClientForNotificationID(message.notificationId); 161 | await handler.unsubscribeNotification(message.notificationId); 162 | break; 163 | } 164 | case "ignore-notification": { 165 | const handler = manager.getClientForNotificationID(message.notificationId); 166 | await handler.ignoreNotification(message.notificationId); 167 | break; 168 | } 169 | case "logout": { 170 | const handler = manager.getClientById(message.handlerId); 171 | try { 172 | await handler.logout(true); 173 | } 174 | catch(error) { 175 | console.error(error); 176 | } 177 | manager.removeClient(handler); 178 | if(!manager.clients.size) { 179 | needsAuth(); 180 | } 181 | break; 182 | } 183 | case "login": 184 | return createHandler(message.type, message.details); 185 | default: 186 | } 187 | }; 188 | 189 | const handleStorageChange = async (changes) => { 190 | await browser.menus.update('badge', { 191 | type: 'checkbox', 192 | checked: !changes.disableBadge.newValue, 193 | }); 194 | const [ 195 | currentText, 196 | count, 197 | ] = await Promise.all([ 198 | browser.browserAction.getBadgeText({}), 199 | manager.getCount(), 200 | ]); 201 | if(currentText === MISSING_AUTH) { 202 | await updateBadge(); 203 | } 204 | else { 205 | await updateBadge(count); 206 | } 207 | }; 208 | 209 | browser.runtime.onMessage.addListener((message) => { 210 | handleMessage(message).catch(console.error); 211 | }); 212 | 213 | browser.runtime.onInstalled.addListener(async (details) => { 214 | if(details.reason === "update") { 215 | const { token } = await browser.storage.local.get("token"); 216 | if(token) { 217 | const handler = ClientManager.createClient(ClientManager.GITHUB); 218 | await handler.setValue("token", token); 219 | const authValid = await handler.checkAuth(); 220 | if(authValid) { 221 | manager.addClient(handler); 222 | await afterAdd(handler); 223 | } 224 | 225 | await browser.storage.local.remove("token"); 226 | } 227 | } 228 | }); 229 | 230 | const init = async () => { 231 | const count = await manager.getInstances(); 232 | if(count) { 233 | await setupNotificationWorkers(); 234 | } 235 | else { 236 | needsAuth(); 237 | } 238 | }; 239 | 240 | browser.storage.onChanged.addListener((changes, area) => { 241 | if(area === 'local' && changes.disableBadge) { 242 | try { 243 | handleStorageChange(changes); 244 | } 245 | catch(error) { 246 | console.error(error); 247 | } 248 | } 249 | }); 250 | 251 | browser.menus.onClicked.addListener(({ 252 | menuItemId, checked, 253 | }) => { 254 | if(menuItemId === 'badge') { 255 | browser.storage.local.set({ 256 | disableBadge: !checked, 257 | }); 258 | } 259 | }); 260 | 261 | globalThis.requestIdleCallback(async () => { 262 | for(const [ 263 | id, 264 | messageId, 265 | ] of Object.entries(MENU_SPEC)) { 266 | browser.menus.create({ 267 | viewTypes: [ 'popup' ], 268 | documentUrlPatterns: [ browser.runtime.getURL('popup.html') ], 269 | id, 270 | title: browser.i18n.getMessage(messageId), 271 | enabled: false, 272 | }); 273 | } 274 | const { disableBadge = false } = await browser.storage.local.get('disableBadge'); 275 | browser.menus.create({ 276 | contexts: [ 'browser_action' ], 277 | title: browser.i18n.getMessage('showBadge'), 278 | type: 'checkbox', 279 | id: 'badge', 280 | checked: !disableBadge, 281 | }); 282 | if(navigator.onLine) { 283 | await init().catch(console.error); 284 | } 285 | else { 286 | // If we can't retrieve the accounts, wait for internet and try again. 287 | const records = await manager.getRecords().catch(() => [ 288 | 'foo', 289 | 'bar', 290 | ]); 291 | if(records.length) { 292 | globalThis.addEventListener("online", () => { 293 | init().catch(console.error); 294 | }, { 295 | passive: true, 296 | capture: false, 297 | once: true, 298 | }); 299 | } 300 | else { 301 | needsAuth(); 302 | } 303 | } 304 | }); 305 | -------------------------------------------------------------------------------- /scripts/client-manager.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import { 8 | clientId, 9 | clientSecret, 10 | } from './config.js'; 11 | import StorageManager from './storage-manager.js'; 12 | import ClientHandler from './handler.js'; 13 | import GitHubEnterpriseUserToken from './github-enterprise-pat.js'; 14 | import GitHubEnterprise from './github-enterprise.js'; 15 | import GitHubLight from './github-light.js'; 16 | import GitHubUserToken from './github-user-token.js'; 17 | import GitLab from './gitlab.js'; 18 | import Gitea from './gitea.js'; 19 | import GitHub from './github.js'; 20 | 21 | //TODO some way to handle accounts that have failing logins instead of just removing them. 22 | 23 | export default class ClientManager extends StorageManager { 24 | static get GITHUB() { 25 | return "github"; 26 | } 27 | 28 | static get ENTERPRISE() { 29 | return "enterprise"; 30 | } 31 | 32 | static get GITHUB_LIGHT() { 33 | return "github-light"; 34 | } 35 | 36 | static get GITHUB_USER_TOKEN() { 37 | return "github-user"; 38 | } 39 | 40 | static get ENTERPRISE_PAT() { 41 | return "enterprise-pat"; 42 | } 43 | 44 | static get GITLAB() { 45 | return "gitlab"; 46 | } 47 | 48 | static get GITEA() { 49 | return "gitea"; 50 | } 51 | 52 | static getTypeForClient(client) { 53 | if(client instanceof GitHubEnterpriseUserToken) { 54 | return ClientManager.ENTERPRISE_PAT; 55 | } 56 | if(client instanceof GitHubEnterprise) { 57 | return ClientManager.ENTERPRISE; 58 | } 59 | else if(client instanceof GitHubLight) { 60 | return ClientManager.GITHUB_LIGHT; 61 | } 62 | else if(client instanceof GitHubUserToken) { 63 | return ClientManager.GITHUB_USER_TOKEN; 64 | } 65 | else if(client instanceof GitLab) { 66 | return ClientManager.GITLAB; 67 | } 68 | else if(client instanceof Gitea) { 69 | return ClientManager.GITEA; 70 | } 71 | return ClientManager.GITHUB; 72 | } 73 | 74 | static async createClient(type, id, details) { 75 | let ClientFactory; 76 | switch(type) { 77 | case ClientManager.GITHUB: 78 | ClientFactory = GitHub; 79 | break; 80 | case ClientManager.GITHUB_LIGHT: 81 | ClientFactory = GitHubLight; 82 | break; 83 | case ClientManager.ENTERPRISE: 84 | ClientFactory = GitHubEnterprise; 85 | if(!details) { 86 | throw new Error("Details required to create enterprise client"); 87 | } 88 | break; 89 | case ClientManager.GITHUB_USER_TOKEN: 90 | ClientFactory = GitHubUserToken; 91 | if(!details) { 92 | throw new Error("Details required to create PAT client"); 93 | } 94 | break; 95 | case ClientManager.ENTERPRISE_PAT: 96 | ClientFactory = GitHubEnterpriseUserToken; 97 | if(!details) { 98 | throw new Error("Details required to create enterprise PAT client"); 99 | } 100 | break; 101 | case ClientManager.GITLAB: 102 | ClientFactory = GitLab; 103 | if(!details) { 104 | throw new Error("Details required to create new GitLab client"); 105 | } 106 | break; 107 | case ClientManager.GITEA: 108 | ClientFactory = Gitea; 109 | if(!details) { 110 | throw new Error("Details required to create new Gitea client"); 111 | } 112 | break; 113 | default: 114 | throw new Error("Unknown account type"); 115 | } 116 | const factoryArguments = ClientFactory.buildArgs(clientId, clientSecret, details); 117 | const client = new ClientFactory(...factoryArguments); 118 | if(id) { 119 | client.id = id; 120 | } 121 | const wrapper = new ClientHandler(client, this.area); 122 | return wrapper; 123 | } 124 | 125 | constructor() { 126 | super(ClientHandler); 127 | this.clients = new Set(); 128 | this.loadedInstances = false; 129 | } 130 | 131 | getClients() { 132 | return this.clients.values(); 133 | } 134 | 135 | async getInstances() { 136 | if(this.loadedInstances) { 137 | return !!this.clients.size; 138 | } 139 | const handlers = await this.getRecords(); 140 | for(const handler of handlers) { 141 | const wrapper = await ClientManager.createClient(handler.type, handler.id, handler.details); 142 | await wrapper.checkAuth(); 143 | this.addClient(wrapper, true) 144 | .catch((error) => console.error("Error adding client", handler.type, handler.id, error)); 145 | } 146 | await this.saveFields(); 147 | 148 | this.loadedInstances = true; 149 | return !!this.clients.size; 150 | } 151 | 152 | addClient(client, noSave = false) { 153 | for(const otherClient of this.clients) { 154 | if(otherClient.id === client.id && otherClient.type === client.type) { 155 | otherClient.checkAuth(); 156 | return Promise.resolve(); 157 | } 158 | } 159 | if(client instanceof ClientHandler) { 160 | this.clients.add(client); 161 | if(!noSave) { 162 | return this.saveFields(); 163 | } 164 | return Promise.resolve(); 165 | } 166 | return Promise.reject(new TypeError('Client is not a ClientHandler')); 167 | } 168 | 169 | removeClient(client) { 170 | this.clients.delete(client); 171 | return this.saveFields(); 172 | } 173 | 174 | saveFields() { 175 | const handlers = []; 176 | for(const client of this.getClients()) { 177 | const object = StorageManager.createRecord(client); 178 | object.type = ClientManager.getTypeForClient(client.client); 179 | object.notifications = client.NOTIFICATION_NAME; 180 | object.id = client.id; 181 | object.details = client.getDetails(); 182 | handlers.push(object); 183 | } 184 | return this.setRecords(handlers); 185 | } 186 | 187 | async getCount() { 188 | const clientCounts = await Promise.all(Array.from(this.getClients(), (c) => c.getCount())); 189 | const START_COUNT = 0; 190 | return clientCounts.reduce((p, c) => p + c, START_COUNT); 191 | } 192 | 193 | getClientForNotificationID(id) { 194 | for(const client of this.getClients()) { 195 | if(client.ownsNotification(id)) { 196 | return client; 197 | } 198 | } 199 | throw new Error(`No client has a notification with the id ${id}`); 200 | } 201 | 202 | getClientById(id) { 203 | for(const client of this.getClients()) { 204 | if(client.STORE_PREFIX === id) { 205 | return client; 206 | } 207 | } 208 | throw new Error(`No client with the id ${id} is registered.`); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | export const clientId = "", 2 | clientSecret = "", 3 | redirectUri = new URL("https://example.com"); 4 | -------------------------------------------------------------------------------- /scripts/gitea.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import { 8 | STATUS_OK, 9 | STATUS_RESET, 10 | } from "./http-constants.js"; 11 | 12 | const PAGE_SIZE = 100; 13 | 14 | export default class Gitea { 15 | static get TYPE_TO_GH() { 16 | return { 17 | Pull: 'PullRequest', 18 | Issue: 'Issue', 19 | Commit: 'Commit', 20 | Repository: 'Repository', 21 | }; 22 | } 23 | 24 | static buildArgs(clientID, clientSecret, details) { 25 | return [ 26 | details.token, 27 | details.instanceURL, 28 | ]; 29 | } 30 | 31 | constructor(token, baseURI) { 32 | this.token = token; 33 | this.instanceURL = baseURI; 34 | if(!this.instanceURL.endsWith('/')) { 35 | this.instanceURL += '/'; 36 | } 37 | this.lastUpdate = undefined; 38 | this.pollInterval = 60; 39 | this._username = ""; 40 | this.headers = { 41 | Accept: "application/json", 42 | }; 43 | this.headers.Authorization = `token ${token}`; 44 | } 45 | 46 | get authorized() { 47 | return "Authorization" in this.headers; 48 | } 49 | 50 | get infoURL() { 51 | return this.buildSiteURL(`settings/connections/applications/${this.clientID}`); 52 | } 53 | 54 | get username() { 55 | return this._username; 56 | } 57 | 58 | get isOauth() { 59 | return false; 60 | } 61 | 62 | async getToken() { 63 | return this.token; 64 | } 65 | 66 | async getUsername() { 67 | const response = await fetch(this.buildAPIURL('user'), { 68 | headers: this.headers, 69 | }); 70 | if(response.ok && response.status === STATUS_OK) { 71 | const json = await response.json(); 72 | this._username = json.login; 73 | this.id = json.id; 74 | } 75 | } 76 | 77 | async authorize(token, method = "POST") { 78 | //TODO check scopes of token we have in this.token. 79 | if(method === "POST") { 80 | await this.getUsername(); 81 | } 82 | } 83 | 84 | 85 | buildSiteURL(endpoint = '') { 86 | return this.instanceURL + endpoint; 87 | } 88 | 89 | buildAPIURL(endpoint = '') { 90 | return this.buildSiteURL(`api/v1/${endpoint}`); 91 | } 92 | 93 | deauthorize() { 94 | // noop, user token is created by user. 95 | } 96 | 97 | getDetails() { 98 | return { 99 | token: this.token, 100 | instanceURL: this.instanceURL, 101 | }; 102 | } 103 | 104 | async unsubscribeNotification() { 105 | throw new Error("Not available"); 106 | } 107 | async ignoreNotification() { 108 | throw new Error("Not available"); 109 | } 110 | 111 | async markNotificationsRead() { 112 | if(this.lastUpdate !== undefined && this.authorized) { 113 | const response = await fetch(this.buildAPIURL(`notifications?last_read_at=${this.lastUpdate}`), { 114 | headers: this.headers, 115 | method: 'PUT', 116 | }); 117 | if(response.ok && response.status == STATUS_RESET) { 118 | return true; 119 | } 120 | 121 | throw new Error(`Marking all notifications read returned a ${response.status} error`); 122 | } 123 | return false; 124 | } 125 | 126 | async markNotificationRead(notificationID) { 127 | const response = await fetch(this.buildAPIURL(`notifications/threads/${notificationID}`, { 128 | headers: this.headers, 129 | method: 'PATCH', 130 | })); 131 | if(response.ok && response.status == STATUS_RESET) { 132 | return true; 133 | } 134 | throw new Error(`Marking ${notificationID} as read returned a ${response.status} error`); 135 | } 136 | 137 | async getNotifications(url = this.buildAPIURL(`notifications?limit=${PAGE_SIZE}`)) { 138 | const response = await fetch(url, { 139 | headers: this.headers, 140 | }); 141 | if(response.ok) { 142 | this.lastUpdate = new Date().toISOString(); 143 | if(response.status === STATUS_OK) { 144 | const json = (await response.json()).map((notification) => { 145 | notification.subject.type = Gitea.TYPE_TO_GH[notification.subject.type]; 146 | return notification; 147 | }); 148 | if(json.length === PAGE_SIZE) { 149 | const NEXT = 1; 150 | const parsedUrl = new URL(url); 151 | const currentPage = Number.parseInt(parsedUrl.searchParams.get('page') ?? '1', 10); 152 | const nextPage = await this.getNotifications(this.buildAPIURL(`notifications?limit=${PAGE_SIZE}&page=${currentPage + NEXT}`)); 153 | return json.concat(nextPage); 154 | } 155 | return json; 156 | } 157 | return false; 158 | } 159 | 160 | throw new Error(`${response.status} ${response.statusText}`); 161 | } 162 | 163 | async getNotificationDetails(notification) { 164 | const response = await fetch(notification.subject.url, { 165 | headers: this.headers, 166 | }); 167 | if(response.ok) { 168 | const json = await response.json(); 169 | json.canUnsubscribe = false; 170 | json.canIgnore = false; 171 | if(json.pull_request) { 172 | json.merged = json.pull_request.merged; 173 | } 174 | if(notification.subject.latest_comment_url) { 175 | try { 176 | const comment = await fetch(notification.subject.latest_comment_url, { 177 | headers: this.headers, 178 | }); 179 | if(comment.ok) { 180 | const commentDetails = await comment.json(); 181 | json.html_url = commentDetails.html_url; // eslint-disable-line camelcase 182 | } 183 | else { 184 | throw new Error("could not fetch comment details"); 185 | } 186 | } 187 | catch{ 188 | json.html_url = notification.subject.latest_comment_url; // eslint-disable-line camelcase 189 | } 190 | } 191 | return json; 192 | } 193 | let fallbackUrl = this.buildSiteURL(); 194 | if(notification.subject.latest_comment_url) { 195 | try { 196 | const comment = await fetch(notification.subject.latest_comment_url, { 197 | headers: this.headers, 198 | }); 199 | if(comment.ok) { 200 | const commentDetails = await comment.json(); 201 | fallbackUrl = commentDetails.html_url; 202 | } 203 | else { 204 | throw new Error("could not fetch comment details"); 205 | } 206 | } 207 | catch{ 208 | fallbackUrl = notification.subject.latest_comment_url; 209 | } 210 | } 211 | else if(notification.subject.type === 'Issue' || notification.subject.type === 'PullRequest') { 212 | fallbackUrl = `${notification.subject.repository.html_url}/issues/${notification.subject.number}`; 213 | } 214 | else if(notification.repository?.html_url) { 215 | fallbackUrl = notification.repository.html_url; 216 | } 217 | return { 218 | 'html_url': fallbackUrl, 219 | state: notification.subject.state, 220 | title: notification.subject.title, 221 | number: Number.parseInt(notification.subject.url.split('/').pop(), 10), 222 | canUnsubscribe: false, 223 | canIgnore: false, 224 | }; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /scripts/github-enterprise-pat.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import GitHubEnterprise from "./github-enterprise.js"; 8 | 9 | export default class GitHubEnterpriseUserToken extends GitHubEnterprise { 10 | static buildArgs(clientID, clientSecret, details) { 11 | return [ 12 | details.token, 13 | details.instanceURL, 14 | ]; 15 | } 16 | 17 | constructor(token, instanceURL) { 18 | super(undefined, undefined, instanceURL); 19 | this.token = token; 20 | this.setToken(token); 21 | } 22 | 23 | get isOauth() { 24 | return false; 25 | } 26 | 27 | async getToken() { 28 | return this.token; 29 | } 30 | 31 | async authorize(token, method = "POST") { 32 | //TODO check scopes of token we have in this.token. 33 | if(method === "POST") { 34 | await this.getUsername(); 35 | } 36 | } 37 | 38 | deauthorize() { 39 | // noop, user token is created by user. 40 | } 41 | 42 | getDetails() { 43 | return { 44 | token: this.token, 45 | instanceURL: this.instanceURL, 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/github-enterprise.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import GitHub from "./github.js"; 8 | 9 | export default class GitHubEnterprise extends GitHub { 10 | static buildArgs(clientID, clientSecret, details) { 11 | return [ 12 | details.clientId, 13 | details.clientSecret, 14 | details.instanceURL, 15 | ]; 16 | } 17 | 18 | constructor(clientID, clientSecret, baseURI) { 19 | super(clientID, clientSecret); 20 | this.instanceURL = baseURI; 21 | if(!this.instanceURL.endsWith('/')) { 22 | this.instanceURL += '/'; 23 | } 24 | } 25 | 26 | buildSiteURL(endpoint = '') { 27 | return this.instanceURL + endpoint; 28 | } 29 | 30 | buildAPIURL(endpoint = '') { 31 | return this.buildSiteURL(`api/v3/${endpoint}`); 32 | } 33 | 34 | getDetails() { 35 | return { 36 | clientId: this.clientID, 37 | clientSecret: this.clientSecret, 38 | instanceURL: this.instanceURL, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/github-light.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import GitHub from "./github.js"; 8 | 9 | export default class GitHubLight extends GitHub { 10 | static get SCOPE() { 11 | return "notifications"; 12 | } 13 | 14 | get scope() { 15 | return GitHubLight.SCOPE; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /scripts/github-user-token.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import GitHub from "./github.js"; 8 | 9 | export default class GitHubUserToken extends GitHub { 10 | static buildArgs(clientID, clientSecret, details) { 11 | return [ details.token ]; 12 | } 13 | 14 | constructor(token) { 15 | super(); 16 | this.token = token; 17 | this.setToken(token); 18 | } 19 | 20 | get isOauth() { 21 | return false; 22 | } 23 | 24 | async getToken() { 25 | return this.token; 26 | } 27 | 28 | async authorize(token, method = "POST") { 29 | //TODO check scopes of token we have in this.token. 30 | if(method === "POST") { 31 | await this.getUsername(); 32 | } 33 | } 34 | 35 | deauthorize() { 36 | // noop, user token is created by user. 37 | } 38 | 39 | getDetails() { 40 | return { 41 | token: this.token, 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/github.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | //TODO make the URIs overridable for Enterprise 8 | 9 | import { 10 | STATUS_OK, 11 | STATUS_RESET, 12 | } from './http-constants.js'; 13 | import { parseLinks } from './link-utils.js'; 14 | 15 | const MS_TO_S = 1000; 16 | const MIN_POLL_INTEVAL = 10; 17 | const AVOID_INFINITY = 1; 18 | 19 | export default class GitHub { 20 | static get BASE_URI() { 21 | return 'https://api.github.com/'; 22 | } 23 | 24 | static get SITE_URI() { 25 | return 'https://github.com/'; 26 | } 27 | 28 | static get REDIRECT_URI() { 29 | return new URL(`${browser.identity.getRedirectURL()}login`); 30 | } 31 | 32 | static get SCOPE() { 33 | return "repo"; 34 | } 35 | 36 | static get FOOTER_URLS() { 37 | return { 38 | "index": GitHub.SITE_URI, 39 | "unread": `${GitHub.SITE_URI}notifications?query=is%3Aunread`, 40 | "all": `${GitHub.SITE_URI}notifications?query=`, 41 | "participating": `${GitHub.SITE_URI}notifications?query=reason%3Aparticipating`, 42 | "watched": `${GitHub.SITE_URI}watching`, 43 | }; 44 | } 45 | 46 | static buildArgs(clientID, clientSecret) { 47 | return [ 48 | clientID, 49 | clientSecret, 50 | ]; 51 | } 52 | 53 | constructor(clientID, clientSecret) { 54 | this.clientID = clientID; 55 | this.clientSecret = clientSecret; 56 | this.lastUpdate = null; 57 | this.forceRefresh = false; 58 | this.pollInterval = 60; 59 | this._username = ""; 60 | this.headers = { 61 | Accept: "application/vnd.github+json", 62 | 'X-GitHub-Api-Version': '2022-11-28', 63 | }; 64 | } 65 | 66 | get authorized() { 67 | return "Authorization" in this.headers; 68 | } 69 | 70 | get infoURL() { 71 | return this.buildSiteURL(`settings/connections/applications/${this.clientID}`); 72 | } 73 | 74 | get scope() { 75 | return GitHub.SCOPE; 76 | } 77 | 78 | get username() { 79 | return this._username; 80 | } 81 | 82 | get isOauth() { 83 | return true; 84 | } 85 | 86 | buildAPIURL(endpoint = '') { 87 | return GitHub.BASE_URI + endpoint; 88 | } 89 | 90 | buildSiteURL(endpoint = '') { 91 | return GitHub.SITE_URI + endpoint; 92 | } 93 | 94 | authURL(authState) { 95 | const standardURL = this.buildSiteURL(`login/oauth/authorize?client_id=${this.clientID}&scope=${this.scope}&state=${authState}&redirect_uri=${encodeURIComponent(GitHub.REDIRECT_URI.toString())}`); 96 | if(this.username) { 97 | return `${standardURL}&login=${encodeURIComponent(this.username)}`; 98 | } 99 | return standardURL; 100 | } 101 | 102 | setToken(token) { 103 | this.headers.Authorization = `token ${token}`; 104 | } 105 | 106 | unsetToken() { 107 | delete this.headers.Authorization; 108 | } 109 | 110 | async getToken(code, authState) { 111 | const parameters = new URLSearchParams(); 112 | parameters.append("client_id", this.clientID); 113 | parameters.append("client_secret", this.clientSecret); 114 | parameters.append("code", code); 115 | parameters.append("redirect_uri", GitHub.REDIRECT_URI.toString()); 116 | parameters.append("state", authState); 117 | 118 | const response = await fetch(this.buildSiteURL('login/oauth/access_token'), { 119 | method: "POST", 120 | body: parameters, 121 | headers: { 122 | Accept: "application/json", 123 | }, 124 | }); 125 | //TODO requeue on network errors 126 | if(response.ok) { 127 | const { 128 | access_token: accessToken, scope, 129 | } = await response.json(); 130 | if(scope.includes(this.scope)) { 131 | this.setToken(accessToken); 132 | await this.getUsername(); 133 | return accessToken; 134 | } 135 | throw new Error("Was not granted required permissions"); 136 | } 137 | throw response; 138 | } 139 | 140 | async getUsername() { 141 | const response = await fetch(this.buildAPIURL('user'), { 142 | headers: this.headers, 143 | }); 144 | if(response.ok && response.status === STATUS_OK) { 145 | const json = await response.json(); 146 | this._username = json.login; 147 | this.id = json.id; 148 | } 149 | } 150 | 151 | async authorize(token, method = "POST") { 152 | const response = await fetch(this.buildAPIURL(`applications/${this.clientID}/token`), { 153 | method, 154 | body: JSON.stringify({ 155 | 'access_token': token, 156 | }), 157 | headers: { 158 | ...this.headers, 159 | Authorization: `Basic ${globalThis.btoa(`${this.clientID}:${this.clientSecret}`)}`, 160 | }, 161 | }); 162 | if(method == "POST") { 163 | if(response.ok && response.status === STATUS_OK) { 164 | const json = await response.json(); 165 | this._username = json.user.login; 166 | this.id = json.user.id; 167 | if(json.scopes.includes(this.scope)) { 168 | this.setToken(token); 169 | return true; 170 | } 171 | 172 | throw new Error("Not all required scopes given"); 173 | } 174 | else { 175 | throw new Error("Token invalid"); 176 | } 177 | } 178 | else if(method == "PATCH") { 179 | this.unsetToken(); 180 | } 181 | return "Token updated"; 182 | } 183 | 184 | deauthorize(token) { 185 | return this.authorize(token, "PATCH"); 186 | } 187 | 188 | async markNotificationsRead() { 189 | if(this.lastUpdate !== null && this.authorized) { 190 | const body = JSON.stringify({ "last_read_at": this.lastUpdate }); 191 | const response = await fetch(this.buildAPIURL('notifications'), { 192 | headers: this.headers, 193 | method: "PUT", 194 | body, 195 | }); 196 | if(response.ok && response.status == STATUS_RESET) { 197 | return true; 198 | } 199 | 200 | throw new Error(`Marking all notifications read returned a ${response.status} error`); 201 | } 202 | return false; 203 | } 204 | 205 | async markNotificationRead(notificationID) { 206 | const response = await fetch(this.buildAPIURL(`notifications/threads/${notificationID}`), { 207 | method: "PATCH", 208 | headers: this.headers, 209 | }); 210 | if(response.ok) { 211 | return true; 212 | } 213 | throw new Error(`Marking ${notificationID} as read returned a ${response.status} error`); 214 | } 215 | 216 | async unsubscribeNotification(notificationId) { 217 | const response = await fetch(this.buildAPIURL(`notifications/threads/${notificationId}/subscription`), { 218 | method: "PUT", 219 | headers: this.headers, 220 | body: `{"subscribed":false}`, 221 | }); 222 | 223 | if(!response.ok) { 224 | throw new Error(response.status); 225 | } 226 | } 227 | 228 | async ignoreNotification(notificationId) { 229 | const response = await fetch(this.buildAPIURL(`notifications/threads/${notificationId}/subscription`), { 230 | method: "PUT", 231 | headers: this.headers, 232 | body: `{"subscribed":false,"ignored":true}`, 233 | }); 234 | 235 | if(!response.ok) { 236 | throw new Error(response.status); 237 | } 238 | } 239 | 240 | async getNotifications(url = this.buildAPIURL('notifications')) { 241 | const response = await fetch(url, { 242 | headers: this.headers, 243 | // Have to bypass cache when there are notifications, as the Etag doesn't 244 | // change when notifications are read. 245 | cache: this.forceRefresh ? "reload" : "no-cache", 246 | }); 247 | 248 | if(response.ok) { 249 | const pollInterval = parseInt(response.headers.get("X-Poll-Interval"), 10), 250 | nowS = Math.floor(Date.now() / MS_TO_S), 251 | ratelimitReset = Math.max(nowS + MIN_POLL_INTEVAL, parseInt(response.headers.get("X-RateLimit-Reset"), 10)), 252 | ratelimitRemaining = Math.max(AVOID_INFINITY, parseInt(response.headers.get("X-RateLimit-Remaining"), 10)); 253 | this.pollInterval = Math.max( 254 | pollInterval, 255 | Math.ceil((ratelimitReset - nowS) / ratelimitRemaining), 256 | MIN_POLL_INTEVAL, 257 | ); 258 | 259 | const now = new Date(); 260 | this.lastUpdate = now.toISOString(); 261 | 262 | if(response.status === STATUS_OK) { 263 | const json = await response.json(); 264 | 265 | // There is some pagination here. 266 | if(response.headers.has('Link')) { 267 | const links = parseLinks(response.headers.get('Link')); 268 | if("next" in links) { 269 | // get next page 270 | const nextPage = await this.getNotifications(links.next); 271 | this.forceRefresh = !!json.length; 272 | return json.concat(nextPage); 273 | } 274 | } 275 | this.forceRefresh = !!json.length; 276 | return json; 277 | } 278 | return false; 279 | } 280 | 281 | throw new Error(`${response.status} ${response.statusText}`); 282 | } 283 | 284 | async getOldestCommentURL(issue, date) { 285 | const comments = await fetch(issue.comments_url, { 286 | headers: this.headers, 287 | }); 288 | if(comments.ok) { 289 | const commentData = await comments.json(); 290 | for(const comment of commentData) { 291 | if(Date.parse(comment.created_at) > date) { 292 | return comment.html_url; 293 | } 294 | } 295 | } 296 | } 297 | 298 | async getNotificationDetails(notification) { 299 | if(notification.subject.type === "RepositoryInvitation" || notification.subject.type === "RepositoryVulnerabilityAlert" || !notification.subject.url) { 300 | const details = { ...notification.repository }; 301 | /* eslint-disable camelcase */ 302 | switch(notification.subject.type) { 303 | case "RepositoryInvitation": 304 | details.html_url = `${notification.repository.html_url}/invitations`; 305 | break; 306 | case "RepositoryVulnerabilityAlert": 307 | details.html_url = `${notification.repository.html_url}/network/dependencies`; 308 | break; 309 | case "RepositoryDependabotAlertsThread": 310 | details.html_url = `${notification.repository.html_url}/security/dependabot`; 311 | break; 312 | case "CheckSuite": 313 | details.html_url = `${notification.repository.html_url}/actions`; 314 | details.willStayUnread = true; 315 | break; 316 | case "Discussion": 317 | details.html_url = `${notification.repository.html_url}/discussions`; 318 | break; 319 | default: 320 | } 321 | /* eslint-enable camelcase */ 322 | return details; 323 | } 324 | const apiEndpoint = notification.subject.url; 325 | const response = await fetch(apiEndpoint, { 326 | headers: this.headers, 327 | }); 328 | if(response.ok) { 329 | const json = await response.json(); 330 | 331 | if(notification.subject.type === "Issue" || notification.subject.type === "PullRequest") { 332 | let gotComment = false; 333 | try { 334 | const commentURL = await this.getOldestCommentURL(json, Date.parse(notification.last_read_at)); 335 | if(commentURL) { 336 | // eslint-disable-next-line camelcase 337 | json.html_url = commentURL; 338 | gotComment = true; 339 | } 340 | } 341 | catch{ 342 | // Ignore error. 343 | } 344 | if(!gotComment && notification.subject.latest_comment_url) { 345 | try { 346 | const commentInfo = await fetch(notification.subject.latest_comment_url, { 347 | headers: this.headers, 348 | }); 349 | if(commentInfo.ok) { 350 | const commentJson = await commentInfo.json(); 351 | if(commentJson.html_url) { 352 | json.html_url = commentJson.html_url; // eslint-disable-line camelcase 353 | } 354 | } 355 | } 356 | catch{ 357 | // Ignore error. 358 | } 359 | } 360 | } 361 | 362 | // Trying to trigger the notification shelf is hard. I think there's some session info where 018:NotificationThread is. 363 | // const notificationUrl = new URL(json.html_url); // eslint-disable-line camelcase, xss/no-mixed-html 364 | // const binaryBS = btoa(`018:NotificationThread${notificationID}:${this.id}`).replace(/=+$/, ""); 365 | // const referrerId = `NT_${binaryBS}`; 366 | // notificationUrl.searchParams.append("notification_referrer_id", referrerId); 367 | // json.html_url = notificationUrl.href; // eslint-disable-line camelcase, xss/no-mixed-html 368 | 369 | return json; 370 | } 371 | 372 | throw new Error(`Could not load details for ${notification.subject.title}: Error ${response.status}`); 373 | } 374 | 375 | getDetails() { 376 | return {}; 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /scripts/gitlab.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import { 8 | STATUS_OK, 9 | STATUS_RESET, 10 | } from "./http-constants.js"; 11 | import { parseLinks } from "./link-utils.js"; 12 | 13 | // https://docs.gitlab.com/ee/api/todos.html 14 | 15 | export default class GitLab { 16 | static get TYPE_TO_GH() { 17 | return { 18 | Issue: 'Issue', 19 | MergeRequest: 'PullRequest', 20 | Commit: 'Commit', 21 | Epic: 'Issue', 22 | 'DesignManagement::Design': 'TeamDiscussion', 23 | 'AlertManagement::Alert': 'RepositoryVulnerabilityAlert', 24 | }; 25 | } 26 | static get STATE_TO_GH() { 27 | return { 28 | opened: 'open', 29 | closed: 'closed', 30 | merged: 'closed', 31 | locked: 'undefined', 32 | }; 33 | } 34 | static get PREFIX_BY_TYPE() { 35 | return { 36 | Issue: '#', 37 | MergeRequest: '!', 38 | Epic: '&', 39 | }; 40 | } 41 | 42 | static buildArgs(clientID, clientSecret, details) { 43 | return [ 44 | details.token, 45 | details.instanceURL, 46 | ]; 47 | } 48 | 49 | constructor(token, baseURI) { 50 | this.token = token; 51 | this.instanceURL = baseURI; 52 | if(!this.instanceURL.endsWith('/')) { 53 | this.instanceURL += '/'; 54 | } 55 | this.lastUpdate = undefined; 56 | this.pollInterval = 60; 57 | this._username = ""; 58 | this.headers = { 59 | Accept: "application/json", 60 | }; 61 | this.headers['PRIVATE-TOKEN'] = token; 62 | } 63 | 64 | get authorized() { 65 | return "PRIVATE-TOKEN" in this.headers; 66 | } 67 | 68 | get infoURL() { 69 | return this.buildSiteURL('-/profile/personal_access_tokens'); 70 | } 71 | 72 | get username() { 73 | return this._username; 74 | } 75 | 76 | get shouldStayUnread() { 77 | return true; 78 | } 79 | 80 | get isOauth() { 81 | return false; 82 | } 83 | 84 | async getToken() { 85 | return this.token; 86 | } 87 | 88 | async getUsername() { 89 | const response = await fetch(this.buildAPIURL('user'), { 90 | headers: this.headers, 91 | }); 92 | if(response.ok && response.status === STATUS_OK) { 93 | const json = await response.json(); 94 | this._username = json.username; 95 | this.id = json.id; 96 | } 97 | } 98 | 99 | async authorize(token, method = "POST") { 100 | //TODO check scopes of token we have in this.token. 101 | if(method === "POST") { 102 | await this.getUsername(); 103 | } 104 | } 105 | 106 | 107 | buildSiteURL(endpoint = '') { 108 | return this.instanceURL + endpoint; 109 | } 110 | 111 | buildAPIURL(endpoint = '') { 112 | return this.buildSiteURL(`api/v4/${endpoint}`); 113 | } 114 | 115 | deauthorize() { 116 | // noop, user token is created by user. 117 | } 118 | 119 | getDetails() { 120 | return { 121 | token: this.token, 122 | instanceURL: this.instanceURL, 123 | }; 124 | } 125 | 126 | async markNotificationsRead() { 127 | if(this.lastUpdate !== undefined && this.authorized) { 128 | const response = await fetch(this.buildAPIURL('todos/mark_as_done'), { 129 | headers: this.headers, 130 | method: 'POST', 131 | }); 132 | if(response.ok && response.status == STATUS_RESET) { 133 | return true; 134 | } 135 | 136 | throw new Error(`Marking all notifications read returned a ${response.status} error`); 137 | } 138 | return false; 139 | } 140 | 141 | async markNotificationRead(notificationID) { 142 | const response = await fetch(this.buildAPIURL(`todos/${notificationID}/mark_as_done`, { 143 | headers: this.headers, 144 | method: 'POST', 145 | })); 146 | if(response.ok && response.status == STATUS_RESET) { 147 | return true; 148 | } 149 | throw new Error(`Marking ${notificationID} as read returned a ${response.status} error`); 150 | } 151 | 152 | async unsubscribeNotification() { 153 | throw new Error("Not available"); 154 | } 155 | async ignoreNotification() { 156 | throw new Error("Not available"); 157 | } 158 | 159 | async getNotifications(url = this.buildAPIURL('todos?per_page=100')) { 160 | const response = await fetch(url, { 161 | headers: this.headers, 162 | }); 163 | if(response.ok) { 164 | this.lastUpdate = new Date().toISOString(); 165 | if(response.status === STATUS_OK) { 166 | const json = (await response.json()).map((todo) => { 167 | const subject = { 168 | type: GitLab.TYPE_TO_GH[todo.target_type], 169 | url: todo.target_url, 170 | state: todo.target.state, 171 | originalTarget: todo.target, 172 | originalType: todo.target_type, 173 | title: todo.body, 174 | }; 175 | if(todo.project) { 176 | subject.repository = { 177 | 'html_url': this.buildSiteURL(todo.project.path_with_namespace), 178 | 'full_name': todo.project.path_with_namespace, 179 | }; 180 | } 181 | return { 182 | id: todo.id, 183 | subject, 184 | 'updated_at': todo.updated_at, 185 | unread: todo.state === 'pending', 186 | repository: subject.repository, 187 | }; 188 | }); 189 | 190 | if(response.headers.has('Link')) { 191 | const links = parseLinks(response.headers.get('Link')); 192 | if("next" in links) { 193 | const nextPage = await this.getNoficiations(links.next); 194 | return json.concat(nextPage); 195 | } 196 | } 197 | return json; 198 | } 199 | return false; 200 | } 201 | 202 | throw new Error(`${response.status} ${response.statusText}`); 203 | } 204 | 205 | async getNotificationDetails(notification) { 206 | return { 207 | 'html_url': notification.subject.url, 208 | state: GitLab.STATE_TO_GH[notification.subject.state], 209 | merged: notification.subject.state === "merged", 210 | draft: notification.subject.originalTarget.draft, 211 | number: notification.subject.originalTarget.iid, 212 | prefix: GitLab.PREFIX_BY_TYPE[notification.subject.originalType] ?? '', 213 | canUnsubscribe: false, 214 | canIgnore: false, 215 | }; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /scripts/handler.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import Storage from "./storage.js"; 8 | 9 | const S_TO_MS = 1000; 10 | const HEX = 16; 11 | const TYPES = { 12 | RepositoryInvitation: "invite", 13 | Issue: "issue", 14 | PullRequest: "pull", 15 | Tag: "release", 16 | Release: "release", 17 | RepositoryVulnerabilityAlert: "security", 18 | RepositoryDependabotAlertsThread: "security", 19 | TeamDiscussion: "discussion", 20 | Commit: "commit", 21 | CheckSuite: "ci", 22 | Discussion: "discussion", 23 | }; 24 | const ICONS = { 25 | invite: "mail", 26 | release: "tag", 27 | security: "alert", 28 | discussion: "comment", 29 | commit: "commit", 30 | ci: "ci", 31 | }; 32 | 33 | export default class ClientHandler extends Storage { 34 | static get NOTIFICATIONS() { 35 | return "notifications"; 36 | } 37 | 38 | static get TOKEN() { 39 | return "token"; 40 | } 41 | 42 | static get USERNAME() { 43 | return "username"; 44 | } 45 | 46 | static get SHOW_NOTIFICATIONS() { 47 | return "showNotifications"; 48 | } 49 | 50 | static getNormalizedType(notification) { 51 | return TYPES[notification.subject.type] ?? "commit"; 52 | } 53 | 54 | static getNotificationState(notification) { 55 | if(notification.normalizedType === "issue") { 56 | if(notification.subjectDetails.state_reason === "not_planned") { 57 | return "notplanned"; 58 | } 59 | return notification.subjectDetails.state; 60 | } 61 | if(notification.normalizedType === "pull") { 62 | if(notification.subjectDetails.merged) { 63 | return "merged"; 64 | } 65 | if(notification.subjectDetails.draft && notification.subjectDetails.state === "open") { 66 | return "wip"; 67 | } 68 | return notification.subjectDetails.state; 69 | } 70 | } 71 | 72 | static getNotificationIcon(notification) { 73 | if(ICONS[notification.normalizedType]) { 74 | return `${ICONS[notification.normalizedType]}.`; 75 | } 76 | if(notification.normalizedType == "issue") { 77 | return `issue-${notification.detailState}.`; 78 | } 79 | if(notification.normalizedType == "pull") { 80 | if(notification.detailState === "merged") { 81 | return "git-merge."; 82 | } 83 | if(notification.detailState === "wip") { 84 | return "git-pull-request-draft."; 85 | } 86 | if(notification.detailState === 'open') { 87 | return "git-pull-request."; 88 | } 89 | if(notification.detailState === 'closed') { 90 | return "git-pull-request-closed."; 91 | } 92 | 93 | return "git-pull-request-undefined."; 94 | } 95 | 96 | return "commit."; 97 | } 98 | 99 | static buildNotificationDetails(notification) { 100 | // Try to build the details as good as we can 101 | const subjectDetails = { 102 | "html_url": this.client.buildSiteURL(), 103 | }; 104 | 105 | /* eslint-disable camelcase */ 106 | if(notification.subject.type === "Issue" || notification.subject.type === "PullRequest") { 107 | subjectDetails.state = "undefined"; 108 | subjectDetails.merged = false; 109 | subjectDetails.number = parseInt(notification.subject.url.split("/").pop(), 10); 110 | if("repository" in notification.subject) { 111 | subjectDetails.html_url = `${notification.subject.repository.html_url}/issues/${subjectDetails.number}`; 112 | } 113 | } 114 | else if("repository" in notification.subject) { 115 | subjectDetails.html_url = notification.subject.repository.html_url; 116 | } 117 | /* eslint-enable camelcase */ 118 | return subjectDetails; 119 | } 120 | 121 | constructor(client, area) { 122 | const uri = new URL(client.buildSiteURL()); 123 | super(uri.hostname + client.id, area); 124 | this._prefix = uri.hostname; 125 | this.client = client; 126 | this.pollInterval = 60; 127 | } 128 | 129 | get STORE_PREFIX() { 130 | return this.storageId; 131 | } 132 | 133 | get NOTIFICATION_NAME() { 134 | return this.getStorageKey(ClientHandler.NOTIFICATIONS); 135 | } 136 | 137 | get TOKEN_NAME() { 138 | return this.getStorageKey(ClientHandler.TOKEN); 139 | } 140 | 141 | get SHOW_NAME() { 142 | return this.getStorageKey(ClientHandler.SHOW_NOTIFICATIONS); 143 | } 144 | 145 | get NOTIFICATION_PREFIX() { 146 | return `${this.STORE_PREFIX}|`; 147 | } 148 | 149 | get id() { 150 | return this.client.id; 151 | } 152 | 153 | set id(id) { 154 | this.client.id = id; 155 | this.storageId = this._prefix + id; 156 | } 157 | 158 | ownsNotification(id) { 159 | return id.startsWith(this.NOTIFICATION_PREFIX); 160 | } 161 | 162 | /** 163 | * @returns {boolean} If something changed. 164 | */ 165 | async check() { 166 | if(!this.client.authorized) { 167 | const authSuccess = await this.checkAuth(); 168 | if(!authSuccess && !this.login(false)) { 169 | return false; 170 | } 171 | } 172 | const notifications = await this.client.getNotifications(); 173 | if(notifications) { 174 | await this._processNewNotifications(notifications); 175 | return true; 176 | } 177 | return false; 178 | } 179 | 180 | getNextCheckTime() { 181 | return Date.now() + (this.client.pollInterval * S_TO_MS); 182 | } 183 | 184 | async login(interactive = true) { 185 | // User Token Client. 186 | if(this.client.token) { 187 | await this.client.getUsername(); 188 | this.storageId = this._prefix + this.client.id; 189 | await this.setValue(ClientHandler.TOKEN, this.client.token); 190 | await this.setValue(ClientHandler.USERNAME, this.client._username); 191 | return true; 192 | } 193 | // Do OAuth for non-User-Token clients 194 | const authState = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(HEX); 195 | let url; 196 | try { 197 | const rawURL = await browser.identity.launchWebAuthFlow({ 198 | url: this.client.authURL(authState), 199 | interactive, 200 | }); 201 | url = new URL(rawURL); 202 | } 203 | catch(error) { 204 | // Ignore if the user cancelled. 205 | if(error.message === 'User cancelled or denied access.') { 206 | return false; 207 | } 208 | throw error; 209 | } 210 | if(!url.searchParams.has("error") && url.searchParams.has("code") && 211 | url.searchParams.get("state") == authState) { 212 | try { 213 | const token = await this.client.getToken(url.searchParams.get('code'), authState); 214 | this.storageId = this._prefix + this.client.id; 215 | await this.setValue(ClientHandler.TOKEN, token); 216 | await this.setValue(ClientHandler.USERNAME, this.client._username); 217 | } 218 | catch{ 219 | throw new Error("Was not granted required permissions"); 220 | } 221 | return true; 222 | } 223 | else if(url.searchParams.get('error') !== 'access_denied') { 224 | throw new Error(`An error occurred during authorization: "${url.searchParams.get("error_description")}". See ${url.searchParams.get("error_uri")}`); 225 | } 226 | // Access denied 227 | return false; 228 | } 229 | 230 | async logout(cleanUp = false) { 231 | if(cleanUp) { 232 | const token = await this.getValue(ClientHandler.TOKEN); 233 | await this.client.deauthorize(token); 234 | } 235 | const valueToRemove = [ ClientHandler.NOTIFICATIONS ]; 236 | if(cleanUp) { 237 | valueToRemove.push(ClientHandler.TOKEN, ClientHandler.SHOW_NOTIFICATIONS, ClientHandler.USERNAME); 238 | } 239 | else if(this.client.isOauth) { 240 | valueToRemove.push(ClientHandler.TOKEN); 241 | } 242 | await this.removeValues(valueToRemove); 243 | } 244 | 245 | async checkAuth() { 246 | if(!this.client.id) { 247 | return false; 248 | } 249 | const token = await this.getValue(ClientHandler.TOKEN); 250 | if(!token) { 251 | return false; 252 | } 253 | try { 254 | await this.client.authorize(token); 255 | await this.setValue(ClientHandler.USERNAME, this.client._username); 256 | } 257 | catch{ 258 | await this.logout(); 259 | return false; 260 | } 261 | return true; 262 | } 263 | 264 | async markAsRead(id, remote = true) { 265 | if(id) { 266 | if(remote) { 267 | const githubID = this._getOriginalID(id); 268 | await this.client.markNotificationRead(githubID); 269 | } 270 | else if(this.client.shouldStayUnread) { 271 | return; 272 | } 273 | const notifications = await this._getNotifications(); 274 | const notifs = notifications.filter((notification) => notification.id != id); 275 | await this.setValue(ClientHandler.NOTIFICATIONS, notifs); 276 | try { 277 | await browser.notifications.clear(id); 278 | } 279 | catch{ 280 | // Don't care about notification clear failing 281 | } 282 | } 283 | else { 284 | if(remote) { 285 | await this.client.markNotificationsRead(); 286 | } 287 | await this.setValue(ClientHandler.NOTIFICATIONS, []); 288 | } 289 | } 290 | 291 | async getNotificationURL(id) { 292 | const notifications = await this._getNotifications(); 293 | const notification = notifications.find((n) => n.id == id); 294 | //TODO get anchor to events after last_read_at for issues/prs 295 | if(notification) { 296 | if(!notification.subjectDetails.html_url) { 297 | return notification.repository.html_url; 298 | } 299 | return notification.subjectDetails.html_url; 300 | } 301 | return ""; 302 | } 303 | 304 | async willAutoMarkAsRead(id) { 305 | if(this.client.shouldStayUnread) { 306 | return false; 307 | } 308 | const notifications = await this._getNotifications(); 309 | const notification = notifications.find((n) => n.id == id); 310 | if(notification?.willStayUnread) { 311 | return false; 312 | } 313 | return true; 314 | } 315 | 316 | async getCount() { 317 | const notifications = await this._getNotifications(); 318 | return notifications.length; 319 | } 320 | 321 | ignoreNotification(id) { 322 | return this.client.ignoreNotification(this._getOriginalID(id)); 323 | } 324 | 325 | unsubscribeNotification(id) { 326 | return this.client.unsubscribeNotification(this._getOriginalID(id)); 327 | } 328 | 329 | getDetails() { 330 | return this.client.getDetails(); 331 | } 332 | 333 | _getNotifications() { 334 | return this.getValue(ClientHandler.NOTIFICATIONS, []); 335 | } 336 | 337 | _getNotificationID(githubID) { 338 | return this.NOTIFICATION_PREFIX + githubID; 339 | } 340 | 341 | _getOriginalID(id) { 342 | return id.slice(this.NOTIFICATION_PREFIX.length); 343 | } 344 | 345 | async _processNewNotifications(json) { 346 | const { hide } = await browser.storage.local.get({ 347 | hide: false, 348 | }); 349 | const notifications = await this._getNotifications(); 350 | const showNotifications = await this.getValue(ClientHandler.SHOW_NOTIFICATIONS, true); 351 | const stillNotificationIds = []; 352 | let notifs = await Promise.all(json.filter((n) => n.unread).map(async (notification) => { 353 | notification.id = this._getNotificationID(notification.id); 354 | const existingNotif = notifications.find((n) => n.id == notification.id); 355 | if(existingNotif) { 356 | stillNotificationIds.push(notification.id); 357 | } 358 | let fetchDetails = true; 359 | if(!existingNotif) { 360 | notification.new = true; 361 | } 362 | else if(existingNotif.updated_at == notification.updated_at) { 363 | notification.subjectDetails = existingNotif.subjectDetails; 364 | notification.normalizedType = ClientHandler.getNormalizedType(notification); 365 | notification.detailState = ClientHandler.getNotificationState(notification); 366 | notification.icon = ClientHandler.getNotificationIcon(notification); 367 | fetchDetails = false; 368 | } 369 | 370 | if(fetchDetails) { 371 | try { 372 | /* eslint-disable require-atomic-updates */ 373 | try { 374 | const details = await this.client.getNotificationDetails(notification); 375 | notification.subjectDetails = details; 376 | } 377 | catch{ 378 | notification.subjectDetails = ClientHandler.buildNotificationDetails(notification); 379 | } 380 | notification.normalizedType = ClientHandler.getNormalizedType(notification); 381 | notification.detailState = ClientHandler.getNotificationState(notification); 382 | notification.icon = ClientHandler.getNotificationIcon(notification); 383 | /* eslint-enable require-atomic-updates */ 384 | } 385 | catch{ 386 | return null; 387 | } 388 | } 389 | if(notification.new) { 390 | //TODO shouldn't be here 391 | if(!hide && showNotifications) { 392 | const typeMessage = browser.i18n.getMessage(`type_${notification.normalizedType}`); 393 | let stateMessage = ''; 394 | if(notification.detailState) { 395 | const stateMessageId = `status_${notification.detailState}`; 396 | stateMessage = ` (${browser.i18n.getMessage(stateMessageId)})`; 397 | } 398 | const repoName = notification.repository?.full_name ?? ''; 399 | const message = `${repoName} 400 | ${typeMessage}${stateMessage}`; 401 | await browser.notifications.create(notification.id, { 402 | type: "basic", 403 | title: notification.subject.title, 404 | message, 405 | eventTime: Date.parse(notification.updated_at), 406 | iconUrl: `images/large/${notification.icon}png`, 407 | }); 408 | } 409 | delete notification.new; 410 | } 411 | return notification; 412 | })); 413 | notifs = notifs.filter((n) => n !== null); 414 | 415 | if(!hide && showNotifications) { 416 | for(const existingNotification of notifications) { 417 | if(!stillNotificationIds.includes(existingNotification.id)) { 418 | try { 419 | await browser.notifications.clear(existingNotification.id); 420 | } 421 | catch{ 422 | // ignore clearing errors. 423 | } 424 | } 425 | } 426 | } 427 | 428 | await this.setValue(ClientHandler.NOTIFICATIONS, notifs); 429 | return notifs; 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /scripts/http-constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | export const STATUS_OK = 200, 8 | STATUS_RESET = 205; 9 | -------------------------------------------------------------------------------- /scripts/l10n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Translates a HTMl page in the web l10n style from the Add-on SDK with WebExtension strings. 3 | * Large parts of the logic are very similar to the SDK implmentation. 4 | * All you have to do to use this in a document is load it. 5 | * 6 | * @license MPL-2.0 7 | * @author Martin Giger 8 | */ 9 | 10 | function translateElementAttributes(element) { 11 | const attributeList = new Set([ 12 | 'title', 13 | 'accesskey', 14 | 'alt', 15 | 'label', 16 | 'placeholder', 17 | 'abbr', 18 | 'content', 19 | 'download', 20 | 'srcdoc', 21 | 'value', 22 | ]); 23 | const ariaAttributeMap = { 24 | 'aria-label': 'ariaLabel', 25 | 'aria-value-text': 'ariaValueText', 26 | 'aria-moz-hint': 'ariaMozHint', 27 | }; 28 | const attributeSeparator = '_'; 29 | 30 | const presentAttributes = element.dataset.l10nAttrs.split(","); 31 | 32 | // Translate allowed attributes. 33 | for(const attribute of presentAttributes) { 34 | let data; 35 | if(attributeList.has(attribute)) { 36 | data = browser.i18n.getMessage(element.dataset.l10nId + attributeSeparator + attribute); 37 | } 38 | // Translate ARIA attributes 39 | else if(attribute in ariaAttributeMap) { 40 | data = browser.i18n.getMessage(element.dataset.l10nId + attributeSeparator + ariaAttributeMap[attribute]); 41 | } 42 | 43 | if(data && data != "??") { 44 | element.setAttribute(attribute, data); 45 | } 46 | } 47 | } 48 | 49 | function translateElement(element = document) { 50 | //TODO follow the tranlsate attribute's instructions (yes/no/inherit) 51 | // Get all children that are marked as being translateable. 52 | const children = element.querySelectorAll('*[data-l10n-id]'); 53 | for(const child of children) { 54 | if(!child.dataset.l10nNocontent) { 55 | const data = browser.i18n.getMessage(child.dataset.l10nId); 56 | if(data && data != "??") { 57 | child.textContent = data; 58 | } 59 | } 60 | if(child.dataset.l10nAttrs) { 61 | translateElementAttributes(child); 62 | } 63 | } 64 | } 65 | 66 | document.addEventListener("DOMContentLoaded", () => translateElement(), { 67 | capture: false, 68 | passive: true, 69 | once: true, 70 | }); 71 | -------------------------------------------------------------------------------- /scripts/link-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | export const parseLinks = (links) => { 8 | const linkInfo = links.split(","); 9 | const linkObject = {}; 10 | for(const link of linkInfo) { 11 | const [ 12 | match, 13 | url, 14 | relation, 15 | ] = link.match(/<([^>]+)>;\s+rel="([^"]+)"/) || []; 16 | if(match && url && relation) { 17 | linkObject[relation] = url; 18 | } 19 | } 20 | return linkObject; 21 | }; 22 | -------------------------------------------------------------------------------- /scripts/menu-spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | export const MENU_SPEC = { 8 | markAsRead: "markAsRead", 9 | unsubscribe: "unwatch", 10 | ignore: "ignore", 11 | }; 12 | -------------------------------------------------------------------------------- /scripts/options.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import Storage from "./storage.js"; 8 | import StorageManager from "./storage-manager.js"; 9 | 10 | const PASSIVE_EVENT = { 11 | capturing: false, 12 | passive: true, 13 | }, 14 | MIN_OAUTH_VERSION = 60, 15 | HAS_INSTANCE_URL = new Set([ 16 | 'enterprise', 17 | 'enterprise-pat', 18 | 'gitlab', 19 | 'gitea', 20 | ]), 21 | IS_TOKEN = new Set([ 22 | 'enterprise-pat', 23 | 'github-user', 24 | 'gitlab', 25 | 'gitea', 26 | ]); 27 | 28 | class Account extends Storage { 29 | constructor(type, id, area, details = {}) { 30 | super(id, area); 31 | this.id = id; 32 | this.type = type; 33 | this.details = details; 34 | this.root = document.createElement("li"); 35 | this.root.dataset.id = this.id; 36 | this.buildAccount(); 37 | } 38 | 39 | get removeAction() { 40 | if(IS_TOKEN.has(this.type)) { 41 | return browser.i18n.getMessage('remove'); 42 | } 43 | return browser.i18n.getMessage('logout'); 44 | } 45 | 46 | async buildAccount() { 47 | const start = document.createElement("div"); 48 | const typeNode = document.createElement("small"); 49 | typeNode.textContent = browser.i18n.getMessage(`account_${this.type}`); 50 | 51 | // Show what enterprise instance the account belongs to 52 | if(HAS_INSTANCE_URL.has(this.type)) { 53 | typeNode.textContent += ` - ${this.details.instanceURL}`; 54 | } 55 | 56 | const username = await this.getValue('username'); 57 | const usernameNode = document.createTextNode(username); 58 | 59 | const controls = document.createElement("div"); 60 | controls.classList.add("account-controls"); 61 | 62 | const showNotifications = document.createElement("label"); 63 | const checkbox = document.createElement("input"); 64 | showNotifications.classList.add("browser-style"); 65 | showNotifications.append(document.createTextNode(browser.i18n.getMessage('showNotifications'))); 66 | 67 | checkbox.type = "checkbox"; 68 | checkbox.disabled = !document.getElementById("notifications").checked; 69 | checkbox.id = `notifs-${this.id}`; 70 | checkbox.classList.add('account-notifs'); 71 | checkbox.checked = await this.getValue('showNotifications', true); // eslint-disable-line require-atomic-updates 72 | checkbox.addEventListener("input", () => { 73 | this.setValue('showNotifications', checkbox.checked); 74 | }, PASSIVE_EVENT); 75 | 76 | showNotifications.classList.toggle("disabled", checkbox.disabled); 77 | showNotifications.append(checkbox); 78 | showNotifications.append(' '); 79 | 80 | const logout = document.createElement("button"); 81 | logout.classList.add('browser-style'); 82 | logout.textContent = this.removeAction; 83 | logout.addEventListener("click", () => this.logout(), PASSIVE_EVENT); 84 | 85 | start.append(usernameNode); 86 | start.append(typeNode); 87 | this.root.append(start); 88 | controls.append(showNotifications); 89 | controls.append(logout); 90 | this.root.append(controls); 91 | } 92 | 93 | logout() { 94 | browser.runtime.sendMessage({ 95 | topic: "logout", 96 | handlerId: this.id, 97 | }); 98 | this.root.remove(); 99 | } 100 | } 101 | 102 | class AccountManager extends StorageManager { 103 | constructor(root) { 104 | super(Account); 105 | this.root = root; 106 | this.form = root.querySelector("#login"); 107 | this.list = root.querySelector("#active"); 108 | 109 | const typeForm = this.form.querySelector("select"); 110 | 111 | browser.runtime.getBrowserInfo() 112 | .then(({ version }) => { 113 | const [ major ] = version.split('.'); 114 | if(parseInt(major, 10) >= MIN_OAUTH_VERSION) { 115 | const disabledOptions = typeForm.querySelectorAll('option[disabled]'); 116 | for(const option of disabledOptions) { 117 | option.disabled = false; 118 | } 119 | // Default selection 120 | typeForm.value = "github"; 121 | typeForm.dispatchEvent(new Event("change")); 122 | } 123 | }) 124 | .catch(console.error); 125 | 126 | browser.storage.onChanged.addListener((changes, areaName) => { 127 | if(areaName === "local" && "handlers" in changes) { 128 | const handlerIds = new Set(); 129 | for(const handler of changes.handlers.newValue) { 130 | handlerIds.add(handler[StorageManager.ID_KEY]); 131 | if(!this.getAccountRoot(handler[StorageManager.ID_KEY])) { 132 | this.addAccount(handler.type, handler[StorageManager.ID_KEY], handler.details); 133 | } 134 | } 135 | if("oldValue" in changes.handlers && changes.handlers.oldValue) { 136 | for(const oldHandler of changes.handlers.oldValue) { 137 | if(!handlerIds.has(oldHandler[StorageManager.ID_KEY])) { 138 | const node = this.getAccountRoot(oldHandler[StorageManager.ID_KEY]); 139 | if(node) { 140 | node.querySelector("button") 141 | .click(); 142 | } 143 | } 144 | } 145 | } 146 | } 147 | }); 148 | 149 | this.form.addEventListener("submit", async (event) => { 150 | //TODO disable form "during" submit 151 | event.preventDefault(); 152 | if(!this.validateForm()) { 153 | //TODO show error 154 | return; 155 | } 156 | let type = typeForm.value, 157 | details; 158 | if(type === 'enterprise-preconfig') { 159 | type = 'enterprise'; 160 | details = this.enterpriseInstance; 161 | } 162 | else { 163 | details = this.getDetails(type); 164 | } 165 | 166 | if(type === 'enterprise') { 167 | let permissionURL = details.instanceURL; 168 | if(!permissionURL.endsWith('/')) { 169 | permissionURL += '/'; 170 | } 171 | permissionURL += 'login/oauth/access_token'; 172 | const granted = await browser.permissions.request({ 173 | origins: [ permissionURL ], 174 | }); 175 | if(!granted) { 176 | this.showError(browser.i18n.getMessage("error_host_enterprise")); 177 | return; 178 | } 179 | } 180 | if(type == 'gitea') { 181 | let permissionURL = details.instanceURL; 182 | if(!permissionURL.endsWith('/')) { 183 | permissionURL += '/'; 184 | } 185 | const granted = await browser.permissions.request({ 186 | origins: [ permissionURL ], 187 | }); 188 | if(!granted) { 189 | this.showError(browser.i18n.getMessage("error_host_gitea")); 190 | return; 191 | } 192 | } 193 | try { 194 | await browser.runtime.sendMessage({ 195 | topic: "login", 196 | type, 197 | details, 198 | }); 199 | this.form.reset(); 200 | typeForm.value = "github"; // eslint-disable-line require-atomic-updates 201 | } 202 | catch(error) { 203 | this.showError(error.message); 204 | } 205 | }, { 206 | passive: false, 207 | }); 208 | 209 | typeForm.addEventListener("change", () => { 210 | this.validateForm(); 211 | }, { 212 | passive: true, 213 | capture: false, 214 | }); 215 | 216 | // Ensure the corect things are shown 217 | this.validateForm(); 218 | } 219 | 220 | async getInstances() { 221 | const records = await this.getRecords(); 222 | return records.map((r) => this.addAccount(r.type, r[StorageManager.ID_KEY], r.details)); 223 | } 224 | 225 | addAccount(type, id, details) { 226 | const account = new this.StorageInstance(type, id, this.area, details); 227 | this.list.append(account.root); 228 | return account; 229 | } 230 | 231 | getAccountRoot(id) { 232 | return this.list.querySelector(`[data-id="${CSS.escape(id)}"]`); 233 | } 234 | 235 | getDetails(type) { 236 | const inputs = this.form.querySelectorAll(`fieldset[name="${CSS.escape(type)}"] input`), 237 | details = {}; 238 | for(const input of inputs) { 239 | if(input.value && !input.disabled) { 240 | details[input.name] = input.value; 241 | } 242 | } 243 | return details; 244 | } 245 | 246 | validateFieldset(fieldset, current) { 247 | const visible = fieldset.name === current; 248 | if(fieldset.hidden !== visible) { 249 | // Only update if visibility state is different. 250 | return; 251 | } 252 | const inputs = fieldset.querySelectorAll('input'); 253 | fieldset.hidden = !visible; 254 | for(const input of inputs) { 255 | input.disabled = !visible; 256 | input.required = visible; 257 | } 258 | } 259 | 260 | validateForm() { 261 | this.hideError(); 262 | const current = this.form.querySelector("select").value; 263 | const fieldsets = this.form.querySelectorAll('fieldset'); 264 | for(const fieldset of fieldsets) { 265 | this.validateFieldset(fieldset, current); 266 | } 267 | return this.form.checkValidity(); 268 | } 269 | 270 | showError(error) { 271 | const errorContainer = this.form.querySelector("#error"); 272 | errorContainer.querySelector("output").textContent = error; 273 | errorContainer.hidden = false; 274 | } 275 | 276 | hideError() { 277 | this.form.querySelector("#error").hidden = true; 278 | } 279 | 280 | addEnterpriseInstance(instanceConfig) { 281 | if(this.enterpriseInstance) { 282 | document.getElementById('enterprise-preconfigured').remove(); 283 | } 284 | this.enterpriseInstance = instanceConfig; 285 | const optgroup = document.createElement("optgroup"); 286 | optgroup.label = instanceConfig.instanceURL; 287 | optgroup.id = 'enterprise-preconfigured'; 288 | const option = new Option(browser.i18n.getMessage('account_enterprise-preconfig', instanceConfig.instanceURL), 'enterprise-preconfig', true, true); 289 | optgroup.append(option); 290 | this.form.querySelector("select").append(optgroup); 291 | } 292 | } 293 | 294 | globalThis.addEventListener("DOMContentLoaded", () => { 295 | document.getElementById("enterprise-redirect").textContent = browser.i18n.getMessage("enterprise_redirect", `${browser.identity.getRedirectURL()}login`); 296 | const notifications = document.getElementById("notifications"); 297 | const badge = document.getElementById("badge"); 298 | const footer = document.getElementById("footer"); 299 | const manager = new AccountManager(document.getElementById("accounts")); 300 | manager.getInstances().catch(console.error); 301 | 302 | notifications.addEventListener("change", () => { 303 | browser.storage.local.set({ hide: !notifications.checked }); 304 | // Disable account-specific notifications checkboxes if notifications are not to be shown 305 | const accountCheckboxes = document.querySelectorAll('input.account-notifs'); 306 | for(const checkbox of accountCheckboxes) { 307 | checkbox.disabled = !notifications.checked; 308 | checkbox.parentNode.classList.toggle("disabled", !notifications.checked); 309 | } 310 | }, PASSIVE_EVENT); 311 | 312 | badge.addEventListener("change", () => { 313 | browser.storage.local.set({ disableBadge: !badge.checked }); 314 | }, PASSIVE_EVENT); 315 | 316 | footer.addEventListener("change", () => { 317 | browser.storage.local.set({ footer: footer.value }); 318 | }, PASSIVE_EVENT); 319 | 320 | browser.storage.onChanged.addListener((changes, areaName) => { 321 | if(areaName === "local" && changes.disableBadge) { 322 | badge.checked = !changes.disableBadge.newValue; 323 | } 324 | }); 325 | 326 | browser.storage.local.get([ 327 | "hide", 328 | "disableBadge", 329 | "footer", 330 | ]) 331 | .then((result) => { 332 | if(result.hide) { 333 | notifications.checked = false; 334 | } 335 | if(result.disableBadge) { 336 | badge.checked = false; 337 | } 338 | if(result.footer) { 339 | footer.value = result.footer; 340 | } 341 | }) 342 | .catch(console.error); 343 | 344 | if("managed" in browser.storage) { 345 | browser.storage.managed.get('enterprise') 346 | .then((result) => { 347 | if(result.enterprise) { 348 | manager.addEnterpriseInstance(result.enterprise); 349 | } 350 | }) 351 | .catch(console.error); 352 | } 353 | }, PASSIVE_EVENT); 354 | -------------------------------------------------------------------------------- /scripts/popup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import { MENU_SPEC } from "./menu-spec.js"; 8 | import Storage from "./storage.js"; 9 | import StorageManager from "./storage-manager.js"; 10 | 11 | const loaded = new Promise((resolve) => { 12 | globalThis.addEventListener("DOMContentLoaded", resolve, { 13 | capture: true, 14 | passive: true, 15 | once: true, 16 | }); 17 | }); 18 | const idPrefix = "ghnotif"; 19 | const formatter = new Intl.DateTimeFormat(undefined, { 20 | weekday: "short", 21 | year: "numeric", 22 | month: "long", 23 | day: "numeric", 24 | hour: "2-digit", 25 | minute: "2-digit", 26 | second: "2-digit", 27 | }); 28 | const HAS_INSTANCE_URL = new Set([ 29 | 'enterprise', 30 | 'enterprise-pat', 31 | 'gitlab', 32 | 'gitea', 33 | ]), 34 | SINGLE_ACCOUNT = 1; 35 | 36 | const clickListener = (id) => { 37 | browser.runtime.sendMessage({ 38 | topic: "open-notification", 39 | notificationId: id, 40 | }); 41 | window.close(); 42 | }; 43 | 44 | const contextMenu = { 45 | items: Object.keys(MENU_SPEC), 46 | menuId: 0, 47 | areVisible: undefined, 48 | init() { 49 | //TODO maybe one can toggle shown/hidden from a contextmenu event, i.e. it's early enough? 50 | browser.menus.onClicked.addListener(({ 51 | menuItemId, 52 | targetElementId, 53 | }) => { 54 | const target = this.getTarget(targetElementId); 55 | this[menuItemId](target); 56 | }); 57 | browser.menus.onShown.addListener(({ targetElementId }) => { 58 | const notificationId = this.getTarget(targetElementId), 59 | isNotification = notificationId !== undefined, 60 | { menuId } = this; 61 | let canUnsubscribe = false, 62 | canIgnore = false; 63 | if(isNotification) { 64 | const notifElement = document.getElementById(`${idPrefix}${notificationId}`); 65 | canUnsubscribe = notifElement.dataset.canUnsubscribe == "true"; 66 | canIgnore = notifElement.dataset.canIgnore == "true"; 67 | } 68 | if(this.areVisible !== isNotification || isNotification) { 69 | Promise.all(this.items.map((id) => { 70 | let enabled = isNotification; 71 | if(id == "unsubscribe") { 72 | enabled &&= canUnsubscribe; 73 | } 74 | if(id == "ignore") { 75 | enabled &&= canIgnore; 76 | } 77 | return browser.menus.update(id, { 78 | enabled, 79 | }); 80 | })) 81 | .then(() => { 82 | if(menuId === this.menuId) { 83 | browser.menus.refresh(); 84 | } 85 | }) 86 | .catch(console.error); 87 | this.areVisible = isNotification; 88 | } 89 | }); 90 | browser.menus.onHidden.addListener(() => { 91 | ++this.menuId; 92 | }); 93 | }, 94 | getTarget(targetElementId) { 95 | let target = browser.menus.getTargetElement(targetElementId); 96 | if(!target.tagName.toLowerCase() !== 'li') { 97 | target = target.closest('li'); 98 | } 99 | if(target != undefined && target.classList.contains('panel-list-item')) { 100 | return target.id.slice(idPrefix.length); 101 | } 102 | }, 103 | open() { 104 | browser.menus.overrideContext({ 105 | showDefaults: true, 106 | }); 107 | }, 108 | markAsRead(notificationId) { 109 | browser.runtime.sendMessage({ 110 | topic: "mark-notification-read", 111 | notificationId, 112 | }); 113 | }, 114 | unsubscribe(notificationId) { 115 | browser.runtime.sendMessage({ 116 | topic: "unsubscribe-notification", 117 | notificationId, 118 | }); 119 | }, 120 | ignore(notificationId) { 121 | browser.runtime.sendMessage({ 122 | topic: "ignore-notification", 123 | notificationId, 124 | }); 125 | }, 126 | }; 127 | 128 | const notificationList = { 129 | IMAGE_SIZE: 16, 130 | root: undefined, 131 | markRead: undefined, 132 | init() { 133 | this.root = document.getElementById("notifications"); 134 | this.markRead = document.getElementById("mark-read"); 135 | this.markRead.addEventListener("click", () => { 136 | //TODO only mark current account as read 137 | if(!this.markRead.classList.contains("disabled")) { 138 | browser.runtime.sendMessage({ topic: "mark-all-read" }); 139 | } 140 | }, { 141 | capture: false, 142 | passive: true, 143 | }); 144 | }, 145 | toggleEmpty(state) { 146 | this.root.hidden = state; 147 | document.getElementById("empty").hidden = !state; 148 | this.markRead.classList.toggle("disabled", state); 149 | }, 150 | create(notification, singleAccount = false) { 151 | const root = document.createElement("li"); 152 | root.id = idPrefix + notification.id; 153 | root.dataset.canUnsubscribe = notification.subjectDetails.canUnsubscribe ?? true; 154 | root.dataset.canIgnore = notification.subjectDetails.canIgnore ?? true; 155 | root.classList.add("panel-list-item"); 156 | const date = new Date(notification.updated_at); 157 | let typeMessage = browser.i18n.getMessage(`type_${notification.normalizedType}`); 158 | if([ 159 | "issue", 160 | "pull", 161 | ].includes(notification.normalizedType)) { 162 | const prefix = notification.subjectDetails.prefix ?? '#'; 163 | typeMessage += ` ${prefix}${notification.subjectDetails.number}`; 164 | } 165 | let stateMessage = ''; 166 | if(notification.detailState) { 167 | const stateMessageId = `status_${notification.detailState}`; 168 | stateMessage = ` (${browser.i18n.getMessage(stateMessageId)})`; 169 | } 170 | let accountInfo = ''; 171 | if(!singleAccount) { 172 | //TODO should use accountselector instance 173 | const account = document.querySelector(`[value=${CSS.escape(notification.accountId)}]`); 174 | accountInfo = ` - ${account.textContent}`; 175 | } 176 | root.title = `${typeMessage}${stateMessage}${accountInfo} ${formatter.format(date)}`; 177 | 178 | const image = new Image(this.IMAGE_SIZE, this.IMAGE_SIZE); 179 | image.src = `images/small/${notification.icon}svg`; 180 | image.classList.add("icon"); 181 | 182 | const title = document.createElement("span"); 183 | title.classList.add("text"); 184 | title.textContent = notification.subject.title; 185 | 186 | root.append(image, title); 187 | if(notification.repository) { 188 | const repo = document.createElement("span"); 189 | repo.classList.add("text-shortcut"); 190 | repo.textContent = notification.repository.full_name; 191 | root.append(repo); 192 | } 193 | 194 | root.addEventListener("click", () => clickListener(notification.id), { 195 | passive: true, 196 | }); 197 | root.addEventListener("contextmenu", () => contextMenu.open(), { 198 | passive: true, 199 | }); 200 | this.root.append(root); 201 | 202 | this.toggleEmpty(false); 203 | }, 204 | delete(notificationId) { 205 | const root = document.getElementById(idPrefix + notificationId); 206 | if(root) { 207 | root.remove(); 208 | } 209 | if(!parent.childElementCount) { 210 | this.toggleEmpty(true); 211 | } 212 | }, 213 | clear() { 214 | while(this.root.hasChildNodes()) { 215 | this.root.firstChild.remove(); 216 | } 217 | }, 218 | async show(stores = []) { 219 | const storedNotifications = await browser.storage.local.get(stores); 220 | 221 | this.clear(); 222 | 223 | const notifications = Object.entries(storedNotifications).flatMap(([ 224 | accountId, 225 | notifs, 226 | ]) => notifs.map((n) => { 227 | n.accountId = accountId; 228 | return n; 229 | })); 230 | this.toggleEmpty(!notifications.length); 231 | for(const notification of notifications) { 232 | this.create(notification, !Array.isArray(stores)); 233 | } 234 | }, 235 | }; 236 | 237 | class Account extends Storage { 238 | constructor(type, id, area, details = {}) { 239 | super(id, area); 240 | this.id = id; 241 | this.type = type; 242 | this.details = details; 243 | this.ready = this.buildRoot(); 244 | } 245 | 246 | async buildRoot() { 247 | this.root = new Option(await this.getLabel(), this.getStorageKey("notifications")); 248 | } 249 | 250 | async getLabel() { 251 | if(HAS_INSTANCE_URL.has(this.type)) { 252 | return browser.i18n.getMessage('username_instance', [ 253 | await this.getValue('username'), 254 | this.details.instanceURL, 255 | ]); 256 | } 257 | return this.getValue('username'); 258 | } 259 | } 260 | 261 | class AccountSelector extends StorageManager { 262 | static get ALL_ACCOUNTS() { 263 | return "all"; 264 | } 265 | 266 | constructor(root) { 267 | super(Account); 268 | this.root = root; 269 | this.root.addEventListener("input", () => { 270 | this.selectAccount(this.currentAccount); 271 | }, { 272 | passive: true, 273 | capture: false, 274 | }); 275 | browser.storage.onChanged.addListener((changes, area) => { 276 | // Only listening for notification changes, since accounts shouldn't 277 | // change while the popup is open. 278 | if(area === "local" && (this.currentAccount === AccountSelector.ALL_ACCOUNTS || this.currentAccount in changes)) { 279 | this.selectAccount(this.currentAccount); 280 | } 281 | }); 282 | this.getInstances() 283 | .then(() => this.selectAccount(this.currentAccount)) 284 | .catch(console.error); 285 | } 286 | 287 | get currentAccount() { 288 | return this.root.value; 289 | } 290 | 291 | async getInstances() { 292 | const records = await this.getRecords(); 293 | if(records.length == SINGLE_ACCOUNT) { 294 | this.root.hidden = true; 295 | this.root.disabled = true; 296 | } 297 | return Promise.all(records.map((r) => this.addAccount(r.type, r[StorageManager.ID_KEY], r.details))); 298 | } 299 | 300 | async addAccount(type, id, details) { 301 | const account = new this.StorageInstance(type, id, this.area, details); 302 | await account.ready; 303 | this.root.append(account.root); 304 | return account; 305 | } 306 | 307 | getAccountRoot(id) { 308 | return this.root.querySelector(`[value="${CSS.escape(id)}"]`); 309 | } 310 | getAccounts() { 311 | return Array.from(this.root.options) 312 | .filter((o) => o.value !== AccountSelector.ALL_ACCOUNTS) 313 | .map((o) => o.value); 314 | } 315 | 316 | selectAccount(account) { 317 | if(account === AccountSelector.ALL_ACCOUNTS) { 318 | notificationList.show(this.getAccounts()).catch(console.error); 319 | } 320 | else { 321 | notificationList.show(account).catch(console.error); 322 | } 323 | } 324 | } 325 | 326 | loaded 327 | .then(() => { 328 | contextMenu.init(); 329 | notificationList.init(); 330 | new AccountSelector(document.getElementById("accounts")); 331 | return browser.storage.local.get({ 332 | "footer": "all", 333 | }); 334 | }) 335 | .then(({ footer }) => { 336 | const open = document.getElementById("open"); 337 | if(footer == "hidden") { 338 | open.parentNode.hidden = true; 339 | } 340 | else { 341 | open.addEventListener("click", () => { 342 | browser.runtime.sendMessage({ topic: "open-notifications" }); 343 | window.close(); 344 | }, { 345 | capture: false, 346 | passive: true, 347 | }); 348 | open.textContent = browser.i18n.getMessage(`footer_${footer}`); 349 | } 350 | }) 351 | .catch(console.error); 352 | -------------------------------------------------------------------------------- /scripts/storage-manager.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | import Storage from "./storage.js"; 8 | 9 | export default class StorageManager { 10 | static get KEY() { 11 | return "handlers"; 12 | } 13 | 14 | static get ID_KEY() { 15 | return "handlerId"; 16 | } 17 | 18 | static createRecord(storageInstance) { 19 | return { 20 | [StorageManager.ID_KEY]: storageInstance.storageId, 21 | }; 22 | } 23 | 24 | constructor(storageConstructor = Storage, area = "local") { 25 | this.StorageInstance = storageConstructor; 26 | this.area = area; 27 | } 28 | 29 | async getInstances() { 30 | const records = await this.getRecords(); 31 | return records.map((record) => new this.StorageInstance(record[StorageManager.ID_KEY], this.area)); 32 | } 33 | 34 | async getRecords() { 35 | const results = await browser.storage[this.area].get({ 36 | [StorageManager.KEY]: [], 37 | }); 38 | return results[StorageManager.KEY]; 39 | } 40 | 41 | setRecords(array) { 42 | return browser.storage[this.area].set({ 43 | [StorageManager.KEY]: array, 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | export default class Storage { 8 | constructor(storageId, area = "local") { 9 | this.storageId = storageId; 10 | this.area = area; 11 | } 12 | 13 | getStorageKey(key) { 14 | return `${this.storageId}_${key}`; 15 | } 16 | 17 | async getValue(key, defaultValue) { 18 | const storageKey = this.getStorageKey(key); 19 | try { 20 | const result = await browser.storage[this.area].get(storageKey); 21 | if(defaultValue !== undefined && (!(storageKey in result) || result[storageKey] === undefined)) { 22 | return defaultValue; 23 | } 24 | return result[storageKey]; 25 | } 26 | catch(error) { 27 | console.warn("Error reading", key, error); 28 | return defaultValue; 29 | } 30 | } 31 | 32 | setValue(key, value) { 33 | return browser.storage[this.area].set({ 34 | [this.getStorageKey(key)]: value, 35 | }); 36 | } 37 | 38 | removeValues(keys) { 39 | return browser.storage[this.area].remove(keys.map((key) => this.getStorageKey(key))); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /styles/options.css: -------------------------------------------------------------------------------- 1 | #active { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | #active li { 7 | padding: 0.5em 0.5em 0.5em 0; 8 | margin: 0; 9 | clear: both; 10 | display: flex; 11 | align-items: baseline; 12 | justify-content: space-between; 13 | } 14 | 15 | #active li div:first-child { 16 | flex-grow: 1; 17 | flex-shrink: 0; 18 | } 19 | 20 | #active li .browser-style { 21 | margin: 0; 22 | } 23 | 24 | #active li .browser-style input { 25 | margin-top: 0; 26 | margin-bottom: 0; 27 | } 28 | 29 | #active li small { 30 | margin-left: 0.5em; 31 | } 32 | 33 | #active li:nth-child(2n) { 34 | background: lightgrey; 35 | } 36 | 37 | .account-controls { 38 | float: right; 39 | } 40 | 41 | section { 42 | padding: 1em 0; 43 | } 44 | 45 | section + section { 46 | border-top: 1px solid #d7d7db; 47 | } 48 | 49 | #error img { 50 | height: 1em; 51 | width: 1em; 52 | } 53 | 54 | #active label.disabled { 55 | opacity: 0.8; 56 | cursor: not-allowed; /* stylelint-disable-line plugin/no-unsupported-browser-features */ 57 | } 58 | 59 | #enterprise-redirect { 60 | user-select: text; 61 | } 62 | -------------------------------------------------------------------------------- /styles/popup.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | ul { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | select { 13 | width: 100%; 14 | } 15 | 16 | .text-shortcut { 17 | margin-left: 4px; 18 | } 19 | 20 | .icon { 21 | margin-right: 4px; 22 | height: 1.2em; 23 | width: 1.2em; 24 | } 25 | 26 | button.panel-section-footer-button { 27 | padding: 12px; 28 | border: none; 29 | margin: 0; 30 | box-shadow: none; 31 | background-color: unset; 32 | } 33 | -------------------------------------------------------------------------------- /test/_mocks.js: -------------------------------------------------------------------------------- 1 | class FakeClient { 2 | static get SITE_URI() { 3 | return 'https://example.com'; 4 | } 5 | 6 | buildSiteURL(endpoint = '') { 7 | return FakeClient.SITE_URI + endpoint; 8 | } 9 | 10 | getDetails() { 11 | return {}; 12 | } 13 | } 14 | 15 | export { FakeClient }; 16 | -------------------------------------------------------------------------------- /test/client-manager.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { FakeClient } from './_mocks.js'; 3 | import ClientManager from '../scripts/client-manager.js'; 4 | import ClientHandler from '../scripts/handler.js'; 5 | import browser from "sinon-chrome/webextensions/index.js"; 6 | 7 | test.before(() => { 8 | globalThis.browser = browser; 9 | }); 10 | 11 | test.after(() => { 12 | globalThis.browser = undefined; 13 | }); 14 | 15 | test.serial.afterEach.always(() => { 16 | browser.flush(); 17 | }); 18 | 19 | test('constructor', (t) => { 20 | const manager = new ClientManager(); 21 | t.is(manager.clients.size, 0); 22 | }); 23 | 24 | test('add non-handler Client', (t) => { 25 | const manager = new ClientManager(); 26 | return t.throwsAsync(manager.addClient({}), { 27 | instanceOf: TypeError, 28 | }); 29 | }); 30 | 31 | test.serial('add handler client', async (t) => { 32 | const handler = new ClientHandler(new FakeClient()); 33 | const manager = new ClientManager(); 34 | 35 | await manager.addClient(handler); 36 | t.is(manager.clients.size, 1); 37 | t.true(browser.storage.local.set.calledOnce); 38 | t.deepEqual(browser.storage.local.set.lastCall.args[0], { 39 | handlers: [ { 40 | details: {}, 41 | type: ClientManager.GITHUB, 42 | notifications: handler.NOTIFICATION_NAME, 43 | id: handler.id, 44 | handlerId: handler.STORE_PREFIX, 45 | } ], 46 | }); 47 | }); 48 | 49 | test.serial('saveNotificationFields', async (t) => { 50 | const manager = new ClientManager(); 51 | 52 | await manager.saveFields(); 53 | t.true(browser.storage.local.set.calledOnce); 54 | t.deepEqual(browser.storage.local.set.lastCall.args[0], { 55 | handlers: [], 56 | }); 57 | }); 58 | 59 | test('getCount', async (t) => { 60 | const manager = new ClientManager(); 61 | 62 | const count = await manager.getCount(); 63 | t.is(count, 0); 64 | }); 65 | 66 | test.serial('removeClient', async (t) => { 67 | const handler = new ClientHandler(new FakeClient()); 68 | const manager = new ClientManager(); 69 | 70 | await manager.addClient(handler); 71 | t.is(manager.clients.size, 1); 72 | 73 | await manager.removeClient(handler); 74 | t.is(manager.clients.size, 0); 75 | t.true(browser.storage.local.set.calledTwice); 76 | t.deepEqual(browser.storage.local.set.lastCall.args[0], { 77 | handlers: [], 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/github.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { default as browser } from "sinon-chrome/webextensions/index.js"; 3 | import GitHub from '../scripts/github.js'; 4 | import { stub } from "sinon"; 5 | 6 | test.before((t) => { 7 | globalThis.browser = browser; 8 | t.context.originalFetch = fetch; 9 | globalThis.fetch = stub(); 10 | browser.identity.getRedirectURL.returns('https://example.com'); 11 | }); 12 | 13 | test.after((t) => { 14 | globalThis.browser = undefined; 15 | globalThis.fetch = t.context.originalFetch; 16 | }); 17 | 18 | test.serial.afterEach.always(() => { 19 | browser.flush(); 20 | }); 21 | 22 | const STATIC_STRING_CONSTANTS = [ 23 | 'BASE_URI', 24 | 'SITE_URI', 25 | 'SCOPE', 26 | ]; 27 | 28 | const testStaticConstants = (t, property) => { 29 | t.true(property in GitHub); 30 | t.is(typeof GitHub[property], 'string'); 31 | }; 32 | testStaticConstants.title = (title, property) => `${title} ${property}`; 33 | 34 | for(const property of STATIC_STRING_CONSTANTS) { 35 | test('static', testStaticConstants, property); 36 | } 37 | 38 | test('redirect URI', (t) => { 39 | t.true(GitHub.REDIRECT_URI instanceof URL); 40 | 41 | const redirectUri = new URL(`${browser.identity.getRedirectURL()}login`); 42 | t.deepEqual(GitHub.REDIRECT_URI.toString(), redirectUri.toString()); 43 | }); 44 | 45 | test('footer urls', (t) => { 46 | t.true("FOOTER_URLS" in GitHub); 47 | 48 | t.is(typeof GitHub.FOOTER_URLS, 'object'); 49 | 50 | const properties = [ 51 | 'index', 52 | 'unread', 53 | 'all', 54 | 'participating', 55 | 'watched', 56 | ]; 57 | for(const p of properties) { 58 | t.true(p in GitHub.FOOTER_URLS); 59 | t.true(GitHub.FOOTER_URLS[p].includes(GitHub.SITE_URI)); 60 | } 61 | }); 62 | 63 | test('construction', (t) => { 64 | const clientId = 'foo'; 65 | const clientSecret = 'bar'; 66 | const client = new GitHub(clientId, clientSecret); 67 | 68 | t.is(client.clientID, clientId); 69 | t.is(client.clientSecret, clientSecret); 70 | t.is(client.lastUpdate, null); 71 | t.false(client.forceRefresh); 72 | t.is(client.pollInterval, 60); 73 | t.is(client._username, ''); 74 | t.deepEqual(client.headers, { 75 | Accept: "application/vnd.github+json", 76 | 'X-GitHub-Api-Version': '2022-11-28', 77 | }); 78 | }); 79 | 80 | test('not authorized', (t) => { 81 | const client = new GitHub(); 82 | 83 | t.false(client.authorized); 84 | }); 85 | 86 | test('authorized', (t) => { 87 | const client = new GitHub(); 88 | client.headers.Authorization = 'lorem upsum'; 89 | 90 | t.true(client.authorized); 91 | }); 92 | 93 | test('info url', (t) => { 94 | const clientId = 'foo'; 95 | const client = new GitHub(clientId); 96 | 97 | t.is(client.infoURL, `${GitHub.SITE_URI}settings/connections/applications/${clientId}`); 98 | }); 99 | 100 | test('username', (t) => { 101 | const client = new GitHub(); 102 | 103 | t.is(client.username, ''); 104 | 105 | client._username = 'lorem upsum'; 106 | t.is(client.username, 'lorem upsum'); 107 | }); 108 | 109 | test('auth url', (t) => { 110 | const clientId = 'foo bar'; 111 | const client = new GitHub(clientId); 112 | const authState = 'lorem ipsum'; 113 | t.is(client.authURL(authState), `${GitHub.SITE_URI}login/oauth/authorize?client_id=${clientId}&scope=${GitHub.SCOPE}&state=${authState}&redirect_uri=${encodeURIComponent(GitHub.REDIRECT_URI.toString())}`); 114 | }); 115 | 116 | test('set token', (t) => { 117 | const client = new GitHub(); 118 | 119 | const token = 'baz'; 120 | client.setToken(token); 121 | 122 | t.true(client.authorized); 123 | t.is(client.headers.Authorization, `token ${token}`); 124 | }); 125 | 126 | test('unset token', (t) => { 127 | const client = new GitHub(); 128 | client.setToken('lorem ipsum'); 129 | 130 | client.unsetToken(); 131 | t.false(client.authorized); 132 | }); 133 | -------------------------------------------------------------------------------- /test/storage-manager.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { default as browser } from "sinon-chrome/webextensions/index.js"; 3 | import StorageManager from '../scripts/storage-manager.js'; 4 | import Storage from '../scripts/storage.js'; 5 | 6 | test.before(() => { 7 | globalThis.browser = browser; 8 | }); 9 | 10 | test.after(() => { 11 | globalThis.browser = undefined; 12 | }); 13 | 14 | test.serial.afterEach.always(() => { 15 | browser.flush(); 16 | }); 17 | 18 | const STATIC_MEMBERS = [ 19 | 'KEY', 20 | 'ID_KEY', 21 | ]; 22 | 23 | const testStaticMember = (t, property) => { 24 | t.true(property in StorageManager); 25 | t.is(typeof StorageManager[property], 'string'); 26 | }; 27 | testStaticMember.title = (title, property) => `${title} ${property}`; 28 | 29 | for(const property of STATIC_MEMBERS) { 30 | test('static', testStaticMember, property); 31 | } 32 | 33 | test.serial('create record', (t) => { 34 | const storageId = 'lorem ipsum'; 35 | const record = StorageManager.createRecord({ 36 | storageId, 37 | }); 38 | 39 | t.true(StorageManager.ID_KEY in record); 40 | t.is(record[StorageManager.ID_KEY], storageId); 41 | }); 42 | 43 | test('construction with default arguments', (t) => { 44 | const storageManager = new StorageManager(); 45 | t.true("StorageInstance" in storageManager); 46 | t.true("area" in storageManager); 47 | 48 | t.is(storageManager.StorageInstance, Storage); 49 | t.is(storageManager.area, 'local'); 50 | }); 51 | 52 | test('construction with arguments', (t) => { 53 | const storageConstructor = class Test {}; 54 | const area = 'managed'; 55 | const storageManager = new StorageManager(storageConstructor, area); 56 | 57 | t.true("StorageInstance" in storageManager); 58 | t.true("area" in storageManager); 59 | 60 | t.is(storageManager.StorageInstance, storageConstructor); 61 | t.is(storageManager.area, area); 62 | }); 63 | 64 | test.serial('set records', async (t) => { 65 | browser.storage.local.set.resolves(); 66 | const storageManager = new StorageManager(); 67 | 68 | const instances = [ 69 | 'foo', 70 | 'bar', 71 | ]; 72 | await storageManager.setRecords(instances); 73 | 74 | t.true(browser.storage.local.set.calledWithMatch({ 75 | [StorageManager.KEY]: instances, 76 | })); 77 | }); 78 | 79 | test.serial('get records', async (t) => { 80 | const storageManager = new StorageManager(); 81 | const data = [ 82 | 'foo', 83 | 'bar', 84 | ]; 85 | browser.storage.local.get.resolves({ 86 | [StorageManager.KEY]: data, 87 | }); 88 | 89 | const results = await storageManager.getRecords(); 90 | 91 | t.deepEqual(results, data); 92 | t.true(browser.storage.local.get.calledWithMatch({ 93 | [StorageManager.KEY]: [], 94 | })); 95 | }); 96 | 97 | test.serial('get instances', async (t) => { 98 | const Instance = class { 99 | constructor(storageId, area) { 100 | this.storageId = storageId; 101 | this.area = area; 102 | } 103 | }; 104 | 105 | const storageManager = new StorageManager(Instance); 106 | const storageId = 'foo'; 107 | browser.storage.local.get.resolves({ 108 | [StorageManager.KEY]: [ { 109 | [StorageManager.ID_KEY]: storageId, 110 | } ], 111 | }); 112 | 113 | const results = await storageManager.getInstances(); 114 | t.is(results.length, 1); 115 | const [ instance ] = results; 116 | t.true(instance instanceof storageManager.StorageInstance); 117 | t.is(instance.storageId, storageId); 118 | t.is(instance.area, storageManager.area); 119 | }); 120 | -------------------------------------------------------------------------------- /test/storage.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { default as browser } from "sinon-chrome/webextensions/index.js"; 3 | import Storage from '../scripts/storage.js'; 4 | 5 | test.before(() => { 6 | globalThis.browser = browser; 7 | }); 8 | 9 | test.after(() => { 10 | globalThis.browser = undefined; 11 | }); 12 | 13 | test.serial.afterEach.always(() => { 14 | browser.flush(); 15 | }); 16 | 17 | test('constructor', (t) => { 18 | const storage = new Storage('foo'); 19 | t.is(storage.storageId, 'foo'); 20 | t.is(storage.area, 'local'); 21 | }); 22 | 23 | test('generate key', (t) => { 24 | const storage = new Storage('foo'); 25 | const key = storage.getStorageKey('test'); 26 | t.true(key.includes('foo')); 27 | t.true(key.includes('test')); 28 | }); 29 | 30 | test.serial('get default value', async (t) => { 31 | browser.storage.local.get.resolves({}); 32 | const storage = new Storage('foo'); 33 | t.is(await storage.getValue('test', 1), 1); 34 | }); 35 | 36 | test.serial('get default without default', async (t) => { 37 | browser.storage.local.get.resolves({}); 38 | const storage = new Storage('foo'); 39 | t.is(await storage.getValue('test'), undefined); 40 | }); 41 | 42 | test.serial('get value', async (t) => { 43 | browser.storage.local.get.resolves({ 44 | 'foo_test': 2, 45 | }); 46 | const storage = new Storage('foo'); 47 | t.is(await storage.getValue('test', 1), 2); 48 | }); 49 | 50 | test.serial('set value', (t) => { 51 | const storage = new Storage('foo'); 52 | storage.setValue('test', 1); 53 | t.true(browser.storage.local.set.calledOnce); 54 | t.deepEqual(browser.storage.local.set.lastCall.args[0], { 55 | 'foo_test': 1, 56 | }); 57 | }); 58 | 59 | test.serial('reset values', (t) => { 60 | const storage = new Storage('foo'); 61 | storage.removeValues([ 62 | 'lorem', 63 | 'ipsum', 64 | ]); 65 | t.true(browser.storage.local.remove.calledOnce); 66 | t.deepEqual(browser.storage.local.remove.lastCall.args[0], [ 67 | 'foo_lorem', 68 | 'foo_ipsum', 69 | ]); 70 | }); 71 | --------------------------------------------------------------------------------