This repository has no description
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);