Instantly share code, notes, and snippets.
Created
December 11, 2025 10:55
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save gsurrel/716d7dd90745424194607afc05bb6b06 to your computer and use it in GitHub Desktop.
Adds per‑toot up/down author buttons that record and update an author’s score in their Personal notes on your local Mastodon instance.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| TootAuthorUpDownVote | |
| What it does: | |
| - Adds a small two-button control to each toot to record an upvote or downvote for the toot's author. | |
| - Stores the result in the author's "Personal notes" on the local instance. | |
| - The note line is a single line: "Upvote/Downvote: up/down (up/(up+down))". | |
| How it works: | |
| - Discovers the web client's bearer token by scanning inline scripts on the page. | |
| - Resolves the author to a numeric account id using the local instance search. | |
| - Reads the existing personal note using the relationships endpoint, merges the Upvote/Downvote line, and posts the merged note back. | |
| Diagnostics: | |
| - Exposes window.__ud_pertoot with helpers: | |
| - getBearer() returns the captured token (if any). | |
| - waitForBearer() returns a promise that resolves when a token is available. | |
| - scanScriptsForToken() re-runs the inline-script scan. | |
| Privacy: | |
| - Notes are written to the instance that hosts the author account (local instance lookup only). | |
| - Requires you to be logged into the local instance in the browser. | |
| Limitations: | |
| - The web interface may display outdated up/down vote status on a user page. | |
| Installation: | |
| - Paste the script into a bookmarklet maker (eg. https://caiorss.github.io/bookmarklet-maker/). | |
| */ | |
| (function(){ | |
| if(window.__ud_pertoot_installed) return; | |
| window.__ud_pertoot_installed = true; | |
| let BEARER = null; | |
| const tokenListeners = []; | |
| function setBearer(token){ | |
| if(!token) return; | |
| if(BEARER === token) return; | |
| BEARER = token; | |
| while(tokenListeners.length) { | |
| try { tokenListeners.shift()(BEARER); } catch(e) {} | |
| } | |
| } | |
| function whenBearer(timeout = 15000){ | |
| if(BEARER) return Promise.resolve(BEARER); | |
| return new Promise((resolve, reject) => { | |
| tokenListeners.push(resolve); | |
| setTimeout(()=>reject(new Error("timeout waiting for bearer token")), timeout); | |
| }); | |
| } | |
| function scanScriptsForToken(){ | |
| try { | |
| const scripts = document.querySelectorAll('script'); | |
| for(const s of scripts){ | |
| try { | |
| const txt = s.textContent || ''; | |
| const m = txt.match(/["'](?:Bearer\s+)?([A-Za-z0-9\-\._~\+\/]+=*)["']/g); | |
| if(!m) continue; | |
| for(const cand of m){ | |
| const cleaned = cand.replace(/^['"]|['"]$/g,'').replace(/^Bearer\s+/i,''); | |
| if(cleaned.length > 20 && /^[A-Za-z0-9\-\._~\+\/]+=*$/.test(cleaned)){ | |
| setBearer(cleaned); | |
| return true; | |
| } | |
| } | |
| } catch(e){} | |
| } | |
| } catch(e){} | |
| return false; | |
| } | |
| scanScriptsForToken(); | |
| function buildFetchOptions(method='GET', body=null){ | |
| if(!BEARER) throw new Error('No bearer token available'); | |
| const headers = { 'Accept':'application/json', 'Authorization':'Bearer ' + BEARER }; | |
| if(body !== null) headers['Content-Type'] = 'application/json'; | |
| return { method, headers, credentials: 'omit', referrerPolicy: 'unsafe-url', body: body !== null ? JSON.stringify(body) : undefined }; | |
| } | |
| async function acctToIdLocal(acct){ | |
| if(!acct.includes('@')) acct = acct + '@' + location.host; | |
| const localOrigin = location.origin; | |
| const q = encodeURIComponent(acct); | |
| const url = `${localOrigin}/api/v2/search?q=${q}&resolve=true&type=accounts&limit=1`; | |
| const res = await fetch(url, buildFetchOptions('GET', null)); | |
| if(!res.ok) throw new Error('local search failed ' + res.status); | |
| const j = await res.json(); | |
| if(j && Array.isArray(j.accounts) && j.accounts.length && j.accounts[0].id){ | |
| return { id: j.accounts[0].id, origin: localOrigin }; | |
| } | |
| throw new Error('Could not resolve account id locally for ' + acct); | |
| } | |
| async function getCurrentUserId(origin){ | |
| const url = `${origin}/api/v1/accounts/verify_credentials`; | |
| const res = await fetch(url, buildFetchOptions('GET', null)); | |
| if(!res.ok) throw new Error('verify_credentials failed ' + res.status); | |
| const j = await res.json(); | |
| if(!j || !j.id) throw new Error('no current user id'); | |
| return j.id; | |
| } | |
| async function getExistingPersonalNote(origin, accountId){ | |
| try { | |
| const currentUserId = await getCurrentUserId(origin); | |
| const relUrl = `${origin}/api/v1/accounts/relationships?id[]=${encodeURIComponent(accountId)}&id[]=${encodeURIComponent(currentUserId)}`; | |
| const relRes = await fetch(relUrl, buildFetchOptions('GET', null)); | |
| if(relRes.ok){ | |
| const relJson = await relRes.json(); | |
| if(Array.isArray(relJson) && relJson.length){ | |
| for(const obj of relJson){ | |
| if(String(obj.id) === String(accountId) || String(obj.account_id) === String(accountId)){ | |
| if(typeof obj.note === 'string' && obj.note.trim() !== '') return obj.note; | |
| if(typeof obj.personal_note === 'string' && obj.personal_note.trim() !== '') return obj.personal_note; | |
| if(obj.relationship && typeof obj.relationship.note === 'string' && obj.relationship.note.trim() !== '') return obj.relationship.note; | |
| } | |
| } | |
| } | |
| } | |
| } catch(e){} | |
| return null; | |
| } | |
| async function postPersonalNote(origin, accountId, mergedComment){ | |
| const url = `${origin}/api/v1/accounts/${accountId}/note`; | |
| const res = await fetch(url, buildFetchOptions('POST', { comment: mergedComment })); | |
| if(!res.ok){ | |
| const txt = await res.text().catch(()=>''); | |
| throw new Error('POST failed ' + res.status + ' ' + txt); | |
| } | |
| return res.json().catch(()=>null); | |
| } | |
| function findToots(root=document){ return Array.from(root.querySelectorAll('article[role="article"], [data-testid="status"], .status, .h-entry')); } | |
| function extractAcctAndHostFromToot(toot){ | |
| const dataAcct = toot.querySelector('[data-account-acct]')?.getAttribute('data-account-acct') || | |
| toot.getAttribute('data-account-acct') || | |
| toot.querySelector('[data-acct]')?.getAttribute('data-acct'); | |
| if(dataAcct){ | |
| if(dataAcct.includes('@')) return { acct: dataAcct, host: dataAcct.split('@')[1] }; | |
| return { acct: dataAcct + '@' + location.host, host: location.host }; | |
| } | |
| const a = toot.querySelector('a[href*="/@"]'); | |
| if(a){ | |
| try { | |
| const url = new URL(a.getAttribute('href'), location.origin); | |
| const m = (url.pathname + url.search).match(/\/@([^\/?]+)/); | |
| if(m){ | |
| const name = decodeURIComponent(m[1]); | |
| if(name.includes('@')) return { acct: name, host: name.split('@')[1] }; | |
| return { acct: name + '@' + url.host, host: url.host }; | |
| } | |
| } catch(e){} | |
| } | |
| return null; | |
| } | |
| function formatLine(up, down){ | |
| const total = up + down; | |
| const score = total === 0 ? 0 : (up / total); | |
| const scoreStr = score.toFixed(2); | |
| return `Upvote/Downvote: ${up}/${down} (${scoreStr})`; | |
| } | |
| function findUpDownLine(lines){ | |
| for(let i=0;i<lines.length;i++){ | |
| const l = lines[i].trim(); | |
| if(!l.toLowerCase().startsWith('upvote/downvote:')) continue; | |
| const m = l.match(/Upvote\/Downvote:\s*([0-9]+)\s*\/\s*([0-9]+)/i); | |
| if(m){ | |
| return { index: i, up: parseInt(m[1],10), down: parseInt(m[2],10), rawLine: l }; | |
| } else { | |
| return { index: i, up: null, down: null, rawLine: l }; | |
| } | |
| } | |
| return null; | |
| } | |
| function mergeNoteText(existingText, deltaUp, deltaDown){ | |
| const prev = existingText || ""; | |
| const lines = prev.split("\n").map(s => s.replace(/\r/g,'')); | |
| const found = findUpDownLine(lines); | |
| if(found){ | |
| if(found.up === null || found.down === null){ | |
| lines.push(formatLine(deltaUp, deltaDown)); | |
| } else { | |
| const newUp = found.up + deltaUp; | |
| const newDown = found.down + deltaDown; | |
| lines[found.index] = formatLine(newUp, newDown); | |
| } | |
| } else { | |
| const cleaned = lines.filter(l => l.trim() !== ''); | |
| cleaned.push(formatLine(deltaUp, deltaDown)); | |
| return cleaned.join("\n").trim(); | |
| } | |
| return lines.map(l=>l.trim()).filter(l=>l!=='').join("\n").trim(); | |
| } | |
| async function performVoteForToot(toot, kind){ | |
| if(kind !== 'up' && kind !== 'down') return; | |
| const info = extractAcctAndHostFromToot(toot); | |
| if(!info || !info.acct) return; | |
| try { | |
| if(!BEARER) await whenBearer(15000); | |
| } catch(e){ | |
| return; | |
| } | |
| try { | |
| const { id: accountId, origin } = await acctToIdLocal(info.acct); | |
| const existing = await getExistingPersonalNote(origin, accountId); | |
| const merged = mergeNoteText(existing, kind==='up'?1:0, kind==='down'?1:0); | |
| await postPersonalNote(origin, accountId, merged); | |
| } catch(e){} | |
| } | |
| function createSvgIconUp(){ | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
| svg.setAttribute('width','24'); | |
| svg.setAttribute('height','24'); | |
| svg.setAttribute('viewBox','0 0 24 24'); | |
| svg.className.baseVal = 'icon icon-ellipsis-h'; | |
| const path = document.createElementNS('http://www.w3.org/2000/svg','path'); | |
| path.setAttribute('d','M12 4l-8 8h5v8h6v-8h5z'); // simple up arrow | |
| svg.appendChild(path); | |
| return svg; | |
| } | |
| function createSvgIconDown(){ | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
| svg.setAttribute('width','24'); | |
| svg.setAttribute('height','24'); | |
| svg.setAttribute('viewBox','0 0 24 24'); | |
| svg.className.baseVal = 'icon icon-ellipsis-h'; | |
| const path = document.createElementNS('http://www.w3.org/2000/svg','path'); | |
| path.setAttribute('d','M12 20l8-8h-5v-8h-6v8h-5z'); // simple down arrow | |
| svg.appendChild(path); | |
| return svg; | |
| } | |
| function createThumbsControl(toot){ | |
| const container = document.createElement('div'); | |
| container.className = 'status__action-bar__button-wrapper'; | |
| container.style.flexBasis = 'auto'; | |
| const upBtn = document.createElement('button'); | |
| upBtn.type = 'button'; | |
| upBtn.className = 'icon-button'; | |
| upBtn.setAttribute('aria-label','upvote-author'); | |
| upBtn.appendChild(createSvgIconUp()); | |
| upBtn.addEventListener('click', (e) => { e.stopPropagation(); performVoteForToot(toot, 'up'); }); | |
| const downBtn = document.createElement('button'); | |
| downBtn.type = 'button'; | |
| downBtn.className = 'icon-button'; | |
| downBtn.setAttribute('aria-label','downvote-author'); | |
| downBtn.appendChild(createSvgIconDown()); | |
| downBtn.addEventListener('click', (e) => { e.stopPropagation(); performVoteForToot(toot, 'down'); }); | |
| container.appendChild(upBtn); | |
| container.appendChild(downBtn); | |
| return container; | |
| } | |
| function addPerTootButtons(root=document){ | |
| const toots = findToots(root); | |
| for(const toot of toots){ | |
| if(toot.dataset.__ud_processed) continue; | |
| toot.dataset.__ud_processed = '1'; | |
| const actionBar = toot.querySelector('.status__action-bar') || toot.querySelector('[role="group"], .status__actions, .actions, .entry__actions') || toot; | |
| try { | |
| const control = createThumbsControl(toot); | |
| actionBar.appendChild(control); | |
| } catch(e){ | |
| try { toot.appendChild(createThumbsControl(toot)); } catch(e){} | |
| } | |
| } | |
| } | |
| const observer = new MutationObserver((mutations) => { | |
| for(const m of mutations) if(m.addedNodes && m.addedNodes.length) addPerTootButtons(document); | |
| }); | |
| addPerTootButtons(document); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| window.__ud_pertoot = { | |
| getBearer: () => BEARER, | |
| waitForBearer: whenBearer, | |
| scanScriptsForToken: scanScriptsForToken | |
| }; | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment