Skip to content

Instantly share code, notes, and snippets.

@gsurrel
Created December 11, 2025 10:55
Show Gist options
  • Select an option

  • Save gsurrel/716d7dd90745424194607afc05bb6b06 to your computer and use it in GitHub Desktop.

Select an option

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.
/*
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