Monorepo for Tangled
0

Configure Feed

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

Add wilb.me.js

Hello from Local-First Conf!

authored by

wilb.me and committed by
Tangled
(Jul 14, 2026, 1:54 PM +0300) 5022bdbc 0b1e3e3d

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