Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368diff --git a/appview/pages/profile-fx/wilb.me.js b/appview/pages/profile-fx/wilb.me.jsnew file mode 100644index 00000000..3b590c58--- /dev/null+++ b/appview/pages/profile-fx/wilb.me.js@@ -0,0 +1,347 @@+// atfeed.js — punchcard profile effect+//+// Swaps the punchcard for a small RSS-style feed of the profile owner's+// atproto records: Leaflet documents (both the original pub.leaflet.* and+// the newer site.standard.* lexicons) and Bluesky posts, merged and sorted+// newest-first. The profile owner's DID is read off the page itself (the+// followers span carries it as data-followers-did). Everything is fetched+// client-side from the public PDS; if anything fails, the punchcard is+// simply left alone.++const DID_SELECTOR = "#followers[data-followers-did]";+const PLC_DIRECTORY = "https://plc.directory";++const FEED_LIMIT = 12; // entries shown+const PER_COLLECTION = 25; // records fetched per collection+const SNIPPET_LEN = 140; // max chars for post/description text+const STAGGER_MS = 45; // per-entry entrance delay++const DARK_QUERY = "(prefers-color-scheme: dark)";+const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";++// Collections worth putting in a feed, with how to read each one.+// `docs` sources look up their publication record to build a public URL.+const SOURCES = {+ "pub.leaflet.document": { label: "leaflet", kind: "doc", pubCollection: "pub.leaflet.publication" },+ "site.standard.document": { label: "leaflet", kind: "doc", pubCollection: "site.standard.publication" },+ "app.bsky.feed.post": { label: "bsky", kind: "post" },+};++const PALETTES = {+ light: {+ text: "rgb(28 25 23)",+ muted: "rgb(120 113 108)",+ accent: "rgb(234 88 12)", // RSS orange+ border: "rgb(231 229 228)",+ tagBg: "rgb(245 245 244)",+ },+ dark: {+ text: "rgb(231 229 228)",+ muted: "rgb(168 162 158)",+ accent: "rgb(251 146 60)",+ border: "rgb(68 64 60)",+ tagBg: "rgb(41 37 36)",+ },+};++// --- fetching -------------------------------------------------------------++const getJson = async (url, signal) => {+ const res = await fetch(url, { signal });+ if (!res.ok) throw new Error(`${res.status} ${url}`);+ return res.json();+};++const xrpc = (pds, method, params) =>+ `${pds}/xrpc/${method}?${new URLSearchParams(params)}`;++const resolveIdentity = async (did, signal) => {+ const doc = await getJson(`${PLC_DIRECTORY}/${did}`, signal);+ const svc = (doc.service || []).find(+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",+ );+ if (!svc) throw new Error("no PDS in DID document");+ const aka = (doc.alsoKnownAs || [])[0] || "";+ return {+ pds: svc.serviceEndpoint,+ handle: aka.startsWith("at://") ? aka.slice(5) : did,+ };+};++const listRecords = (pds, did, collection, limit, signal) =>+ getJson(+ xrpc(pds, "com.atproto.repo.listRecords", { repo: did, collection, limit }),+ signal,+ ).then((r) => r.records || []);++// --- record → feed entry ---------------------------------------------------++const rkeyOf = (uri) => uri.split("/").pop();++const truncate = (s) => {+ const t = (s || "").replace(/\s+/g, " ").trim();+ return t.length > SNIPPET_LEN ? `${t.slice(0, SNIPPET_LEN - 1)}…` : t;+};++const ensureHttps = (u) => (/^https?:\/\//.test(u) ? u : `https://${u}`);++// Public URL for a document, via its publication's url/base_path.+const docUrl = (record, pubMap) => {+ const v = record.value;+ if (typeof v.url === "string") return ensureHttps(v.url);+ const base = pubMap.get(v.publication);+ if (base) return `${ensureHttps(base).replace(/\/+$/, "")}/${rkeyOf(record.uri)}`;+ return `https://pdsls.dev/at://${record.uri.slice(5)}`; // browse the raw record+};++const toEntry = (record, source, pubMap, did) => {+ const v = record.value || {};+ const when = new Date(v.publishedAt || v.createdAt || 0);+ if (source.kind === "doc") {+ return {+ when,+ label: source.label,+ title: v.title || v.name || "(untitled document)",+ snippet: truncate(v.description),+ href: docUrl(record, pubMap),+ };+ }+ // Bluesky post: skip replies, use the text as the body.+ if (v.reply) return null;+ return {+ when,+ label: source.label,+ title: truncate(v.text) || "(media post)",+ snippet: "",+ href: `https://witchsky.app/profile/${did}/post/${rkeyOf(record.uri)}`,+ };+};++const loadFeed = async (did, signal) => {+ const identity = await resolveIdentity(did, signal);+ const { pds } = identity;++ // Only query collections that actually exist in the repo.+ const info = await getJson(+ xrpc(pds, "com.atproto.repo.describeRepo", { repo: did }),+ signal,+ );+ const present = new Set(info.collections || []);+ const wanted = Object.keys(SOURCES).filter((c) => present.has(c));+ if (wanted.length === 0) throw new Error("no feed-worthy collections");++ // Publication records (small collections) → map at-uri → base url.+ const pubCollections = [+ ...new Set(+ wanted+ .map((c) => SOURCES[c].pubCollection)+ .filter((c) => c && present.has(c)),+ ),+ ];+ const pubMap = new Map();+ const pubResults = await Promise.allSettled(+ pubCollections.map((c) => listRecords(pds, did, c, 50, signal)),+ );+ pubResults.forEach((r) => {+ if (r.status !== "fulfilled") return;+ r.value.forEach((rec) => {+ const base = rec.value?.url || rec.value?.base_path;+ if (base) pubMap.set(rec.uri, base);+ });+ });++ // The records themselves; PDS returns newest-first by default.+ const results = await Promise.allSettled(+ wanted.map((c) => listRecords(pds, did, c, PER_COLLECTION, signal)),+ );+ const entries = results.flatMap((r, i) =>+ r.status === "fulfilled"+ ? r.value+ .map((rec) => toEntry(rec, SOURCES[wanted[i]], pubMap, did))+ .filter(Boolean)+ : [],+ );+ entries.sort((a, b) => b.when - a.when);+ return { identity, entries: entries.slice(0, FEED_LIMIT) };+};++// --- rendering --------------------------------------------------------------++const relativeDate = (d) => {+ const s = (Date.now() - d.getTime()) / 1000;+ if (!Number.isFinite(s) || s < 0 || s > 30 * 86400) {+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });+ }+ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`;+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;+ return `${Math.floor(s / 86400)}d ago`;+};++const el = (tag, styles, text) => {+ const node = document.createElement(tag);+ Object.assign(node.style, styles);+ if (text != null) node.textContent = text; // records are untrusted: text only+ return node;+};++const render = (grid, { identity, entries }, colors, animate, height) => {+ const root = el("div", {+ fontSize: "13px",+ lineHeight: "1.45",+ color: colors.text,+ height: height ? `${height}px` : "auto",+ overflowY: "auto",+ paddingRight: "4px",+ boxSizing: "border-box",+ });+ root.setAttribute("data-punchcard-feed", "");++ const header = el("div", {+ display: "flex",+ alignItems: "center",+ gap: "6px",+ padding: "2px 0 8px",+ borderBottom: `1px solid ${colors.border}`,+ fontFamily: "ui-monospace, monospace",+ fontSize: "12px",+ color: colors.muted,+ });+ header.appendChild(el("span", {}, `@${identity.handle} · ${entries.length} entries`));+ root.appendChild(header);++ entries.forEach((entry, i) => {+ const row = el("div", {+ padding: "8px 0",+ borderBottom: `1px solid ${colors.border}`,+ });++ const meta = el("div", {+ display: "flex",+ alignItems: "baseline",+ gap: "8px",+ fontFamily: "ui-monospace, monospace",+ fontSize: "11px",+ color: colors.muted,+ });+ meta.appendChild(el("span", {}, relativeDate(entry.when)));+ meta.appendChild(+ el(+ "span",+ {+ background: colors.tagBg,+ border: `1px solid ${colors.border}`,+ borderRadius: "9999px",+ padding: "0 7px",+ },+ entry.label,+ ),+ );+ row.appendChild(meta);++ const link = el("a", {+ display: "block",+ marginTop: "2px",+ color: colors.text,+ textDecoration: "none",+ fontWeight: "550",+ overflowWrap: "anywhere",+ });+ link.href = entry.href;+ link.target = "_blank";+ link.rel = "noopener noreferrer";+ link.textContent = entry.title;+ link.addEventListener("pointerenter", () => {+ link.style.color = colors.accent;+ link.style.textDecoration = "underline";+ });+ link.addEventListener("pointerleave", () => {+ link.style.color = colors.text;+ link.style.textDecoration = "none";+ });+ row.appendChild(link);++ if (entry.snippet) {+ row.appendChild(el("div", { color: colors.muted, marginTop: "1px" }, entry.snippet));+ }++ if (animate) {+ row.style.opacity = "0";+ row.style.transform = "translateY(4px)";+ row.style.transition = "opacity 300ms ease, transform 300ms ease";+ row.style.transitionDelay = `${i * STAGGER_MS}ms`;+ requestAnimationFrame(() =>+ requestAnimationFrame(() => {+ row.style.opacity = "1";+ row.style.transform = "none";+ }),+ );+ }+ root.appendChild(row);+ });++ return root;+};++// --- lifecycle ---------------------------------------------------------------++const attach = (grid) => {+ // The profile page carries the owner's DID on the followers count.+ const did = document.querySelector(DID_SELECTOR)?.dataset.followersDid;+ if (!did || !did.startsWith("did:")) return () => {};++ const ac = new AbortController();+ const dark = matchMedia(DARK_QUERY);+ const reduced = matchMedia(REDUCED_QUERY).matches;++ let feedEl = null;+ let data = null;+ let gridHeight = 0; // measured before the grid is hidden+ const savedDisplay = grid.style.display;++ const mount = (animate) => {+ if (!data) return;+ if (!feedEl) gridHeight = grid.getBoundingClientRect().height;+ const colors = dark.matches ? PALETTES.dark : PALETTES.light;+ const next = render(grid, data, colors, animate && !reduced, gridHeight);+ if (feedEl) {+ feedEl.replaceWith(next);+ } else {+ grid.insertAdjacentElement("afterend", next);+ grid.style.display = "none"; // punchcard stays intact underneath+ }+ feedEl = next;+ };++ // Re-render with the other palette when the color scheme flips.+ dark.addEventListener("change", () => mount(false), { signal: ac.signal });++ loadFeed(did, ac.signal)+ .then((d) => {+ if (ac.signal.aborted || d.entries.length === 0) return;+ data = d;+ mount(true);+ })+ .catch(() => {+ /* network/CORS/empty repo: leave the punchcard as it was */+ });++ return () => {+ ac.abort();+ if (feedEl) feedEl.remove();+ grid.style.display = savedDisplay;+ };+};++let cleanup = null;+let current = null;++const init = () => {+ const grid = document.querySelector("[data-punchcard]");+ if (grid === current) return;+ if (cleanup) cleanup();+ current = grid;+ cleanup = grid ? attach(grid) : null;+};++init();+document.addEventListener("htmx:load", init);\ No newline at end of filediff --git a/appview/state/profile.go b/appview/state/profile.goindex 2145db86..800b6487 100644--- a/appview/state/profile.go+++ b/appview/state/profile.go@@ -56,6 +56,7 @@ func (s *State) Profile(w http.ResponseWriter, r *http.Request) { var profileScripts = map[string]string{ "did:plc:3fwecdnvtcscjnrx2p4n7alz": "/static/profile-fx/orrery.js", "did:plc:qfpnj4og54vl56wngdriaxug": "/static/profile-fx/oppili.js",+ "did:plc:oxdlsmnvpk2riyyuvq5jtdkd": "/static/profile-fx/wilb.me.js", } func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {