├── .eslintrc.yaml ├── .firefox-profile └── .gitkeep ├── .github └── workflows │ ├── codequality.yml │ ├── prepare-release.yml │ └── publish-extension.yml ├── .gitignore ├── .node-version ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── documentation ├── example_screenshot.png └── get-the-addon-178x60px.png ├── jsconfig.json ├── package-lock.json ├── package.json ├── scripts ├── build.mjs ├── shared.mjs └── watch.mjs ├── src ├── content_scripts │ └── protoots.js ├── icons │ ├── icon full_size │ │ ├── icon full_size.css │ │ ├── icon full_size.html │ │ └── icon full_size.png │ └── icon small_size │ │ ├── icon small_size.css │ │ ├── icon small_size.html │ │ ├── icon small_size.png │ │ └── icon small_size.svg ├── libs │ ├── caching.js │ ├── domhelpers.js │ ├── fetchPronouns.js │ ├── logging.js │ ├── platforms │ │ └── mastodon.js │ ├── pronouns.js │ ├── protootshelpers.js │ └── settings.js ├── manifest.json ├── options │ ├── options.css │ ├── options.html │ └── options.js └── styles │ └── proplate.css └── tests └── extractPronouns.spec.js /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | 6 | extends: 7 | - "eslint:recommended" 8 | - "prettier" 9 | 10 | parserOptions: 11 | ecmaVersion: latest 12 | sourceType: "module" 13 | 14 | rules: 15 | no-console: error 16 | camelcase: warn 17 | consistent-return: warn 18 | dot-notation: warn 19 | func-style: ["warn", "declaration"] 20 | prefer-const: warn 21 | -------------------------------------------------------------------------------- /.firefox-profile/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsVipra/ProToots/a9e740014f13f91d06ff50de5ce83bbeec9dc471/.firefox-profile/.gitkeep -------------------------------------------------------------------------------- /.github/workflows/codequality.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | format: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | # Give the default GITHUB_TOKEN write permission to commit and push the 13 | # added or changed files to the repository. 14 | contents: write 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | ref: ${{ github.head_ref }} 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Format files using prettier 22 | run: npm run format 23 | - uses: stefanzweifel/git-auto-commit-action@v4 24 | name: Commit possible changes 25 | with: 26 | commit_message: "Format files using prettier" 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Install dependencies 33 | run: npm ci 34 | - name: Run eslint 35 | run: npm run lint 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Install dependencies 42 | run: npm ci 43 | - name: Run tests 44 | run: npm test 45 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | name: Prepare release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The version number to use for this release, should start with the 'v' prefix." 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Update package version 20 | run: npm version from-git --no-git-tag-version 21 | - uses: stefanzweifel/git-auto-commit-action@v4 22 | name: Commit package version update 23 | with: 24 | commit_message: "Update to ${{ inputs.version }}" 25 | file_pattern: "package*.json" 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Build packages 30 | run: npm run package 31 | 32 | # Create automatic draft release for pushes of tags, prefilled with all the important information. 33 | # And since we have our files, let's attach them as well. 34 | - name: Create GitHub Release 35 | uses: softprops/action-gh-release@v1 36 | with: 37 | draft: true 38 | tag_name: ${{ inputs.version }} 39 | generate_release_notes: true 40 | files: "web-ext-artifacts/*.zip" 41 | -------------------------------------------------------------------------------- /.github/workflows/publish-extension.yml: -------------------------------------------------------------------------------- 1 | name: Publish extension in stores 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | firefox: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Download assets for release 13 | run: > 14 | gh release download ${{ github.ref }} --archive zip --dir assets --output source.zip 15 | gh release download ${{ github.ref }} --pattern "*firefox*" --dir assets 16 | - uses: browser-actions/release-firefox-addon@v0.1.3 17 | with: 18 | addon-id: "protoots" 19 | addon-path: "assets/protoots-firefox.zip" 20 | source-path: "assets/source.zip" 21 | approval-note: | 22 | The source code requires Node.js 18 or newer. To generate the source code, run: 23 | 24 | npm ci 25 | npm run package 26 | 27 | To check it's functionality, you can enable it on any Mastodon page, such as: https://mastodon.social/public 28 | license: OSL-3.0 29 | auth-api-issuer: ${{ secrets.FIREFOX_AUTH_API_ISSUER }} 30 | auth-api-secret: ${{ secrets.FIREFOX_AUTH_API_SECRET }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | # web-ext-artifacts 147 | web-ext-artifacts 148 | 149 | # note files 150 | TODO 151 | edgecases 152 | notes 153 | .firefox-profile 154 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v18.16.0 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[markdown]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode", 16 | "files.trimTrailingWhitespace": false 17 | }, 18 | "[typescript]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "files.trimTrailingWhitespace": true, 22 | "files.trimFinalNewlines": true, 23 | "files.insertFinalNewline": true, 24 | "editor.formatOnSave": true 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Open Software License ("OSL") v 3.0 2 | 3 | This Open Software License (the "License") applies to any original work of 4 | authorship (the "Original Work") whose owner (the "Licensor") has placed the 5 | following licensing notice adjacent to the copyright notice for the Original 6 | Work: 7 | 8 | Licensed under the Open Software License version 3.0 9 | 10 | 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, 11 | non-exclusive, sublicensable license, for the duration of the copyright, to do 12 | the following: 13 | 14 | a) to reproduce the Original Work in copies, either alone or as part of a 15 | collective work; 16 | 17 | b) to translate, adapt, alter, transform, modify, or arrange the Original 18 | Work, thereby creating derivative works ("Derivative Works") based upon the 19 | Original Work; 20 | 21 | c) to distribute or communicate copies of the Original Work and Derivative 22 | Works to the public, with the proviso that copies of Original Work or 23 | Derivative Works that You distribute or communicate shall be licensed under 24 | this Open Software License; 25 | 26 | d) to perform the Original Work publicly; and 27 | 28 | e) to display the Original Work publicly. 29 | 30 | 2. Grant of Patent License. Licensor grants You a worldwide, royalty- free, 31 | non-exclusive, sublicensable license, under patent claims owned or controlled 32 | by the Licensor that are embodied in the Original Work as furnished by the 33 | Licensor, for the duration of the patents, to make, use, sell, offer for sale, 34 | have made, and import the Original Work and Derivative Works. 35 | 36 | 3. Grant of Source Code License. The term "Source Code" means the preferred 37 | form of the Original Work for making modifications to it and all available 38 | documentation describing how to modify the Original Work. Licensor agrees to 39 | provide a machine-readable copy of the Source Code of the Original Work along 40 | with each copy of the Original Work that Licensor distributes. Licensor 41 | reserves the right to satisfy this obligation by placing a machine-readable 42 | copy of the Source Code in an information repository reasonably calculated to 43 | permit inexpensive and convenient access by You for as long as Licensor 44 | continues to distribute the Original Work. 45 | 46 | 4. Exclusions From License Grant. Neither the names of Licensor, nor the names 47 | of any contributors to the Original Work, nor any of their trademarks or 48 | service marks, may be used to endorse or promote products derived from this 49 | Original Work without express prior permission of the Licensor. Except as 50 | expressly stated herein, nothing in this License grants any license to 51 | Licensor's trademarks, copyrights, patents, trade secrets or any other 52 | intellectual property. No patent license is granted to make, use, sell, offer 53 | for sale, have made, or import embodiments of any patent claims other than the 54 | licensed claims defined in Section 2. No license is granted to the trademarks 55 | of Licensor even if such marks are included in the Original Work. Nothing in 56 | this License shall be interpreted to prohibit Licensor from licensing under 57 | terms different from this License any Original Work that Licensor otherwise 58 | would have a right to license. 59 | 60 | 5. External Deployment. The term "External Deployment" means the use, 61 | distribution, or communication of the Original Work or Derivative Works in any 62 | way such that the Original Work or Derivative Works may be used by anyone 63 | other than You, whether those works are distributed or communicated to those 64 | persons or made available as an application intended for use over a network. 65 | As an express condition for the grants of license hereunder, You must treat 66 | any External Deployment by You of the Original Work or a Derivative Work as a 67 | distribution under section 1(c). 68 | 69 | 6. Attribution Rights. You must retain, in the Source Code of any Derivative 70 | Works that You create, all copyright, patent, or trademark notices from the 71 | Source Code of the Original Work, as well as any notices of licensing and any 72 | descriptive text identified therein as an "Attribution Notice." You must cause 73 | the Source Code for any Derivative Works that You create to carry a prominent 74 | Attribution Notice reasonably calculated to inform recipients that You have 75 | modified the Original Work. 76 | 77 | 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that 78 | the copyright in and to the Original Work and the patent rights granted herein 79 | by Licensor are owned by the Licensor or are sublicensed to You under the 80 | terms of this License with the permission of the contributor(s) of those 81 | copyrights and patent rights. Except as expressly stated in the immediately 82 | preceding sentence, the Original Work is provided under this License on an "AS 83 | IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without 84 | limitation, the warranties of non-infringement, merchantability or fitness for 85 | a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK 86 | IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this 87 | License. No license to the Original Work is granted by this License except 88 | under this disclaimer. 89 | 90 | 8. Limitation of Liability. Under no circumstances and under no legal theory, 91 | whether in tort (including negligence), contract, or otherwise, shall the 92 | Licensor be liable to anyone for any indirect, special, incidental, or 93 | consequential damages of any character arising as a result of this License or 94 | the use of the Original Work including, without limitation, damages for loss 95 | of goodwill, work stoppage, computer failure or malfunction, or any and all 96 | other commercial damages or losses. This limitation of liability shall not 97 | apply to the extent applicable law prohibits such limitation. 98 | 99 | 9. Acceptance and Termination. If, at any time, You expressly assented to this 100 | License, that assent indicates your clear and irrevocable acceptance of this 101 | License and all of its terms and conditions. If You distribute or communicate 102 | copies of the Original Work or a Derivative Work, You must make a reasonable 103 | effort under the circumstances to obtain the express assent of recipients to 104 | the terms of this License. This License conditions your rights to undertake 105 | the activities listed in Section 1, including your right to create Derivative 106 | Works based upon the Original Work, and doing so without honoring these terms 107 | and conditions is prohibited by copyright law and international treaty. 108 | Nothing in this License is intended to affect copyright exceptions and 109 | limitations (including "fair use" or "fair dealing"). This License shall 110 | terminate immediately and You may no longer exercise any of the rights granted 111 | to You by this License upon your failure to honor the conditions in Section 112 | 1(c). 113 | 114 | 10. Termination for Patent Action. This License shall terminate automatically 115 | and You may no longer exercise any of the rights granted to You by this 116 | License as of the date You commence an action, including a cross-claim or 117 | counterclaim, against Licensor or any licensee alleging that the Original Work 118 | infringes a patent. This termination provision shall not apply for an action 119 | alleging patent infringement by combinations of the Original Work with other 120 | software or hardware. 121 | 122 | 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this 123 | License may be brought only in the courts of a jurisdiction wherein the 124 | Licensor resides or in which Licensor conducts its primary business, and under 125 | the laws of that jurisdiction excluding its conflict-of-law provisions. The 126 | application of the United Nations Convention on Contracts for the 127 | International Sale of Goods is expressly excluded. Any use of the Original 128 | Work outside the scope of this License or after its termination shall be 129 | subject to the requirements and penalties of copyright or patent law in the 130 | appropriate jurisdiction. This section shall survive the termination of this 131 | License. 132 | 133 | 12. Attorneys' Fees. In any action to enforce the terms of this License or 134 | seeking damages relating thereto, the prevailing party shall be entitled to 135 | recover its costs and expenses, including, without limitation, reasonable 136 | attorneys' fees and costs incurred in connection with such action, including 137 | any appeal of such action. This section shall survive the termination of this 138 | License. 139 | 140 | 13. Miscellaneous. If any provision of this License is held to be 141 | unenforceable, such provision shall be reformed only to the extent necessary 142 | to make it enforceable. 143 | 144 | 14. Definition of "You" in This License. "You" throughout this License, 145 | whether in upper or lower case, means an individual or a legal entity 146 | exercising rights under, and complying with all of the terms of, this License. 147 | For legal entities, "You" includes any entity that controls, is controlled by, 148 | or is under common control with you. For purposes of this definition, 149 | "control" means (i) the power, direct or indirect, to cause the direction or 150 | management of such entity, whether by contract or otherwise, or (ii) ownership 151 | of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 152 | ownership of such entity. 153 | 154 | 15. Right to Use. You may use the Original Work in all ways not otherwise 155 | restricted or conditioned by this License or by law, and Licensor promises not 156 | to interfere with or be responsible for such uses by You. 157 | 158 | 16. Modification of This License. This License is Copyright © 2005 Lawrence 159 | Rosen. Permission is granted to copy, distribute, or communicate this License 160 | without modification. Nothing in this License permits You to modify this 161 | License as applied to the Original Work or to Derivative Works. However, You 162 | may modify the text of this License and copy, distribute or communicate your 163 | modified version (the "Modified License") and apply it to other original works 164 | of authorship subject to the following conditions: (i) You may not indicate in 165 | any way that your Modified License is the "Open Software License" or "OSL" and 166 | you may not use those names in the name of your Modified License; (ii) You 167 | must replace the notice specified in the first paragraph above with the notice 168 | "Licensed under " or with a notice of your own 169 | that is not confusingly similar to the notice in this License; and (iii) You 170 | may not claim that your original works are open source software unless your 171 | Modified License has been approved by Open Source Initiative (OSI) and You 172 | comply with its license review and certification process. 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProToots 2 | 3 | A Firefox extension which displays an author's pronouns next to their name on Mastodon. (Now in even more places!) 4 | 5 | ![A Mastodon screenshot showing off pronouns next to a person's name](documentation/example_screenshot.png) 6 | 7 | --- 8 | 9 | ## Download/Installation 10 | 11 | The extension is available on the firefox store! (we're working on chrome) 12 | 13 | [](https://addons.mozilla.org/en-US/firefox/addon/protoots/) 14 | 15 | Alternatively you can download an unsigned version from the [releases page](https://github.com/ItsVipra/Protoots/releases). 16 | 17 | --- 18 | 19 | ## Known issues 20 | 21 | - None! It's perfect! 22 | - no but seriously, please submit any bugs you find as an issue :3 23 | 24 | Please also take a look at the FAQ below and the [issue list](https://github.com/ItsVipra/ProToots/issues). 25 | 26 | --- 27 | 28 | ## FAQ 29 | 30 | ### Why does ProToots need permission for all websites? 31 | 32 | > The addon needs to determine whether or not the site you are currently browsing is a Mastodon server. For that to work, it requires access to all sites. Otherwise, each existing Mastodon server would have to be explicitly added. 33 | 34 | ### Why can't I see any ProPlates? 35 | 36 | > It is likely your instance is not supported. This is because forks of Mastodon all work slightly differently and we cannot account for every version out there. 37 | > If ProToots isn't working on your instance please tell your admins to contact us here on Github. 38 | 39 | ### ProPlates don't have a background/low contrast on my instance. 40 | 41 | > Mastodon does not provide set variables for element colors, so we have to adjust the plate styling to each theme. If they're not displaying correctly please tell your admins to [follow these steps to style ProPlates](#how-do-i-style-proplates-to-correctly-display-on-my-themes). 42 | 43 | ### Somebody has added/changed pronouns, why is ProToots still showing no/their old pronouns? 44 | 45 | > In order to strain your instance less pronouns are cached for 24h, so it might take up to a day to see this change reflected. 46 | > Alternatively you can simply hit the "Reset cache" button in the addon settings. 47 | 48 | ### Why does the ProPlate just show a link? 49 | 50 | > When an author only provides their pronouns as a pronouns.page link we display that instead. In the future we'll be able to extract pronouns from the given link. (See [#7](https://github.com/ItsVipra/ProToots/issues/7)) 51 | 52 | --- 53 | 54 | ## Instance admin info 55 | 56 | ### Protoots aren't working on my instance 57 | 58 | > Currently ProToots only looks for specific classes and IDs. If your instance has changed the name of those, ProToots will not find them. 59 | > Especially the **parent div with id 'Mastodon'** is important, since without that no other code will run. 60 | > Please open an issue with your server name and info on which names you've changed, so we can add support for your instance. 61 | > We're also working on a way to more easily support many different types of fedi software, such as Misskey or Akkoma. See [#12](https://github.com/ItsVipra/ProToots/issues/12) 62 | 63 | ### How do I style ProPlates to correctly display on my themes? 64 | 65 | > You can set their background-color and color attribute for each theme. 66 | > To do this simply add some CSS to your server. [Here's how.](https://fedi.tips/customising-your-mastodon-servers-appearance/) 67 | > See [our default styles](/src/styles/proplate.css) for reference. 68 | 69 | --- 70 | 71 | ## Developer setup 72 | 73 | - Clone the repository 74 | - Install the required dependencies using `npm install` 75 | - Start the development workflow with `npm start` 76 | - Build with `npm run package` 77 | - Mess around with with [protoots.js](/src/content_scripts/protoots.js) 78 | - Trans rights! 79 | -------------------------------------------------------------------------------- /documentation/example_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsVipra/ProToots/a9e740014f13f91d06ff50de5ce83bbeec9dc471/documentation/example_screenshot.png -------------------------------------------------------------------------------- /documentation/get-the-addon-178x60px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsVipra/ProToots/a9e740014f13f91d06ff50de5ce83bbeec9dc471/documentation/get-the-addon-178x60px.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "checkJs": true, 6 | "moduleResolution": "nodenext" 7 | }, 8 | "include": ["src/**/*.js", "tests/**/*.js"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "protoots", 3 | "scripts": { 4 | "build:scripts": "node scripts/build.mjs", 5 | "build:webext": "web-ext build --overwrite-dest --filename protoots-firefox.zip", 6 | "start": "run-p -l -r watch:**", 7 | "watch:scripts": "node scripts/watch.mjs", 8 | "watch:webext": "web-ext run --keep-profile-changes --profile-create-if-missing --firefox-profile=.firefox-profile/", 9 | "format": "prettier --write --ignore-path .gitignore .", 10 | "package": "run-s -l build:**", 11 | "archive": "git archive --format zip main -o protoots_source.zip", 12 | "lint": "eslint src/", 13 | "test": "uvu tests/" 14 | }, 15 | "devDependencies": { 16 | "@sprout2000/esbuild-copy-plugin": "1.1.8", 17 | "esbuild": "0.17.19", 18 | "eslint": "^8.42.0", 19 | "eslint-config-prettier": "^8.8.0", 20 | "npm-run-all": "^4.1.5", 21 | "prettier": "^2.8.8", 22 | "uvu": "^0.5.6", 23 | "web-ext": "^7.6.2" 24 | }, 25 | "prettier": { 26 | "endOfLine": "lf", 27 | "printWidth": 100, 28 | "useTabs": true, 29 | "trailingComma": "all" 30 | }, 31 | "type": "module", 32 | "webExt": { 33 | "sourceDir": "dist/" 34 | }, 35 | "dependencies": { 36 | "sanitize-html": "^2.11.0", 37 | "webextension-polyfill": "^0.10.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import { defaultBuildOptions } from "./shared.mjs"; 3 | 4 | await esbuild.build({ 5 | ...defaultBuildOptions, 6 | }); 7 | -------------------------------------------------------------------------------- /scripts/shared.mjs: -------------------------------------------------------------------------------- 1 | import copyPluginPkg from "@sprout2000/esbuild-copy-plugin"; 2 | import path from "path"; 3 | const { copyPlugin } = copyPluginPkg; // js and your fucking mess of imports, sigh. 4 | 5 | /** 6 | * This array contains all files that we want to handle with esbuild. 7 | * For now, this is limited to our scripts, but it can be extended to more files in the future if needed. 8 | * 9 | * @type {string[]} 10 | */ 11 | const files = [ 12 | path.join("src", "content_scripts", "protoots.js"), 13 | path.join("src", "options", "options.js"), 14 | ]; 15 | 16 | /** 17 | * @type {import("esbuild").BuildOptions} 18 | */ 19 | export const defaultBuildOptions = { 20 | entryPoints: files, 21 | 22 | // Use bundling. Especially useful because web extensions do not support it by default for some reason. 23 | bundle: true, 24 | 25 | // Settings for the correct esbuild output. 26 | outbase: "src", 27 | outdir: "dist", 28 | 29 | // Because we modify the files, sourcemaps are essential for us. 30 | sourcemap: "inline", 31 | 32 | // self-explanatory 33 | platform: "browser", 34 | logLevel: "info", 35 | 36 | // Copy all files from src/ except our build files (they would be overwritten) to dist/. 37 | plugins: [ 38 | copyPlugin({ 39 | src: "src", 40 | dest: "dist", 41 | recursive: true, 42 | 43 | // Return true if the file should be copied and false otherwise. 44 | filter: (src) => !src.endsWith(".js"), 45 | }), 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /scripts/watch.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import { defaultBuildOptions } from "./shared.mjs"; 3 | let ctx = await esbuild.context(defaultBuildOptions); 4 | await ctx.watch(); 5 | -------------------------------------------------------------------------------- /src/content_scripts/protoots.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // obligatory crime. because be gay, do crime. 3 | // 8======D 4 | 5 | const contributorList = [ 6 | "vi@toot.vipra.vip", 7 | "jasmin@queer.group", 8 | "aurora@queer-lexikon.net", 9 | "LenaEine@chaos.social", 10 | ]; 11 | 12 | import { fetchPronouns } from "../libs/fetchPronouns"; 13 | import { 14 | accountVisibility, 15 | conversationVisibility, 16 | getSettings, 17 | isLogging, 18 | notificationVisibility, 19 | statusVisibility, 20 | } from "../libs/settings"; 21 | import { warn, log } from "../libs/logging"; 22 | import { 23 | findAllDescendants, 24 | hasClasses, 25 | insertAfter, 26 | waitForElement, 27 | waitForElementRemoved, 28 | } from "../libs/domhelpers"; 29 | import { 30 | accountNameFromURL, 31 | addTypeAttribute, 32 | normaliseAccountName, 33 | } from "../libs/protootshelpers.js"; 34 | import { debug } from "../libs/logging.js"; 35 | 36 | //before anything else, check whether we're on a Mastodon page 37 | checkSite(); 38 | // log("hey vippy, du bist cute <3") 39 | 40 | /** 41 | * Checks whether site responds to Mastodon API Calls. 42 | * If so creates an 'readystatechange' EventListener, with callback to main() 43 | */ 44 | async function checkSite() { 45 | getSettings(); 46 | 47 | document.addEventListener("readystatechange", main, { once: true }); 48 | } 49 | 50 | /** 51 | * Evaluates the result of document.querySelector("#mastodon") and only creates a MutationObserver if the site is Mastodon. 52 | * Warns that site is not Mastodon otherwise. 53 | * - This prevents any additional code from being run. 54 | * 55 | */ 56 | function main() { 57 | // debug('selection for id mastodon', {'result': document.querySelector("#mastodon")}) 58 | if (!document.querySelector("#mastodon")) { 59 | warn("Not a Mastodon instance"); 60 | return; 61 | } 62 | 63 | //All of this is Mastodon specific - factor out into mastodon.js? 64 | log("Mastodon instance, activating Protoots"); 65 | 66 | //create a global tootObserver to handle all article objects 67 | const tootObserver = new IntersectionObserver((entries) => { 68 | onTootIntersection(entries); 69 | }); 70 | 71 | // We are tracking navigation changes with the location and a MutationObserver on `document`, 72 | // because the popstate event from the History API is only triggered with the back/forward buttons. 73 | let lastUrl = location.href; 74 | new MutationObserver((mutations) => { 75 | const url = location.href; 76 | if (url !== lastUrl) { 77 | lastUrl = url; 78 | } 79 | 80 | /** 81 | * Checks whether the given n is eligible to have a proplate added 82 | * @param {Node} n 83 | * @returns {Boolean} 84 | */ 85 | function isPronounableElement(n) { 86 | return ( 87 | n instanceof HTMLElement && 88 | ((n.nodeName == "ARTICLE" && n.hasAttribute("data-id")) || 89 | hasClasses( 90 | n, 91 | "detailed-status", 92 | "status-public", 93 | "status-unlisted", 94 | "status-private", 95 | "status-direct", 96 | "conversation", 97 | "account-authorize", 98 | "notification", 99 | "notification__message", 100 | "account", 101 | )) 102 | ); 103 | } 104 | 105 | mutations 106 | .flatMap((m) => Array.from(m.addedNodes).map((m) => findAllDescendants(m))) 107 | .flat() 108 | // .map((n) => console.log("found node: ", n)); 109 | .filter(isPronounableElement) 110 | .forEach((a) => addtoTootObserver(a, tootObserver)); 111 | }).observe(document, { subtree: true, childList: true }); 112 | } 113 | 114 | /** 115 | * Callback for TootObserver 116 | * 117 | * Loops through all IntersectionObserver entries and checks whether each toot is on screen. If so a proplate will be added once the toot is ready. 118 | * 119 | * Once a toot has left the viewport its "protoots-checked" attribute will be removed. 120 | * @param {IntersectionObserverEntry[]} observerentries 121 | */ 122 | function onTootIntersection(observerentries) { 123 | for (const observation of observerentries) { 124 | const ArticleElement = observation.target; 125 | if (!observation.isIntersecting) { 126 | waitForElementRemoved(ArticleElement, ".protoots-proplate", () => { 127 | ArticleElement.removeAttribute("protoots-checked"); 128 | }); 129 | } else { 130 | if (ArticleElement.getAttribute("protoots-type") == "conversation") { 131 | waitForElement(ArticleElement, ".conversation__content__names", () => 132 | addProplate(ArticleElement), 133 | ); 134 | } else if (ArticleElement.nodeName == "ASIDE") { 135 | //glitch-soc notifications 136 | waitForElement(ArticleElement, ".status__display-name", () => { 137 | addProplate(ArticleElement); 138 | }); 139 | } else { 140 | waitForElement(ArticleElement, ".display-name", () => addProplate(ArticleElement)); 141 | } 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Adds ActionElement to the tootObserver, if it has not been added before. 148 | * @param {HTMLElement} ActionElement 149 | * @param {IntersectionObserver} tootObserver Observer to add the element to 150 | */ 151 | function addtoTootObserver(ActionElement, tootObserver) { 152 | // console.log(ActionElement); 153 | if (ActionElement.hasAttribute("protoots-tracked")) return; 154 | 155 | addTypeAttribute(ActionElement); 156 | ActionElement.setAttribute("protoots-tracked", "true"); 157 | tootObserver.observe(ActionElement); 158 | } 159 | 160 | /** 161 | * Adds the pro-plate to the element. The caller needs to ensure that the passed element 162 | * is defined and that it's either a: 163 | * 164 | * -
with the "protoots-type" attribute 165 | * 166 | * -
with the "protoots-type" of either "status" or "detailed-status" 167 | * 168 | * Although it's possible to pass raw {@type Element}s, the method only does things on elements of type {@type HTMLElement}. 169 | * 170 | * @param {Node | Element | HTMLElement} element The status where the element should be added. 171 | */ 172 | async function addProplate(element) { 173 | if (!(element instanceof HTMLElement)) return; 174 | 175 | if (element.hasAttribute("protoots-checked")) return; 176 | 177 | const type = element.getAttribute("protoots-type"); 178 | 179 | //objects that are not statuses would be added twice, 180 | //notifications and such do not have their own data-id, just their articles 181 | if (element.nodeName == "DIV" && !(type === "status" || type === "detailed-status")) { 182 | element.setAttribute("protoots-checked", "true"); 183 | return; 184 | } 185 | 186 | if (element.querySelector(".protoots-proplate")) return; 187 | 188 | switch (type) { 189 | case "status": 190 | case "detailed-status": 191 | if (statusVisibility()) addToStatus(element); 192 | break; 193 | case "notification": 194 | if (notificationVisibility()) addToNotification(element); 195 | break; 196 | case "account": 197 | case "account-authorize": 198 | if (accountVisibility()) addToAccount(element); 199 | break; 200 | case "conversation": 201 | if (conversationVisibility()) addToConversation(element); 202 | break; 203 | } 204 | 205 | /** 206 | * Generates a proplate and adds it as a sibling of the given nameTagEl 207 | * @param {string|undefined} statusId Id of the target object 208 | * @param {string|null} accountName Name of the account the plate is for 209 | * @param {HTMLElement|null} nametagEl Element to add the proplate next to 210 | * @param {string} type type of the target object 211 | * @returns 212 | */ 213 | async function generateProPlate(statusId, accountName, nametagEl, type) { 214 | debug("generateProPlate called with params", { statusId, accountName, nametagEl, type }); 215 | if (!statusId) throw new Error("empty statusId passed to proplate generation, aborting."); 216 | if (!accountName) throw new Error("empty accountName passed to proplate generation, aborting."); 217 | if (!nametagEl) throw new Error("empty nametagEl passed to proplate generation, aborting."); 218 | 219 | //create plate 220 | const proplate = document.createElement("span"); 221 | const pronouns = await fetchPronouns(statusId, accountName, type); 222 | 223 | if (pronouns == "null" && !isLogging()) { 224 | return; 225 | } 226 | proplate.innerText = pronouns; 227 | proplate.title = pronouns; 228 | proplate.classList.add("protoots-proplate"); 229 | if (contributorList.includes(accountName)) { 230 | //i think you can figure out what this does on your own 231 | proplate.classList.add("proplate-pog"); 232 | } 233 | //add plate to nametag 234 | insertAfter(proplate, nametagEl); 235 | } 236 | 237 | /** 238 | * Gets the data-id from the given element 239 | * @param {HTMLElement} element Element with data-id attribute 240 | * @returns {string|undefined} 241 | */ 242 | function getID(element) { 243 | let id = element.dataset.id; 244 | if (!id) { 245 | // We don't have a status ID, pronouns might not be in cache 246 | warn( 247 | "The element passed to addProplate does not have a data-id attribute, searching for article.", 248 | element, 249 | ); 250 | //if we couldn't get an id from the div try the closest article 251 | id = element.closest("article[data-id]")?.dataset.id; 252 | } 253 | if (id) id = id.replace("f-", ""); 254 | return id; 255 | } 256 | 257 | /** 258 | * Basically just element.querySelector, but outputs a warning if the element isn't found 259 | * @param {HTMLElement} element 260 | * @param {string} accountNameClass 261 | * @returns {HTMLElement|null} 262 | */ 263 | function getAccountNameEl(element, accountNameClass) { 264 | const accountNameEl = /** @type {HTMLElement|null} */ (element.querySelector(accountNameClass)); 265 | if (!accountNameEl) { 266 | warn( 267 | `The element passed to addProplate does not have a ${accountNameClass}, although it should have one.`, 268 | element, 269 | ); 270 | } 271 | return accountNameEl; 272 | } 273 | 274 | /** 275 | * Gets the given element's textcontent or given attribute 276 | * @param {HTMLElement|null} element Element which textcontent is the account name 277 | * @param {string} attribute Attribute from which to pull the account name 278 | * @returns {string|null} Normalised account name or null if it can't be found. 279 | */ 280 | function getAccountName(element, attribute = "textContent") { 281 | if (!element) return null; 282 | let accountName = element.textContent; 283 | if (attribute != "textContent") { 284 | accountName = element.getAttribute(attribute); 285 | } 286 | 287 | if (!accountName) { 288 | warn( 289 | `Could not extract the account name from the element, using attribute ${attribute} aborting pronoun extraction:`, 290 | element, 291 | ); 292 | return null; 293 | } 294 | 295 | accountName = normaliseAccountName(accountName); 296 | 297 | return accountName; 298 | } 299 | 300 | /** 301 | * 302 | * @param {HTMLElement} element 303 | * @param {string} nametagClass 304 | * @returns {HTMLElement|null} 305 | */ 306 | function getNametagEl(element, nametagClass) { 307 | const nametagEl = /** @type {HTMLElement|null} */ (element.querySelector(nametagClass)); 308 | if (!nametagEl) { 309 | warn( 310 | "The element passed to addProplate does not have a .display-name__html, although it should have one.", 311 | element, 312 | ); 313 | } 314 | return nametagEl; 315 | } 316 | 317 | async function addToStatus(element) { 318 | let statusId = getID(element); 319 | if (!statusId) { 320 | if (type === "detailed-status") { 321 | //if we still don't have an ID try the domain as a last resort 322 | warn("Attempting to retrieve id from url - this may have unforseen consequences."); 323 | statusId = location.pathname.split("/").pop(); 324 | } 325 | } 326 | 327 | const accountNameEl = getAccountNameEl(element, ".display-name__account"); 328 | const accountName = getAccountName(accountNameEl); 329 | 330 | const nametagEl = getNametagEl(element, ".display-name__html"); 331 | nametagEl.parentElement.classList.add("has-proplate"); 332 | element.setAttribute("protoots-checked", "true"); 333 | // Add the checked attribute only _after_ we've passed the basic checks. 334 | // This allows us to pass incomplete nodes into this method, because 335 | // we only process them after we have all required information. 336 | 337 | generateProPlate(statusId, accountName, nametagEl, "status"); 338 | } 339 | 340 | async function addToNotification(element) { 341 | //debug("adding to notification"); 342 | const statusId = getID(element); 343 | 344 | let accountNameEl = getAccountNameEl(element, ".notification__display-name"); 345 | if (!accountNameEl) accountNameEl = getAccountNameEl(element, ".status__display-name"); 346 | 347 | let accountName = getAccountName(accountNameEl, "title"); 348 | if (!accountName) { 349 | accountName = accountNameFromURL(getAccountName(accountNameEl, "href")); 350 | } 351 | 352 | let nametagEl = getNametagEl(element, ".notification__display-name"); 353 | if (!nametagEl) return; 354 | 355 | element.setAttribute("protoots-checked", "true"); 356 | generateProPlate(statusId, accountName, nametagEl, "notification"); 357 | } 358 | 359 | async function addToAccount(element) { 360 | //debug("adding to account"); 361 | const statusId = getID(element); 362 | const nametagEl = element.querySelector(".display-name__html"); 363 | const accountName = getAccountName(element.querySelector(".display-name__account")); 364 | 365 | nametagEl.parentElement.classList.add("has-proplate"); 366 | 367 | element.setAttribute("protoots-checked", "true"); 368 | 369 | generateProPlate(statusId, accountName, nametagEl, "account"); 370 | } 371 | 372 | async function addToConversation(element) { 373 | const nametagEls = element.querySelectorAll(".display-name__html"); 374 | 375 | for (const nametagEl of nametagEls) { 376 | const accountName = getAccountName(nametagEl.parentElement.parentElement, "title"); 377 | generateProPlate("null", accountName, nametagEl, "conversation"); 378 | } 379 | element.setAttribute("protoots-checked", "true"); 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/icons/icon full_size/icon full_size.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: black; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | div { 9 | background-color: white; 10 | border-radius: 1em; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .gradient { 16 | display: grid; 17 | grid-template-columns: repeat(3, 1fr); 18 | background-image: linear-gradient(90deg, #5bcffa, 30%, #f5abb9); 19 | -webkit-background-clip: text; 20 | } 21 | 22 | span { 23 | font-family: "cascadia code"; 24 | font-size: 1000%; 25 | background-size: 100%; 26 | 27 | -webkit-text-fill-color: transparent; 28 | } 29 | 30 | .P { 31 | margin-left: 0.25em; 32 | } 33 | 34 | .slash { 35 | justify-self: center; 36 | align-self: center; 37 | margin-left: 0; 38 | margin-right: 0.1em; 39 | -webkit-text-fill-color: black; 40 | } 41 | 42 | .T { 43 | margin-right: 0.25em; 44 | } 45 | -------------------------------------------------------------------------------- /src/icons/icon full_size/icon full_size.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 | P 9 | / 10 | T 11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/icons/icon full_size/icon full_size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsVipra/ProToots/a9e740014f13f91d06ff50de5ce83bbeec9dc471/src/icons/icon full_size/icon full_size.png -------------------------------------------------------------------------------- /src/icons/icon small_size/icon small_size.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: black; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | div { 9 | background-color: white; 10 | border-radius: 2em; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .gradient { 16 | display: grid; 17 | grid-template-columns: repeat(3, 1fr); 18 | } 19 | 20 | span { 21 | font-family: "cascadia code"; 22 | font-size: 500%; 23 | background-size: 100%; 24 | } 25 | 26 | .P { 27 | margin-left: 0.25em; 28 | color: #5bcffa; 29 | } 30 | 31 | .slash { 32 | justify-self: center; 33 | align-self: center; 34 | margin-left: 0; 35 | margin-right: 0.1em; 36 | } 37 | 38 | .T { 39 | margin-right: 0.25em; 40 | color: #f5abb9; 41 | } 42 | -------------------------------------------------------------------------------- /src/icons/icon small_size/icon small_size.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 | P 9 | / 10 | T 11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/icons/icon small_size/icon small_size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsVipra/ProToots/a9e740014f13f91d06ff50de5ce83bbeec9dc471/src/icons/icon small_size/icon small_size.png -------------------------------------------------------------------------------- /src/icons/icon small_size/icon small_size.svg: -------------------------------------------------------------------------------- 1 | 2 | 46 | 47 | 48 | 49 | 50 | 51 | P 52 | / 53 | T 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/libs/caching.js: -------------------------------------------------------------------------------- 1 | import { debug, info, error } from "./logging.js"; 2 | import { runtime, storage } from "webextension-polyfill"; 3 | 4 | const currentVersion = runtime.getManifest().version; 5 | const cacheMaxAge = 24 * 60 * 60 * 1000; // time after which cached pronouns should be checked again: 24h 6 | 7 | /** 8 | * Appends an entry to the "pronounsCache" object in local storage. 9 | * 10 | * @param {string} account The account ID 11 | * @param {string} pronouns The pronouns to cache. 12 | */ 13 | export async function cachePronouns(account, pronouns) { 14 | let cache = {}; 15 | try { 16 | cache = await storage.local.get(); 17 | } catch { 18 | // Ignore errors and use an empty object as fallback. 19 | cache = { pronounsCache: {} }; 20 | } 21 | 22 | cache.pronounsCache[account] = { 23 | acct: account, 24 | timestamp: Date.now(), 25 | value: pronouns, 26 | version: currentVersion, 27 | }; 28 | try { 29 | await storage.local.set(cache); 30 | debug(`${account} cached`); 31 | } catch (e) { 32 | error(`${account} could not been cached: `, e); 33 | } 34 | } 35 | 36 | /** 37 | * 38 | * @param {string} accountName 39 | * @returns {Promise} Account's cached pronouns, or null if not saved or stale 40 | */ 41 | export async function getPronouns(accountName) { 42 | const fallback = { pronounsCache: {} }; 43 | let cacheResult; 44 | try { 45 | cacheResult = await storage.local.get(); 46 | if (!cacheResult.pronounsCache) { 47 | //if result doesn't have "pronounsCache" create it 48 | await storage.local.set(fallback); 49 | cacheResult = fallback; 50 | } 51 | } catch { 52 | cacheResult = fallback; 53 | // ignore errors, we have an empty object as fallback. 54 | } 55 | 56 | // Extract the current cache by using object destructuring. 57 | if (accountName in cacheResult.pronounsCache) { 58 | const { value, timestamp, version } = cacheResult.pronounsCache[accountName]; 59 | 60 | // If we have a cached value and it's not outdated, use it. 61 | if (value && Date.now() - timestamp < cacheMaxAge && version == currentVersion) { 62 | info(`${accountName} in cache with value: ${value}`); 63 | return value; 64 | } else { 65 | info(`${accountName} cache entry is stale, refreshing`); 66 | } 67 | } else { 68 | info(`${accountName} not in cache, fetching status`); 69 | } 70 | 71 | return null; 72 | } 73 | -------------------------------------------------------------------------------- /src/libs/domhelpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Recursively finds all descendants of a node. 3 | * @param {Node} node 4 | * @return {Node[]} Array containing the root node and all its descendants 5 | */ 6 | export function findAllDescendants(node) { 7 | return [node, ...node.childNodes, ...[...node.childNodes].flatMap((n) => findAllDescendants(n))]; 8 | } 9 | 10 | /** 11 | * Checks whether the given element has one of the passed classes. 12 | * 13 | * @param {HTMLElement} element The element to check. 14 | * @param {string[]} cl The class(es) to check for. 15 | * @returns Whether the classList contains the class. 16 | */ 17 | export function hasClasses(element, ...cl) { 18 | const classList = element.classList; 19 | if (!classList || !cl) return false; 20 | 21 | for (const c of classList) { 22 | for (const c2 of cl) { 23 | if (c === c2) return true; 24 | } 25 | } 26 | return false; 27 | } 28 | 29 | /** 30 | * Waits until the given selector appears below the given node. Then removes itself. 31 | * TODO: turn into single MutationObserver? 32 | * 33 | * @param {Element} node 34 | * @param {string} selector 35 | * @param {(el: Element) => void} callback 36 | * @copyright CC-BY-SA 4.0 wOxxoM https://stackoverflow.com/a/71488320 37 | */ 38 | export function waitForElement(node, selector, callback) { 39 | let el = node.querySelector(selector); 40 | if (el) { 41 | callback(el); 42 | return; 43 | } 44 | 45 | new MutationObserver((mutations, observer) => { 46 | el = node.querySelector(selector); 47 | if (el) { 48 | observer.disconnect(); 49 | callback(el); 50 | } 51 | }).observe(node, { subtree: true, childList: true }); 52 | } 53 | 54 | /** 55 | * Waits until the given selector appears below the given node. Then removes itself. 56 | * TODO: turn into single MutationObserver? 57 | * 58 | * @param {Element} node 59 | * @param {string} selector 60 | * @param {(el: Element) => void} callback 61 | * @copyright CC-BY-SA 4.0 wOxxoM https://stackoverflow.com/a/71488320 62 | */ 63 | export function waitForElementRemoved(node, selector, callback) { 64 | let el = node.querySelector(selector); 65 | if (!el) { 66 | callback(el); 67 | return; 68 | } 69 | 70 | new MutationObserver((mutations, observer) => { 71 | el = node.querySelector(selector); 72 | if (!el) { 73 | observer.disconnect(); 74 | callback(el); 75 | } 76 | }).observe(node, { subtree: true, childList: true }); 77 | } 78 | 79 | /** 80 | * Inserts a given new element as a sibling of the target 81 | * @param {HTMLElement} insertion Element to insert 82 | * @param {HTMLElement} target Element, which insertion is placed after 83 | */ 84 | export function insertAfter(insertion, target) { 85 | //docs: https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore#example_2 86 | target.parentElement.insertBefore(insertion, target.nextSibling); 87 | } 88 | 89 | /** 90 | * Turns HTML text into human-readable text 91 | * @param {string} input HTML Text 92 | * @returns {string} 93 | */ 94 | export function htmlDecode(input) { 95 | if (typeof window === "undefined" || !window.DOMParser) { 96 | const replacements = { 97 | "&": "&", 98 | """: '"', 99 | "<": "<", 100 | ">": ">", 101 | " ": "", 102 | }; 103 | for (const [html, text] of Object.entries(replacements)) input = input.replaceAll(html, text); 104 | 105 | return input; 106 | } 107 | 108 | const doc = new DOMParser().parseFromString(input, "text/html"); 109 | return doc.documentElement.textContent; 110 | } 111 | -------------------------------------------------------------------------------- /src/libs/fetchPronouns.js: -------------------------------------------------------------------------------- 1 | import { debug, error, info, log, warn } from "./logging"; 2 | import { cachePronouns, getPronouns } from "./caching"; 3 | import { normaliseAccountName } from "./protootshelpers"; 4 | import { extractFromStatus } from "./pronouns"; 5 | 6 | let conversationsCache; 7 | 8 | /** 9 | * Fetches pronouns associated with account name. 10 | * If cache misses object is fetched from the instance. 11 | * 12 | * @param {string | undefined} dataID ID of the object being requested, in case cache misses. 13 | * @param {string} accountName The account name, used for caching. Should have the "@" prefix. 14 | * @param {string} type Type of data-id 15 | * @returns {string} The pronouns if we have any, otherwise "null". 16 | */ 17 | export async function fetchPronouns(dataID, accountName, type) { 18 | // log(`searching for ${account_name}`); 19 | const cacheResult = await getPronouns(accountName); 20 | debug(cacheResult); 21 | if (cacheResult) return cacheResult; 22 | 23 | if (!dataID) { 24 | warn(`Could not fetch pronouns for user ${accountName}, because no status ID was passed.`); 25 | return null; 26 | } 27 | 28 | let status; 29 | if (type === "notification") { 30 | status = await fetchNotification(dataID); 31 | } else if (type === "account") { 32 | status = await fetchAccount(dataID); 33 | } else if (type === "conversation") { 34 | const conversations = await fetchConversations(); 35 | for (const conversation of conversations) { 36 | for (const account of conversation.accounts) { 37 | //conversations can have multiple participants, check that we're passing along the right account 38 | if (normaliseAccountName(account.acct) == accountName) { 39 | //package the account object in an empty object for compatibility with getPronounField() 40 | status = { account: account }; 41 | } 42 | } 43 | } 44 | } else { 45 | status = await fetchStatus(dataID); 46 | } 47 | 48 | if (!status) { 49 | log(`Fetching ${type} failed, trying notification instead.`); 50 | status = await fetchNotification(dataID); 51 | } //fallback for glitch-soc notifications 52 | 53 | let pronouns = await extractFromStatus(status); 54 | if (!pronouns) { 55 | pronouns = "null"; 56 | info(`no pronouns found for ${accountName}, cached null`); 57 | } 58 | await cachePronouns(accountName, pronouns); 59 | return pronouns; 60 | } 61 | 62 | /** 63 | * Fetches status by statusID from host_name with user's access token. 64 | * 65 | * @param {string} statusID ID of status being requested. 66 | * @returns {Promise} Contents of the status in json form. 67 | */ 68 | async function fetchStatus(statusID) { 69 | const accessToken = await getActiveAccessToken(); 70 | //fetch status from home server with access token 71 | const response = await fetch( 72 | `${location.protocol}//${location.host}/api/v1/statuses/${statusID}`, 73 | { 74 | headers: { Authorization: `Bearer ${accessToken}` }, 75 | }, 76 | ); 77 | 78 | if (!response.ok) return null; 79 | 80 | let status = await response.json(); 81 | 82 | //if status contains a reblog get that for further processing - we want the embedded post's author 83 | if (status.reblog) status = status.reblog; 84 | return status; 85 | } 86 | 87 | /** 88 | * Fetches notification by notificationID from host_name with user's access token. 89 | * 90 | * @param {string} notificationID ID of notification being requested. 91 | * @returns {Promise} Contents of notification in json form. 92 | */ 93 | async function fetchNotification(notificationID) { 94 | const accessToken = await getActiveAccessToken(); 95 | 96 | const response = await fetch( 97 | `${location.protocol}//${location.host}/api/v1/notifications/${notificationID}`, 98 | { 99 | headers: { Authorization: `Bearer ${accessToken}` }, 100 | }, 101 | ); 102 | 103 | const notification = await response.json(); 104 | 105 | return notification; 106 | } 107 | 108 | /** 109 | * Fetches account by accountID from host_name with user's access token. 110 | * 111 | * @param {string} accountID ID of account being requested. 112 | * @returns {Promise} Contents of account in json form. 113 | */ 114 | async function fetchAccount(accountID) { 115 | const accessToken = await getActiveAccessToken(); 116 | 117 | const response = await fetch( 118 | `${location.protocol}//${location.host}/api/v1/accounts/${accountID}`, 119 | { 120 | headers: { Authorization: `Bearer ${accessToken}` }, 121 | }, 122 | ); 123 | 124 | if (!response.ok) return null; 125 | 126 | const account = await response.json(); 127 | 128 | return { account: account }; 129 | } 130 | 131 | /** 132 | * Fetches the user's last <=40 direct message threads from host_name with user's access token. 133 | * @returns {Promise} Array containing direct message thread objects in json from. 134 | * 135 | * DOCS: https://docs.joinmastodon.org/methods/conversations/#response 136 | */ 137 | async function fetchConversations() { 138 | if (conversationsCache) return conversationsCache; 139 | //the api wants status IDs, not conversation IDs 140 | //as a result we can only get pronouns for the first 40 conversations max 141 | //most of these should be in cache anyways 142 | const accessToken = await getActiveAccessToken(); 143 | 144 | const response = await fetch( 145 | `${location.protocol}//${location.host}/api/v1/conversations?limit=40`, 146 | { 147 | headers: { Authorization: `Bearer ${accessToken}` }, 148 | }, 149 | ); 150 | 151 | const conversations = await response.json(); 152 | conversationsCache = conversations; 153 | 154 | return conversations; 155 | } 156 | 157 | /** 158 | * Fetches the current access token for the user. 159 | * @returns {Promise} The accessToken for the current user if we are logged in. 160 | */ 161 | async function getActiveAccessToken() { 162 | // Fortunately, Mastodon provides the initial state in a 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { storage } from "webextension-polyfill"; 3 | import { error } from "../libs/logging"; 4 | 5 | function saveOptions(e) { 6 | e.preventDefault(); 7 | storage.sync.set({ 8 | logging: document.querySelector("#logging").checked, 9 | statusVisibility: document.querySelector("#status").checked, 10 | notificationVisibility: document.querySelector("#notification").checked, 11 | accountVisibility: document.querySelector("#account").checked, 12 | conversationVisibility: document.querySelector("#conversation").checked, 13 | }); 14 | } 15 | 16 | function restoreOptions() { 17 | async function setCurrentChoice(result) { 18 | if (!result.statusVisibility) { 19 | await defaultOptions(); 20 | } else { 21 | document.querySelector("#logging").checked = result.logging || false; 22 | document.querySelector("#status").checked = result.statusVisibility || false; 23 | document.querySelector("#notification").checked = result.notificationVisibility || false; 24 | document.querySelector("#account").checked = result.accountVisibility || false; 25 | document.querySelector("#conversation").checked = result.conversationVisibility || false; 26 | } 27 | } 28 | 29 | function onError(err) { 30 | error(`Error: ${err}`); 31 | } 32 | 33 | const getting = storage.sync.get(); 34 | getting.then(setCurrentChoice, onError); 35 | } 36 | 37 | async function defaultOptions() { 38 | await storage.sync.set({ 39 | logging: false, 40 | statusVisibility: true, 41 | notificationVisibility: true, 42 | accountVisibility: true, 43 | conversationVisibility: false, 44 | }); 45 | restoreOptions(); 46 | } 47 | 48 | document.addEventListener("DOMContentLoaded", restoreOptions); 49 | document.querySelector("form").addEventListener("submit", saveOptions); 50 | document.querySelector("#resetbutton").addEventListener("click", async () => { 51 | await storage.local.clear(); 52 | }); 53 | document.querySelector("#defaultSettings").addEventListener("click", async () => { 54 | await defaultOptions(); 55 | }); 56 | -------------------------------------------------------------------------------- /src/styles/proplate.css: -------------------------------------------------------------------------------- 1 | .protoots-proplate { 2 | padding-left: 0.25rem; 3 | padding-right: 0.25rem; 4 | border-radius: 0.25rem; 5 | margin-left: 0.5rem; 6 | display: inline-flex; 7 | animation: proplate-fadein 0.15s linear; 8 | font-weight: 500; 9 | 10 | /* 11 | For users of the advanced view, the proplate shouldn't take all available space. 12 | Most of the times this was working fine, by setting it explicitly it definitely works 13 | across all places of Mastodon. 14 | */ 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | } 18 | 19 | /* dark theme */ 20 | .theme-default .protoots-proplate { 21 | /* header background */ 22 | background-color: #313543; 23 | 24 | /* pulled from nowhere, basically just as low contrast as we can go while keeping AA rating */ 25 | color: #aaa; 26 | } 27 | 28 | /* dark theme on detailed status */ 29 | .theme-default .detailed-status .protoots-proplate { 30 | /* scrollable background */ 31 | background-color: #282c37; 32 | 33 | /* pulled from nowhere, basically just as low contrast as we can go while keeping AA rating */ 34 | color: #aaa; 35 | } 36 | 37 | /* light theme */ 38 | .theme-mastodon-light .protoots-proplate { 39 | /* body background */ 40 | background-color: #eff3f5; 41 | /* search bar foreground */ 42 | color: #282c37; 43 | } 44 | 45 | /* high contrast */ 46 | .theme-contrast .protoots-proplate { 47 | background-color: black; 48 | color: #fff; 49 | } 50 | 51 | .theme-macaron .protoots-proplate { 52 | background-color: #c8c4dd; 53 | } 54 | 55 | .theme-fairy-floss .protoots-proplate { 56 | background-color: #474059; 57 | } 58 | 59 | .flavour-glitch.skin-default .protoots-proplate { 60 | background-color: #313543; 61 | } 62 | 63 | /* rainbow pronouns for jasmin and i cause why not */ 64 | .proplate-pog:hover { 65 | color: inherit; 66 | 67 | /* 68 | The 12-bit rainbow palette by https://fosstodon.org/@kate, adjusted to rgba value with funny opacity. 69 | https://iamkate.com/data/12-bit-rainbow 70 | */ 71 | background: linear-gradient( 72 | 45deg, 73 | rgba(136, 17, 119, 0.69) 0%, 74 | rgba(170, 51, 85, 0.69) 10%, 75 | rgba(204, 102, 102, 0.69) 20%, 76 | rgba(238, 153, 68, 0.69) 30%, 77 | rgba(238, 221, 0, 0.69) 40%, 78 | rgba(153, 221, 85, 0.69) 50%, 79 | rgba(68, 221, 136, 0.69) 60%, 80 | rgba(34, 204, 187, 0.69) 70%, 81 | rgba(0, 187, 204, 0.69) 80%, 82 | rgba(0, 153, 204, 0.69) 90%, 83 | rgba(51, 102, 187, 0.69) 100% 84 | ); 85 | } 86 | 87 | @keyframes proplate-fadein { 88 | from { 89 | opacity: 0; 90 | } 91 | to { 92 | opacity: 1; 93 | } 94 | } 95 | 96 | @media (prefers-reduced-motion) { 97 | .protoots-proplate { 98 | animation: none; 99 | } 100 | } 101 | 102 | bdi.has-proplate { 103 | display: flex; 104 | } 105 | -------------------------------------------------------------------------------- /tests/extractPronouns.spec.js: -------------------------------------------------------------------------------- 1 | import { suite } from "uvu"; 2 | import * as assert from "uvu/assert"; 3 | import * as pronouns from "../src/libs/pronouns.js"; 4 | 5 | const extract = suite("field extraction"); 6 | const validFields = [ 7 | "pronoun", 8 | "pronouns", 9 | "PRONOUNS", 10 | "professional nouns", 11 | "pronomen", 12 | "Pronouns / Pronomen", 13 | "Pronomen (DE)", 14 | "Pronouns (EN)", 15 | "i go by", 16 | "go by", 17 | ]; 18 | const invalidFields = ["pronounciation", "pronomenverwaltung"]; 19 | 20 | for (const field of validFields) { 21 | extract(`${field} is extracted`, async () => { 22 | const result = await pronouns.extractFromStatus({ 23 | account: { 24 | fields: [{ name: field, value: "pro/nouns" }], 25 | }, 26 | }); 27 | assert.equal("pro/nouns", result); 28 | }); 29 | } 30 | 31 | for (const field of invalidFields) { 32 | extract(`${field} is not extracted`, async () => { 33 | const result = await pronouns.extractFromStatus({ 34 | account: { 35 | fields: [{ name: field, value: "pro/nouns" }], 36 | }, 37 | }); 38 | assert.equal(result, null); 39 | }); 40 | } 41 | 42 | extract.run(); 43 | 44 | const valueExtractionSuite = suite("value extraction"); 45 | valueExtractionSuite.before(() => { 46 | global.window = { 47 | // @ts-ignore 48 | navigator: { 49 | languages: ["en"], 50 | }, 51 | }; 52 | global.document = { 53 | // @ts-ignore 54 | documentElement: { 55 | lang: "de", 56 | }, 57 | }; 58 | }); 59 | valueExtractionSuite.after(() => { 60 | global.window = undefined; 61 | global.document = undefined; 62 | }); 63 | const valueExtractionTests = [ 64 | ["she/her", "she/her"], // exact match 65 | ["they and them", "they and them"], // exact match with multiple words 66 | ["they/them (https://pronouns.page/they/them)", "they/them"], // plain-text "URL" with additional text 67 | ["https://en.pronouns.page/they/them", "they/them"], // plain-text "URLs" 68 | ["pronouns.page/they/them", "they/them"], // plain-text "URLs" without scheme 69 | [``, "they/them"], // HTML-formatted URLs 70 | [``, "she/her"], // pronoun pages with usernames 71 | [ 72 | ``, 73 | null, 74 | ], // 404 errors 75 | [``, "Katze"], // custom pronouns 76 | [``, "Katze/Katze's"], // custom pronouns in profile 77 | [`:theythem:`, null], // emojis shortcodes used for pronouns 78 | [ 79 | // This is an actual example from a Mastodon field, with example.com redirecting to pronouns.page. 80 | `dey/denen, es/ihm - example.com`, 81 | "dey/denen, es/ihm", 82 | ], 83 | ["https://en.pronouns.page/it", "it/its"], // single-word pronoun pages 84 | ]; 85 | for (const [input, expects] of valueExtractionTests) { 86 | valueExtractionSuite(input, async () => { 87 | const result = await pronouns.extractFromStatus({ 88 | account: { 89 | fields: [{ name: "pronouns", value: input }], 90 | }, 91 | }); 92 | assert.equal(result, expects); 93 | }); 94 | } 95 | 96 | valueExtractionSuite.run(); 97 | 98 | const bioExtractSuite = suite("bio extraction"); 99 | const bioExtractTests = [ 100 | ["I'm cute and my pronouns are she/her", "she/her"], // exact match 101 | ["my pronouns are helicopter/joke", null], // not on allowlist 102 | ["pronouns: uwu/owo", "uwu/owo"], // followed by pronoun pattern 103 | ["pronouns: any", "any"], // followed by pronoun pattern 104 | ["I'm cute af (she / they)", "she/they"], // with whitespace between pronouns 105 | ["pronouns: any/all", "any/all"], // any pronouns 106 | ["any pronouns", "any pronouns"], // any pronouns 107 | ["He/Him", "He/Him"], //capitalised pronouns 108 | ]; 109 | for (const [input, expects] of bioExtractTests) { 110 | bioExtractSuite(input, async () => { 111 | const result = await pronouns.extractFromStatus({ 112 | account: { note: input }, 113 | }); 114 | assert.equal(result, expects); 115 | }); 116 | } 117 | 118 | bioExtractSuite.run(); 119 | --------------------------------------------------------------------------------