initial commit

This commit is contained in:
Vipra 2023-05-24 18:47:04 +02:00
commit 1e5e338c08
16 changed files with 6396 additions and 0 deletions

152
.gitignore vendored Normal file
View file

@ -0,0 +1,152 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
# web-ext-artifacts
web-ext-artifacts
# note files
TODO
edgecases
notes

1
.node-version Normal file
View file

@ -0,0 +1 @@
v18.16.0

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"endOfLine": "lf",
"printWidth": 100,
"useTabs": true,
"trailingComma": "all"
}

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}

23
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,23 @@
{
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.trimTrailingWhitespace": true,
"files.trimFinalNewlines": true,
"files.insertFinalNewline": true
}

20
README.md Normal file
View file

@ -0,0 +1,20 @@
# ProToots (v0.99)
A Firefox extension which displays an author's pronouns next to their name on Mastodon.
![A Mastodon screenshot showing off pronouns next to a person's name](documentation\firefox_ehHwJufMau.png)
## Download
uuuuhhh i dunno, i've probably sent it to you
## FAQ
Why does it need permission for all websites?
> 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.
## setup
- install web-ext with `npm install --global web-ext`
- optionally:
- run `web-ext run --firefox-profile='$ProfileNameOfYourChoosing' --profile-create-if-mising`
- open that profile in firefox, log into fedi
- after that when you run `web-ext run -p='$ProfileNameOfYourChoosing'` you should be logged into your fedi account
- run the extension with `web-ext run -u -u="yourinstancehere"`

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

42
manifest.json Normal file
View file

@ -0,0 +1,42 @@
{
"manifest_version": 2,
"name": "ProToots",
"version": "0.99",
"description": "puts pronouns next to usernames on mastodon",
"homepage_url": "https://github.com/ItsVipra/ProToots",
"permissions": [
"storage"
],
"browser_action": {
"default_icon": "src/icons/beasts-32.png",
"default_title": "ProToots",
"default_popup": "src/options/options.html"
},
"content_scripts": [
{
"matches": [
"*://*/*"
],
"js" : [
"src/content_scripts/protoots.js"
],
"css": ["src/styles/proplate.css"],
"run_at": "document_start"
}
],
"options_ui": {
"page": "src/options/options.html"
},
"browser_specific_settings": {
"gecko": {
"id": "protoots@mastodon.com"
}
}
}

5555
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

6
package.json Normal file
View file

@ -0,0 +1,6 @@
{
"devDependencies": {
"prettier": "^2.8.8",
"web-ext": "^7.6.2"
}
}

View file

@ -0,0 +1,403 @@
// @ts-check
//const proplate = document.createElement("bdi");
//proplate.textContent = "pro/nouns";
//document.body.display-name__html.append();
// const max_age = 8.64e7
const max_age = 24*60*60*1000 //time after which cached pronouns should be checked again: 24h
const host_name = location.hostname
//before anything else, check whether we're on a Mastodon page
checkSite()
let logging;
function error() {
if (logging) console.error(arguments);
}
function warn() {
if (logging) console.warn(arguments);
}
function log() {
if (logging) console.log(arguments);
}
function info() {
if (logging) console.info(arguments);
}
function debug() {
if (logging) console.debug(arguments);
}
// log("hey vippy, du bist cute <3")
/**
* Checks whether site responds to Mastodon API Calls.
* If so creates an 'readystatechange' EventListener, with callback to main()
*/
async function checkSite() {
await browser.storage.sync.get("logging").then((res) => {logging = res['logging']}, () => {logging = true});
let requestDest = location.protocol + '//' + host_name + '/api/v1/instance'
let response = await fetch(requestDest)
if (response) {
// debug('checksite response got', {'response' : response.json()})
document.addEventListener('readystatechange', main)
}
else {
warn('Not a Mastodon instance')
}
}
/**
* Evaluates the result of document.querySelector("#mastodon") and only creates a MutationObserver if the site is Mastodon.
* Warns that site is not Mastodon otherwise.
* - This prevents any additional code from being run.
*
*/
function main() {
// debug('selection for id mastodon', {'result': document.querySelector("#mastodon")})
if (document.querySelector("#mastodon")) {
log('Mastodon instance, activating Protoots')
let lastUrl = location.href
new MutationObserver((mutations) => {
const url = location.href
if (url !== lastUrl) {
lastUrl = url
onUrlChange()
}
for (const m of mutations) {
m.addedNodes.forEach(n => {
if (!(n instanceof HTMLElement))
return
if (n.className == "column") {
debug("found a column: ", n)
createObserver(n)
//TODO: yet another bad hack, pls fix
//TODO: doesn't work when going from detailed-status to detailed-status
document.querySelectorAll(".detailed-status").forEach(el => addProplate(el))
}
})
}
}).observe(document, { subtree: true, childList: true })}
else {
warn('Not a Mastodon instance')
}
}
/**
* Fetches pronouns associated with account name.
* If cache misses status is fetched from the instance.
*
* @param {string | undefined} statusID ID of the status being requested, in case cache misses.
* @param {string} account_name The account name, used for caching. Should have the "@" prefix.
*/
async function fetchPronouns(statusID, account_name) {
// log(`searching for ${account_name}`);
let cacheResult = await browser.storage.local.get().then(getSuccess, onError);
if (account_name[0] == '@') account_name = account_name.substring(1);
// if the username doesn't contain an @ (i.e. the post we're looking at is from this instance)
// append the host name to it, to avoid cache overlap between instances
if (!account_name.includes("@")) {
account_name = account_name + "@" + host_name
}
// debug(cacheResult);
if (Object.keys(cacheResult).length == 0) {
let pronounsCache = {}
await browser.storage.local.set({pronounsCache}).then(setSuccess, onError);
warn('created pronounsCache in storage');
}
let cacheKeys = Object.keys(cacheResult["pronounsCache"])
if (cacheKeys.includes(account_name)) {
let entryValue = cacheResult["pronounsCache"][account_name].value
let entryTimestamp = cacheResult["pronounsCache"][account_name].timestamp
if ((Date.now() - entryTimestamp) < max_age) {
info(`${account_name} in cache:`, {'cache entry': cacheResult["pronounsCache"][account_name]} )
return entryValue
}
else {
info(`${account_name} entry is stale, refreshing`);
}
}
if (!statusID) {
console.warn(`Could not fetch pronouns for user ${account_name}, because no status ID was passed. This is an issue we're working on.`)
return;
}
info(`${account_name} not in cache, fetching status`)
let status = await fetchStatus(statusID);
let PronounField = getPronounField(status, account_name);
if (PronounField == 'null') {
//TODO: if no field check bio
info(`no pronouns found for ${account_name}, cached null`);
}
return PronounField
}
/**
* Fetches status by statusID from host_name with user's access token.
*
* @param {string} statusID ID of status being requested.
* @returns {Promise<object>} Contents of the status in json form.
*/
async function fetchStatus(statusID) {
const accessToken = await getActiveAccessToken();
//fetch status from home server with access token
const response = await fetch(`https://${host_name}/api/v1/statuses/${statusID}`, { headers: { 'Authorization': `Bearer ${accessToken}` } });
let status = await response.json();
return status;
}
/**
* Searches for fields labelled "pronouns" in the statuses' author.
* If found returns the value of said field.
*
* @param {string} status
* @param {string} account_name
* @returns {string} Author pronouns if found. Otherwise returns "null"
*/
function getPronounField(status, account_name) {
debug(status)
// get account from status and pull out fields
let account = status["account"];
let fields = account["fields"];
for (let field of fields) {
//match fields against "pronouns"
//TODO: multiple languages
if (field['name'].toLowerCase().includes("pronouns")) {
debug(`${account['acct']}: ${field['value']}`);
if (!(field['value'].includes("a href"))) { //filter links
let pronounSet = generatePronounSet(account_name, field['value'])
cachePronouns(account_name, pronounSet)
return(field['value']);
}
}
}
//if not returned by this point no field with pronouns was found
let pronounSet = generatePronounSet(account_name, 'null')
cachePronouns(account_name, pronounSet)
return "null"
}
/**
* Generates object with pronoun related data to be saved to storage.
*
* @param {string} account Full account name as generated in fetchPronouns()
* @param {string} value Contents of the found field's value
* @returns {object} Object containing account name, timestamp and pronouns
*/
function generatePronounSet(account, value) {
return { 'acct': account, 'timestamp': Date.now(), 'value': value }; //TODO: this should be just account right
}
/**
* Appends an entry to the "pronounsCache" object in local storage.
*
* @param {string} account The account ID
* @param {{ acct: any; timestamp: number; value: any; }} set The data to cache.
*/
async function cachePronouns(account, set) {
let result = await browser.storage.local.get("pronounsCache").then(getSuccess, onError);
let pronounsCache = result['pronounsCache']
pronounsCache[account] = set
await browser.storage.local.set({pronounsCache}).then(setSuccess, onError);
debug(`caching ${account}`)
// return
}
function getSuccess(result) {
return result;
}
function setSuccess() {
}
function onError(error) {
error('Failed save to storage!');
}
/**
* Adds the pro-plate to the element. The caller needs to ensure that the passed element
* is defined and that it's either a:
* - <article> with the "status" class or
* - <article> with the "detailed-status" class.
*
* Although it's possible to pass raw {@type Element}s, the method only does things on elements of type {@type HTMLElement}.
*
* @param {Element | HTMLElement} element The status where the element should be added.
*/
async function addProplate (element) {
if (!(element instanceof HTMLElement)) return;
//check whether element has already had a proplate added
if (element.hasAttribute("protoots-checked")) return
//if not add the attribute
element.setAttribute("protoots-checked", "true")
let statusId = element.dataset.id
if (!statusId) {
// We don't have a status ID, pronouns might not be in cache
warn("The element passed to addProplate does not have a data-id attribute, although it should have one.", element);
}
let accountNameEl = element.querySelector(".display-name__account");
if (!accountNameEl) {
warn(
"The element passed to addProplate does not have a .display-name__account, although it should have one.",
element,
);
return;
}
let accountName = accountNameEl.textContent
if (!accountName){
warn("Could not extract the account name from the element.")
return;
}
//get the name element and apply CSS
let nametagEl =/** @type {HTMLElement|null} */ (element.querySelector(".display-name__html"));
if (!nametagEl) {
warn(
"The element passed to addProplate does not have a .display-name__html, although it should have one.",
element,
);
return;
}
nametagEl.style.display = "flex";
nametagEl.style.alignItems = "baseline";
//create plate
const proplate = document.createElement("span");
let pronouns = await fetchPronouns(statusId, accountName)
if (pronouns == "null" && !logging) {return}
proplate.textContent = pronouns;
proplate.classList.add("protoots-proplate");
if ((host_name == "queer.group" && (accountName == "@vivien" || accountName == "@jasmin")) || accountName == "@jasmin@queer.group" || accountName == "@vivien@queer.group") {
//i think you can figure out what this does on your own
proplate.classList.add("pog")
}
//add plate to nametag
nametagEl.appendChild(proplate);
}
function createObserver(element) {
// select column as observation target
// const targetNode = document.querySelector(".column");
const targetNode = element
// observe childlist and subtree events
// docs: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
const config = { childList: true, subtree: true, attributes: true };
// define callback inline
const callback = (/** @type {MutationRecord[]} */ mutationList, /** @type {MutationObserver} */ observer) => {
for (const mutation of mutationList) {
mutation.addedNodes.forEach((n) => {
if (!(n instanceof HTMLElement)) return;
//case for all the other normal statuses
if (containsClass(n.classList, "status") && !containsClass(n.classList, "status__prepend")) { //|| containsClass(n.classList, "detailed-status"))) {
addProplate(n);
}
else {
//for nodes that have a broken classlist
let statusElement = n.querySelector(".status")
if (statusElement) {
addProplate(statusElement);
}
//potential solution for dirty hack?
// debug(".status not found looking for .detailed-status", {"element:": n})
// statusElement = n.querySelector(".detailed-status")
// if (statusElement != null) {
// addProplate(statusElement);
// }
}
})
}
//TODO: bad hack, please remove
document.querySelectorAll(".status").forEach(el => addProplate(el));
document.querySelectorAll(".detailed-status").forEach(el => addProplate(el));
};
// Create an observer instance linked to the callback function
const observer = new MutationObserver(callback);
// Start observing the target node for configured mutations
observer.observe(targetNode, config);
}
/**
* Called by MutationObserver when the url changes.
* Creates a new MutationObserver for each column on the page.
*/
function onUrlChange() {
//select all columns for advanced interface
document.querySelectorAll(".column").forEach(el => {createObserver(el)});
// createObserver();
}
/**
* @param {DOMTokenList} classList The class list.
* @param {string} cl The class to check for.
* @returns Whether the classList contains the class.
*/
function containsClass(classList, cl) {
if (!classList|| !cl) return false;
for (const c of classList) {
if (c === cl) {
return true
}
}
return false
}
/**
* Fetches the current access token for the user.
* @returns {Promise<string>} The accessToken for the current user if we are logged in.
*/
async function getActiveAccessToken() {
// Fortunately, Mastodon provides the initial state in a <script> element at the beginning of the page.
// Besides a lot of other information, it contains the access token for the current user.
const initialStateEl = document.getElementById("initial-state")
if (!initialStateEl) {
error("user not logged in yet")
return "";
}
// Parse the JSON inside the script tag and extract the meta element from it.
const { meta } = JSON.parse(initialStateEl.innerText)
return meta.access_token;
}

BIN
src/icons/beasts-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

74
src/options/options.css Normal file
View file

@ -0,0 +1,74 @@
body {
margin: 0;
--background: hsl(249, 11%, 90%);
--hover: hsl(249, 11%, 75%);
--border: hsl(0, 0%, 50%);
--link: hsl(0, 0%, 25%);
}
.protoots-settings{
margin-bottom: 0;
}
.row {
background-color: var(--background);
margin: 0.5em;
border-radius: 0.5em;
display: flex;
justify-content: space-between;
}
.switch{
display: block;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
padding: 0.25em;
padding-left: 0.75em;
margin-top: 0.25em;
}
input{
margin: 0.75em;
}
.description {
/* up right down left */
padding: 0 0 0 0.75em;
margin: 0 0 0.5em 0;
}
.footer {
background-color: var(--background);
padding: 1em;
border-top: 1px dashed var(--border);
display: flex;
align-items: baseline;
justify-content: space-between;
}
button {
padding: 0.75em 1em;
border-radius: 0.5em;
border: 1px solid var(--border);
cursor: pointer;
}
button:hover {
background-color: var(--hover);
}
a {
color: var(--link);
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1c1b22;
color: hsl(0, 0%, 90%);
--background: hsl(249, 11%, 25%);
--hover: hsl(249, 11%, 75%);
--border: hsl(0, 0%, 90%);
--link: hsl(0, 0%, 75%);
}
}

