Something went wrong. Try again.
An ATProto Powered Reputation Network
Something went wrong. Try again.
17 kB · 354 lines
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355"use strict";
const AUTOCOMPLETE_DELAY_MS = 250;const VOUCH_PAGE_SIZE = 100;const Severity = Object.freeze({INFO: "info", ERROR: "error"});const VouchSource = Object.freeze({FOLLOW: "app.bsky.graph.follow", BLOCK: "app.bsky.graph.block", LIST_BLOCK: "app.bsky.graph.listblock"});const RATINGS = Object.freeze({ "-3": {label: "Blocked", color: "blocked"}, "-2": {label: "Avoid", color: "red"}, "-1": {label: "Caution", color: "orange"}, "0": {label: "Neutral", color: "grey"}, "1": {label: "Trust", color: "blue"}, "2": {label: "Strong trust", color: "green"}, "3": {label: "Your own account", color: "green"}});let csrf = "";let offset = 0;const accounts = new Map();const accountLookups = new Map();
async function lookupAccount(did) { if (accounts.has(did)) return accounts.get(did); if (!accountLookups.has(did)) { accountLookups.set(did, request(`/api/accounts?${new URLSearchParams({q: did})}`).then(actors => { actors?.forEach(remember); return accounts.get(did); }).catch(() => { accountLookups.delete(did); return undefined; })); } return accountLookups.get(did);}
// Resolve visible labels only so a long vouch list does not issue a lookup for every row.const labelObserver = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(entries => { for (const entry of entries) { if (!entry.isIntersecting) continue; const node = entry.target; labelObserver.unobserve(node); lookupAccount(node.dataset.did).then(name => { if (name && node.isConnected) node.textContent = name; }); }});
function accountLabel(did) { const node = text("span", accounts.get(did) || `Account · ${did.slice(-8)}`); node.dataset.did = did; node.title = did; if (!accounts.has(did)) labelObserver?.observe(node); return node;}
function message(text, severity = Severity.INFO) { const node = document.querySelector("#message"); node.textContent = text; node.classList.toggle("error", severity === Severity.ERROR); node.setAttribute("role", severity === Severity.ERROR ? "alert" : "status"); node.hidden = !text;}
async function request(path, method = "GET", data, signal) { const headers = {"Accept": "application/json"}; if (method !== "GET") headers["X-CSRF-Token"] = csrf; if (data !== undefined) headers["Content-Type"] = "application/json"; let response; try { response = await fetch(path, {method, headers, signal, body: data === undefined ? undefined : JSON.stringify(data)}); } catch (error) { if (error.name === "AbortError") throw error; throw new Error("Cannot reach Provenance. Check your connection. Check your vouches before you repeat a publish request."); } let result; try { result = await response.json(); } catch { throw new Error("Provenance returned an unreadable response. Try again later."); } if (!response.ok) { const reference = result.request_id ? ` Reference: ${result.request_id}` : ""; throw new Error(`${result.error || "The request failed. Try again later."}${reference}`); } return result;}
function action(handler) { return async event => { event.preventDefault(); const button = event.currentTarget.querySelector?.("button[type=submit]") || event.currentTarget; if (button.disabled || button.getAttribute("aria-busy") === "true") return; button.setAttribute("aria-busy", "true"); try { await handler(); } catch (error) { message(error.message, Severity.ERROR); } finally { button.removeAttribute("aria-busy"); } };}
function remember(actor) { if (!actor?.did) return; const handle = actor.handle && actor.handle !== "handle.invalid" ? `@${actor.handle}` : ""; const label = actor.displayName ? `${actor.displayName}${handle ? ` · ${handle}` : ""}` : handle || actor.did; accounts.set(actor.did, label);}
// Use a visible list instead of browser-dependent datalist menus for account search.function autocomplete(input) { const list = document.getElementById(`${input.id}-suggestions`); const help = document.getElementById(`${input.id}-help`); const initialHelp = help.textContent; let timer; let controller; let generation = 0; let active = -1; let items = []; input.setAttribute("role", "combobox"); input.setAttribute("aria-autocomplete", "list"); input.setAttribute("aria-controls", list.id); input.setAttribute("aria-expanded", "false"); list.setAttribute("role", "listbox"); list.setAttribute("aria-label", "Account suggestions");
function close() { list.hidden = true; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); active = -1; } function choose(index) { const actor = items[index]; if (!actor) return; input.value = actor.handle && actor.handle !== "handle.invalid" ? actor.handle : actor.did; help.textContent = `Selected ${actor.displayName || actor.handle || actor.did}.`; generation++; clearTimeout(timer); controller?.abort(); close(); } function highlight(index) { active = index; Array.from(list.children).forEach((option, i) => option.setAttribute("aria-selected", String(i === index))); const option = list.children[index]; if (option) { input.setAttribute("aria-activedescendant", option.id); option.scrollIntoView({block: "nearest"}); } } input.addEventListener("input", () => { clearTimeout(timer); controller?.abort(); const current = ++generation; close(); list.replaceChildren(); const query = input.value.trim().replace(/^@/, ""); if (!query) { help.textContent = initialHelp; return; } help.textContent = "Search accounts…"; timer = setTimeout(async () => { controller = new AbortController(); try { const result = await request(`/api/accounts?${new URLSearchParams({q: query})}`, "GET", undefined, controller.signal); if (current !== generation) return; items = Array.isArray(result) ? result : []; for (const [index, actor] of items.entries()) { remember(actor); const option = document.createElement("li"); option.id = `${list.id}-${index}`; option.setAttribute("role", "option"); option.setAttribute("aria-selected", "false"); const name = document.createElement("strong"); name.textContent = actor.displayName || actor.handle || actor.did; const handle = document.createElement("small"); handle.textContent = actor.handle && actor.handle !== "handle.invalid" ? `@${actor.handle}` : actor.did; option.append(name, handle); option.addEventListener("pointerdown", event => { event.preventDefault(); choose(index); }); list.append(option); } list.hidden = items.length === 0; input.setAttribute("aria-expanded", String(items.length > 0)); help.textContent = items.length ? "Choose an account, or enter its full handle or DID." : "No suggestions. You can still enter a full handle or DID."; } catch (error) { if (current !== generation || error.name === "AbortError") return; help.textContent = `${error.message} You can still enter a full handle or DID.`; } }, AUTOCOMPLETE_DELAY_MS); }); input.addEventListener("keydown", event => { if (event.key === "Escape") { generation++; controller?.abort(); clearTimeout(timer); close(); return; } if (list.hidden || !items.length) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); highlight(event.key === "ArrowDown" ? (active + 1) % items.length : (active <= 0 ? items.length - 1 : active - 1)); } if (event.key === "Enter" && active >= 0) { event.preventDefault(); choose(active); } }); input.addEventListener("blur", () => { generation++; controller?.abort(); clearTimeout(timer); close(); });}
function tagSuggestions(input) { let timer; let generation = 0; input.addEventListener("input", () => { clearTimeout(timer); const current = ++generation; const list = document.getElementById(input.getAttribute("list")); list.replaceChildren(); timer = setTimeout(async () => { try { const tags = await request(`/api/tags?${new URLSearchParams({prefix: input.value})}`); if (current !== generation) return; for (const tag of tags || []) { const option = document.createElement("option"); option.value = tag; list.append(option); } } catch (error) { if (current === generation) message(error.message, Severity.ERROR); } }, AUTOCOMPLETE_DELAY_MS); });}
function rating(score) { if (score === null || score === undefined) return {label: "No connection yet", color: "grey"}; return RATINGS[String(Math.round(score))] || RATINGS["0"];}
// Show the source so that soft assertions retain their distinct labels.function assertionLabel(step) { if (step.source === VouchSource.LIST_BLOCK) return `Bluesky blocked list (${step.assertion}): ${step.list_uri}`; if (step.source === VouchSource.FOLLOW) return `Bluesky follow (${step.assertion})`; if (step.source === VouchSource.BLOCK) return `Bluesky block (${step.assertion})`; return rating(step.assertion).label;}function dot(score) { const node = document.createElement("span"); node.className = `score-dot ${rating(score).color}`; node.setAttribute("aria-hidden", "true"); return node;}function text(tag, value, className = "") { const node = document.createElement(tag); node.textContent = value; node.className = className; return node;}
async function refreshVouches() { const list = document.querySelector("#vouches"); if (!list) return; const vouches = await request(`/api/vouches?offset=${offset}`); list.replaceChildren(); if (!vouches?.length) list.append(text("li", "No vouches on this page yet.", "field-help")); for (const vouch of vouches || []) { const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; const label = text("span", ""); label.append(accountLabel(vouch.subject), text("small", `${rating(vouch.score).label} · ${vouch.tag || "General"}`)); button.append(dot(vouch.score), label); button.addEventListener("click", () => { document.querySelector("#subject").value = vouch.subject; document.querySelector("#vouch-tag").value = vouch.tag; const choice = document.querySelector(`input[name=score][value="${vouch.score}"]`); if (choice) { choice.checked = true; if (vouch.score === -3) choice.closest("details").open = true; } document.querySelector("#subject").focus(); }); item.append(button); list.append(item); } document.querySelector("#previous").disabled = offset === 0; document.querySelector("#next").disabled = (vouches?.length || 0) < VOUCH_PAGE_SIZE;}
function showResult(result, target) { const node = document.querySelector("#result"); node.replaceChildren(); node.hidden = false; const heading = document.createElement("div"); heading.className = "result-heading"; const title = result.status === "unknown" ? "No connection yet" : result.status === "blocked" ? "You blocked this account" : result.status === "self" ? "This is you" : result.trusted ? "Meets your trust limit" : "Below your trust limit"; const content = text("div", ""); content.append(text("p", target, "eyebrow"), text("h2", title)); heading.append(dot(result.score), content); node.append(heading); const explanation = result.status === "unknown" ? "We found no vouch path from you to this person. This is not a negative rating." : result.status === "blocked" ? "Your block overrides indirect vouches for this topic." : "This result reflects your vouches and the connections between them. It is not a guarantee."; node.append(text("p", explanation)); if (result.path_scores?.length) { const details = document.createElement("details"); details.open = true; details.append(text("summary", "How you connect")); const path = document.createElement("ol"); path.className = "path-list"; result.path_scores.forEach((step, index) => { const item = text("li", index === 0 ? "You" : ""); if (index > 0) item.append(accountLabel(step.did)); item.append(text("small", step.assertion === null ? "Your starting point" : `Previous person's vouch: ${assertionLabel(step)}. Trust along this path: ${step.score.toFixed(2)}.`)); path.append(item); }); details.append(path); node.append(details); } const technical = document.createElement("details"); technical.className = "technical"; technical.append(text("summary", "Technical details"), text("pre", JSON.stringify(result, null, 2))); node.append(technical);}
function showReputation(result, target) { const node = document.querySelector("#result"); node.replaceChildren(text("p", target, "eyebrow"), text("h2", "Relative reputation")); node.hidden = false;
const scores = document.createElement("dl"); scores.className = "reputation-scores"; for (const [label, value] of [["Signed sum", result.absolute_score], ["Mean score", result.mean_score]]) { scores.append(text("dt", label), text("dd", String(value))); } node.append(scores, text("p", "The signed sum adds qualifying direct vouches. The mean divides that sum by the number of qualifying vouches, including zero scores.")); node.append(text("p", "Authors must meet your private trust limit. Hard blocks do not contribute. An empty set returns zero. Zero does not prove consensus."));}
document.querySelectorAll(".account").forEach(autocomplete);document.querySelectorAll(".tag").forEach(tagSuggestions);
if (document.querySelector("#app")) { const ready = (async () => { const me = await request("/api/me"); csrf = me.csrf; document.querySelector("#identity").replaceChildren(accountLabel(me.did)); document.querySelector("#threshold").value = me.threshold; const synced = document.querySelector("#synced"); if (synced) synced.textContent = me.syncedAt ? `Last index update: ${new Date(me.syncedAt).toLocaleString()}` : "The index awaits its first update."; })(); ready.catch(error => message(error.message, Severity.ERROR)); ready.then(refreshVouches).catch(error => message(error.message, Severity.ERROR)); document.querySelector("#threshold-form").addEventListener("submit", action(async () => { await ready; await request("/api/threshold", "PUT", {threshold: Number(document.querySelector("#threshold").value)}); message("Your private trust limit is saved."); })); document.querySelector("#vouch-form")?.addEventListener("submit", action(async () => { await ready; const result = await request("/api/vouch", "PUT", {subject: document.querySelector("#subject").value, tag: document.querySelector("#vouch-tag").value, score: Number(document.querySelector("input[name=score]:checked").value)}); message(result.indexed ? "Your public vouch is saved." : "Your public vouch is saved. It can take time to appear in the list."); })); document.querySelector("#delete-vouch")?.addEventListener("click", action(async () => { if (!confirm("Remove this public vouch?")) return; await ready; await request("/api/vouch", "DELETE", {subject: document.querySelector("#subject").value, tag: document.querySelector("#vouch-tag").value}); message("Your vouch is removed from your account. The list can take time to update."); })); document.querySelector("#query-form")?.addEventListener("submit", action(async () => { await ready; const mode = document.querySelector("#query-mode").value; const target = document.querySelector("#target").value; const aggregate = document.querySelector("#query-form").dataset.calculation === "reputation"; const params = new URLSearchParams({target, mode, tag: mode === "default" ? "" : document.querySelector("#query-tag").value}); if (!aggregate) params.set("include_path", String(document.querySelector("#include-path").checked)); const result = await request(`${aggregate ? "/api/aggregate" : "/api/reputation"}?${params}`); message(""); if (aggregate) showReputation(result, target); else showResult(result, target); })); document.querySelector("#logout").addEventListener("click", action(async () => { await ready; await request("/api/logout", "POST"); location.assign("/"); })); document.querySelector("#previous")?.addEventListener("click", action(async () => { offset = Math.max(0, offset - VOUCH_PAGE_SIZE); await refreshVouches(); })); document.querySelector("#next")?.addEventListener("click", action(async () => { offset += VOUCH_PAGE_SIZE; await refreshVouches(); })); document.querySelector("#refresh-vouches")?.addEventListener("click", action(refreshVouches));}