This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pages / profile-fx / wilb.me.js
11 kB 347 lines
1// atfeed.js — punchcard profile effect 2// 3// Swaps the punchcard for a small RSS-style feed of the profile owner's 4// atproto records: Leaflet documents (both the original pub.leaflet.* and 5// the newer site.standard.* lexicons) and Bluesky posts, merged and sorted 6// newest-first. The profile owner's DID is read off the page itself (the 7// followers span carries it as data-followers-did). Everything is fetched 8// client-side from the public PDS; if anything fails, the punchcard is 9// simply left alone. 10 11const DID_SELECTOR = "#followers[data-followers-did]"; 12const PLC_DIRECTORY = "https://plc.directory"; 13 14const FEED_LIMIT = 12; // entries shown 15const PER_COLLECTION = 25; // records fetched per collection 16const SNIPPET_LEN = 140; // max chars for post/description text 17const STAGGER_MS = 45; // per-entry entrance delay 18 19const DARK_QUERY = "(prefers-color-scheme: dark)"; 20const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; 21 22// Collections worth putting in a feed, with how to read each one. 23// `docs` sources look up their publication record to build a public URL. 24const SOURCES = { 25 "pub.leaflet.document": { label: "leaflet", kind: "doc", pubCollection: "pub.leaflet.publication" }, 26 "site.standard.document": { label: "leaflet", kind: "doc", pubCollection: "site.standard.publication" }, 27 "app.bsky.feed.post": { label: "bsky", kind: "post" }, 28}; 29 30const PALETTES = { 31 light: { 32 text: "rgb(28 25 23)", 33 muted: "rgb(120 113 108)", 34 accent: "rgb(234 88 12)", // RSS orange 35 border: "rgb(231 229 228)", 36 tagBg: "rgb(245 245 244)", 37 }, 38 dark: { 39 text: "rgb(231 229 228)", 40 muted: "rgb(168 162 158)", 41 accent: "rgb(251 146 60)", 42 border: "rgb(68 64 60)", 43 tagBg: "rgb(41 37 36)", 44 }, 45}; 46 47// --- fetching ------------------------------------------------------------- 48 49const getJson = async (url, signal) => { 50 const res = await fetch(url, { signal }); 51 if (!res.ok) throw new Error(`${res.status} ${url}`); 52 return res.json(); 53}; 54 55const xrpc = (pds, method, params) => 56 `${pds}/xrpc/${method}?${new URLSearchParams(params)}`; 57 58const resolveIdentity = async (did, signal) => { 59 const doc = await getJson(`${PLC_DIRECTORY}/${did}`, signal); 60 const svc = (doc.service || []).find( 61 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 62 ); 63 if (!svc) throw new Error("no PDS in DID document"); 64 const aka = (doc.alsoKnownAs || [])[0] || ""; 65 return { 66 pds: svc.serviceEndpoint, 67 handle: aka.startsWith("at://") ? aka.slice(5) : did, 68 }; 69}; 70 71const listRecords = (pds, did, collection, limit, signal) => 72 getJson( 73 xrpc(pds, "com.atproto.repo.listRecords", { repo: did, collection, limit }), 74 signal, 75 ).then((r) => r.records || []); 76 77// --- record → feed entry --------------------------------------------------- 78 79const rkeyOf = (uri) => uri.split("/").pop(); 80 81const truncate = (s) => { 82 const t = (s || "").replace(/\s+/g, " ").trim(); 83 return t.length > SNIPPET_LEN ? `${t.slice(0, SNIPPET_LEN - 1)}` : t; 84}; 85 86const ensureHttps = (u) => (/^https?:\/\//.test(u) ? u : `https://${u}`); 87 88// Public URL for a document, via its publication's url/base_path. 89const docUrl = (record, pubMap) => { 90 const v = record.value; 91 if (typeof v.url === "string") return ensureHttps(v.url); 92 const base = pubMap.get(v.publication); 93 if (base) return `${ensureHttps(base).replace(/\/+$/, "")}/${rkeyOf(record.uri)}`; 94 return `https://pdsls.dev/at://${record.uri.slice(5)}`; // browse the raw record 95}; 96 97const toEntry = (record, source, pubMap, did) => { 98 const v = record.value || {}; 99 const when = new Date(v.publishedAt || v.createdAt || 0); 100 if (source.kind === "doc") { 101 return { 102 when, 103 label: source.label, 104 title: v.title || v.name || "(untitled document)", 105 snippet: truncate(v.description), 106 href: docUrl(record, pubMap), 107 }; 108 } 109 // Bluesky post: skip replies, use the text as the body. 110 if (v.reply) return null; 111 return { 112 when, 113 label: source.label, 114 title: truncate(v.text) || "(media post)", 115 snippet: "", 116 href: `https://witchsky.app/profile/${did}/post/${rkeyOf(record.uri)}`, 117 }; 118}; 119 120const loadFeed = async (did, signal) => { 121 const identity = await resolveIdentity(did, signal); 122 const { pds } = identity; 123 124 // Only query collections that actually exist in the repo. 125 const info = await getJson( 126 xrpc(pds, "com.atproto.repo.describeRepo", { repo: did }), 127 signal, 128 ); 129 const present = new Set(info.collections || []); 130 const wanted = Object.keys(SOURCES).filter((c) => present.has(c)); 131 if (wanted.length === 0) throw new Error("no feed-worthy collections"); 132 133 // Publication records (small collections) → map at-uri → base url. 134 const pubCollections = [ 135 ...new Set( 136 wanted 137 .map((c) => SOURCES[c].pubCollection) 138 .filter((c) => c && present.has(c)), 139 ), 140 ]; 141 const pubMap = new Map(); 142 const pubResults = await Promise.allSettled( 143 pubCollections.map((c) => listRecords(pds, did, c, 50, signal)), 144 ); 145 pubResults.forEach((r) => { 146 if (r.status !== "fulfilled") return; 147 r.value.forEach((rec) => { 148 const base = rec.value?.url || rec.value?.base_path; 149 if (base) pubMap.set(rec.uri, base); 150 }); 151 }); 152 153 // The records themselves; PDS returns newest-first by default. 154 const results = await Promise.allSettled( 155 wanted.map((c) => listRecords(pds, did, c, PER_COLLECTION, signal)), 156 ); 157 const entries = results.flatMap((r, i) => 158 r.status === "fulfilled" 159 ? r.value 160 .map((rec) => toEntry(rec, SOURCES[wanted[i]], pubMap, did)) 161 .filter(Boolean) 162 : [], 163 ); 164 entries.sort((a, b) => b.when - a.when); 165 return { identity, entries: entries.slice(0, FEED_LIMIT) }; 166}; 167 168// --- rendering -------------------------------------------------------------- 169 170const relativeDate = (d) => { 171 const s = (Date.now() - d.getTime()) / 1000; 172 if (!Number.isFinite(s) || s < 0 || s > 30 * 86400) { 173 return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); 174 } 175 if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`; 176 if (s < 86400) return `${Math.floor(s / 3600)}h ago`; 177 return `${Math.floor(s / 86400)}d ago`; 178}; 179 180const el = (tag, styles, text) => { 181 const node = document.createElement(tag); 182 Object.assign(node.style, styles); 183 if (text != null) node.textContent = text; // records are untrusted: text only 184 return node; 185}; 186 187const render = (grid, { identity, entries }, colors, animate, height) => { 188 const root = el("div", { 189 fontSize: "13px", 190 lineHeight: "1.45", 191 color: colors.text, 192 height: height ? `${height}px` : "auto", 193 overflowY: "auto", 194 paddingRight: "4px", 195 boxSizing: "border-box", 196 }); 197 root.setAttribute("data-punchcard-feed", ""); 198 199 const header = el("div", { 200 display: "flex", 201 alignItems: "center", 202 gap: "6px", 203 padding: "2px 0 8px", 204 borderBottom: `1px solid ${colors.border}`, 205 fontFamily: "ui-monospace, monospace", 206 fontSize: "12px", 207 color: colors.muted, 208 }); 209 header.appendChild(el("span", {}, `@${identity.handle} · ${entries.length} entries`)); 210 root.appendChild(header); 211 212 entries.forEach((entry, i) => { 213 const row = el("div", { 214 padding: "8px 0", 215 borderBottom: `1px solid ${colors.border}`, 216 }); 217 218 const meta = el("div", { 219 display: "flex", 220 alignItems: "baseline", 221 gap: "8px", 222 fontFamily: "ui-monospace, monospace", 223 fontSize: "11px", 224 color: colors.muted, 225 }); 226 meta.appendChild(el("span", {}, relativeDate(entry.when))); 227 meta.appendChild( 228 el( 229 "span", 230 { 231 background: colors.tagBg, 232 border: `1px solid ${colors.border}`, 233 borderRadius: "9999px", 234 padding: "0 7px", 235 }, 236 entry.label, 237 ), 238 ); 239 row.appendChild(meta); 240 241 const link = el("a", { 242 display: "block", 243 marginTop: "2px", 244 color: colors.text, 245 textDecoration: "none", 246 fontWeight: "550", 247 overflowWrap: "anywhere", 248 }); 249 link.href = entry.href; 250 link.target = "_blank"; 251 link.rel = "noopener noreferrer"; 252 link.textContent = entry.title; 253 link.addEventListener("pointerenter", () => { 254 link.style.color = colors.accent; 255 link.style.textDecoration = "underline"; 256 }); 257 link.addEventListener("pointerleave", () => { 258 link.style.color = colors.text; 259 link.style.textDecoration = "none"; 260 }); 261 row.appendChild(link); 262 263 if (entry.snippet) { 264 row.appendChild(el("div", { color: colors.muted, marginTop: "1px" }, entry.snippet)); 265 } 266 267 if (animate) { 268 row.style.opacity = "0"; 269 row.style.transform = "translateY(4px)"; 270 row.style.transition = "opacity 300ms ease, transform 300ms ease"; 271 row.style.transitionDelay = `${i * STAGGER_MS}ms`; 272 requestAnimationFrame(() => 273 requestAnimationFrame(() => { 274 row.style.opacity = "1"; 275 row.style.transform = "none"; 276 }), 277 ); 278 } 279 root.appendChild(row); 280 }); 281 282 return root; 283}; 284 285// --- lifecycle --------------------------------------------------------------- 286 287const attach = (grid) => { 288 // The profile page carries the owner's DID on the followers count. 289 const did = document.querySelector(DID_SELECTOR)?.dataset.followersDid; 290 if (!did || !did.startsWith("did:")) return () => {}; 291 292 const ac = new AbortController(); 293 const dark = matchMedia(DARK_QUERY); 294 const reduced = matchMedia(REDUCED_QUERY).matches; 295 296 let feedEl = null; 297 let data = null; 298 let gridHeight = 0; // measured before the grid is hidden 299 const savedDisplay = grid.style.display; 300 301 const mount = (animate) => { 302 if (!data) return; 303 if (!feedEl) gridHeight = grid.getBoundingClientRect().height; 304 const colors = dark.matches ? PALETTES.dark : PALETTES.light; 305 const next = render(grid, data, colors, animate && !reduced, gridHeight); 306 if (feedEl) { 307 feedEl.replaceWith(next); 308 } else { 309 grid.insertAdjacentElement("afterend", next); 310 grid.style.display = "none"; // punchcard stays intact underneath 311 } 312 feedEl = next; 313 }; 314 315 // Re-render with the other palette when the color scheme flips. 316 dark.addEventListener("change", () => mount(false), { signal: ac.signal }); 317 318 loadFeed(did, ac.signal) 319 .then((d) => { 320 if (ac.signal.aborted || d.entries.length === 0) return; 321 data = d; 322 mount(true); 323 }) 324 .catch(() => { 325 /* network/CORS/empty repo: leave the punchcard as it was */ 326 }); 327 328 return () => { 329 ac.abort(); 330 if (feedEl) feedEl.remove(); 331 grid.style.display = savedDisplay; 332 }; 333}; 334 335let cleanup = null; 336let current = null; 337 338const init = () => { 339 const grid = document.querySelector("[data-punchcard]"); 340 if (grid === current) return; 341 if (cleanup) cleanup(); 342 current = grid; 343 cleanup = grid ? attach(grid) : null; 344}; 345 346init(); 347document.addEventListener("htmx:load", init);