25
src/options/options.html Normal file
View file

@ -0,0 +1,25 @@
<html>
<head>
<link rel="stylesheet" href="options.css">
</head>
<body>
<form class="protoots-settings">
<div class="row">
<div class="text">
<label for="logging" class="switch">Enable logging</label>
<p id="logging-description" class="description">Log debug information to browser console</p>
</div>
<input type="checkbox" id="logging" name="logging" aria-describedby="logging-description">
</div>
<div class="footer">
<button type="submit">Save</button>
<a target="_blank" href="https://github.com/ItsVipra/ProToots">More info / help on Github</a>
</div>
</form>
<script src="options.js"></script>
</body>
</html>

22
src/options/options.js Normal file
View file

@ -0,0 +1,22 @@
function saveOptions(e) {
e.preventDefault();
browser.storage.sync.set({
logging: document.querySelector("#logging").checked
});
}
function restoreOptions() {
function setCurrentChoice(result) {
document.querySelector("#logging").checked = result.logging || off;
}
function onError(error) {
console.log(`Error: ${error}`);
}
let getting = browser.storage.sync.get("logging");
getting.then(setCurrentChoice, onError);
}
document.addEventListener("DOMContentLoaded", restoreOptions);
document.querySelector("form").addEventListener("submit", saveOptions);

