Monorepo for Tangled
0

Configure Feed

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

Actually, now it's an atproto RSS reader

Wilhelm Berggren (Jul 14, 2026, 12:21 PM +0200) 7ced0017 c2025894

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