64
src/styles/proplate.css Normal file
View file

@ -0,0 +1,64 @@
.protoots-proplate {
padding-left: 0.25rem;
padding-right: 0.25rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
display: inline-flex;
text-transform: lowercase;
}
/* dark theme */
.theme-default .protoots-proplate {
/* header background */
background-color: #313543;
/* pulled from nowhere, basically just as low contrast as we can go while keeping AA rating */
color: #aaa;
}
/* dark theme on detailed status */
.theme-default .detailed-status .protoots-proplate {
/* scrollable background */
background-color: #282c37;
/* pulled from nowhere, basically just as low contrast as we can go while keeping AA rating */
color: #aaa;
}
/* light theme */
.theme-mastodon-light .protoots-proplate {
/* body background */
background-color: #eff3f5;
/* search bar foreground */
color: #282c37;
}
/* high contrast */
.theme-contrast .protoots-proplate {
background-color: black;
color: #fff;
}
.theme-macaron .protoots-proplate {
background-color: #c8c4dd;
}
.theme-fairy-floss .protoots-proplate {
background-color: #474059;
}
/* rainbow pronouns for jasmin and i cause why not */
.pog:hover {
background-image: linear-gradient(45deg,
rgba(255, 0, 0, 1) 0%,
rgba(255, 154, 0, 1) 10%,
rgba(208, 222, 33, 1) 20%,
rgba(79, 220, 74, 1) 30%,
rgba(63, 218, 216, 1) 40%,
rgba(47, 201, 226, 1) 50%,
rgba(28, 127, 238, 1) 60%,
rgba(95, 21, 242, 1) 70%,
rgba(186, 12, 248, 1) 80%,
rgba(251, 7, 217, 1) 90%,
rgba(255, 0, 0, 1) 100%);
}