PageSourceSearch

https://goblfc.org/assets/js/bluesky-feed.js?v=1acf24489bca

js goblfc.org collected 2026-10-09 21:55:58 UTC 12,014 bytes, 330 lines download raw bytes

1/**
2 * Homepage Bluesky author feed for goblfc.org.
3 * Full text + links by default; media expands on interaction.
4 */
5(() => {
6  const root = document.getElementById("bluesky-feed");
7  if (!root) return;
8
9  const HANDLE = root.dataset.blueskyHandle || "goblfc.org";
10  const MIN_POSTS = 3;
11  const MAX_POSTS = 5;
12  const FETCH_LIMIT = 20;
13  const REFRESH_MS = 5 * 60 * 1000;
14  const API =
15    "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed" +
16    `?actor=${encodeURIComponent(HANDLE)}&limit=${FETCH_LIMIT}&filter=posts_no_replies`;
17
18  const listEl = root.querySelector("[data-bluesky-list]");
19  const statusEl = root.querySelector("[data-bluesky-status]");
20  if (!listEl) return;
21  let refreshTimer = null;
22  const expandedUris = new Set();
23
24  function setStatus(message, isError) {
25    if (!statusEl) return;
26    statusEl.hidden = !message;
27    statusEl.textContent = message || "";
28    statusEl.classList.toggle("bluesky-feed__status--error", !!isError);
29  }
30
31  function escapeHtml(str) {
32    return String(str)
33      .replace(/&/g, "&")
34      .replace(/</g, "&lt;")
35      .replace(/>/g, "&gt;")
36      .replace(/"/g, "&quot;");
37  }
38
39  function utf8Bytes(str) {
40    return new TextEncoder().encode(str);
41  }
42
43  function utf8Slice(str, start, end) {
44    const bytes = utf8Bytes(str);
45    return new TextDecoder().decode(bytes.slice(start, end));
46  }
47
48  function facetHref(feature) {
49    const type = feature.$type || "";
50    if (type.endsWith("#link")) return feature.uri;
51    if (type.endsWith("#mention")) {
52      return `https://bsky.app/profile/${encodeURIComponent(feature.did || feature.handle || "")}`;
53    }
54    if (type.endsWith("#tag")) {
55      return `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`;
56    }
57    return null;
58  }
59
60  const TRAILING_TAGS_RE = /(?:\s*\n)*\s*(?:#[\p{L}\p{N}_]+\s*)+$/gu;
61
62  function hasTrailingTags(text) {
63    TRAILING_TAGS_RE.lastIndex = 0;
64    return TRAILING_TAGS_RE.test(text);
65  }
66
67  // Drop only end-of-post floating hashtags; keep mid-text ones.
68  function textWithoutTrailingTags(text) {
69    TRAILING_TAGS_RE.lastIndex = 0;
70    return text.replace(TRAILING_TAGS_RE, "").trimEnd() || text.trim();
71  }
72
73  // Bluesky facets use UTF-8 byte offsets into the post text.
74  function renderFacets(text, facets) {
75    if (!facets || !facets.length) {
76      return escapeHtml(text).replace(/\n/g, "<br>");
77    }
78
79    const sorted = facets
80      .slice()
81      .filter((f) => f.index && Array.isArray(f.features) && f.features.length)
82      .sort((a, b) => a.index.byteStart - b.index.byteStart);
83
84    let html = "";
85    let cursor = 0;
86    const total = utf8Bytes(text).length;
87
88    for (const facet of sorted) {
89      const start = Math.max(0, Math.min(total, facet.index.byteStart));
90      const end = Math.max(start, Math.min(total, facet.index.byteEnd));
91      if (start < cursor) continue;
92      if (start >= total) break;
93
94      let gap = utf8Slice(text, cursor, start);
95      const featureType = facet.features[0]?.$type || "";
96      // Tighten "paragraph\n\nhttps://..." so links don't sit under a blank line.
97      if (featureType.endsWith("#link")) {
98        gap = gap.replace(/[\t ]*\n[\t \n]*$/u, " ");
99      }
100
101      html += escapeHtml(gap).replace(/\n/g, "<br>");
102
103      const segment = utf8Slice(text, start, end);
104      const href = facetHref(facet.features[0]);
105      if (href) {
106        html +=
107          `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">` +
108          `${escapeHtml(segment)}</a>`;
109      } else {
110        html += escapeHtml(segment);
111      }
112      cursor = end;
113    }
114
115    html += escapeHtml(utf8Slice(text, cursor, total)).replace(/\n/g, "<br>");
116    return html.replace(/(?:<br>\s*)+$/g, "");
117  }
118
119  function postUrl(post) {
120    const rkey = (post.uri || "").split("/").pop();
121    const handle = post.author?.handle || HANDLE;
122    return `https://bsky.app/profile/${encodeURIComponent(handle)}/post/${encodeURIComponent(rkey)}`;
123  }
124
125  function formatRelativeTime(iso) {
126    const then = new Date(iso).getTime();
127    if (Number.isNaN(then)) return "";
128    const diffSec = Math.round((Date.now() - then) / 1000);
129    const abs = Math.abs(diffSec);
130    const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
131
132    if (abs < 60) return rtf.format(-diffSec, "second");
133    if (abs < 3600) return rtf.format(-Math.round(diffSec / 60), "minute");
134    if (abs < 86400) return rtf.format(-Math.round(diffSec / 3600), "hour");
135    if (abs < 86400 * 30) return rtf.format(-Math.round(diffSec / 86400), "day");
136    return rtf.format(-Math.round(diffSec / (86400 * 30)), "month");
137  }
138
139  function renderImages(embed) {
140    if (!embed || !embed.images || !embed.images.length) return "";
141    const count = Math.min(embed.images.length, 4);
142    const items = embed.images
143      .slice(0, count)
144      .map((img) => {
145        const src = escapeHtml(img.thumb || img.fullsize || "");
146        const alt = escapeHtml(img.alt || "");
147        const full = escapeHtml(img.fullsize || img.thumb || "#");
148        return (
149          `<a class="bluesky-feed__image" href="${full}" target="_blank" rel="noopener noreferrer">` +
150          `<img src="${src}" alt="${alt}" loading="lazy" decoding="async"></a>`
151        );
152      })
153      .join("");
154    return `<div class="bluesky-feed__images bluesky-feed__images--${count}">${items}</div>`;
155  }
156
157  function renderExternal(embed) {
158    const ext = embed && embed.external;
159    if (!ext || !ext.uri) return "";
160    const title = escapeHtml(ext.title || ext.uri);
161    const desc = ext.description ? `<p>${escapeHtml(ext.description)}</p>` : "";
162    const thumb = ext.thumb
163      ? `<img src="${escapeHtml(ext.thumb)}" alt="" loading="lazy" decoding="async">`
164      : "";
165    return (
166      `<a class="bluesky-feed__external" href="${escapeHtml(ext.uri)}" target="_blank" rel="noopener noreferrer">` +
167      `${thumb}<span><strong>${title}</strong>${desc}</span></a>`
168    );
169  }
170
171  function renderEmbed(embed) {
172    if (!embed) return "";
173    const type = embed.$type || "";
174    if (type.includes("images")) return renderImages(embed);
175    if (type.includes("external")) return renderExternal(embed);
176    if (type.includes("recordWithMedia")) {
177      return renderEmbed(embed.media) + renderEmbed(embed.record);
178    }
179    if (type.includes("record") && embed.record?.value?.text) {
180      const text = escapeHtml(embed.record.value.text).replace(/\n/g, "<br>");
181      return `<blockquote class="bluesky-feed__quote">${text}</blockquote>`;
182    }
183    if (type.includes("record") && embed.record?.embeds) {
184      return embed.record.embeds.map(renderEmbed).join("");
185    }
186    return "";
187  }
188
189  function renderPost(item) {
190    const post = item.post;
191    if (!post) return "";
192
193    const author = post.author || {};
194    const text = post.record?.text || "";
195    const facets = post.record?.facets;
196    const createdAt = post.record?.createdAt || post.indexedAt;
197    const url = postUrl(post);
198    const embedHtml = renderEmbed(post.embed);
199    const trailingTags = hasTrailingTags(text);
200    const needsExpand = !!(embedHtml || trailingTags);
201    const isExpanded = expandedUris.has(post.uri);
202    const previewText = trailingTags ? textWithoutTrailingTags(text) : text;
203
204    const avatar = author.avatar
205      ? `<img class="bluesky-feed__avatar" src="${escapeHtml(author.avatar)}" alt="" width="28" height="28" loading="lazy">`
206      : `<span class="bluesky-feed__avatar bluesky-feed__avatar--fallback" aria-hidden="true"></span>`;
207
208    const reason = item.reason?.$type?.includes("reasonRepost")
209      ? `<span class="bluesky-feed__reason"><i class="fa-solid fa-retweet" aria-hidden="true"></i> Repost</span>`
210      : "";
211
212    const expandBtn = needsExpand
213      ? `<button type="button" class="bluesky-feed__toggle" data-bluesky-toggle aria-expanded="${isExpanded}">
214          <span data-label-collapsed>Expand</span>
215          <span data-label-expanded>Less</span>
216        </button>`
217      : "";
218
219    return `
220      <article class="bluesky-feed__post${isExpanded ? " is-expanded" : ""}" data-uri="${escapeHtml(post.uri)}">
221        <header class="bluesky-feed__meta">
222          ${avatar}
223          <div class="bluesky-feed__author">
224            ${reason}
225            <span class="bluesky-feed__name">${escapeHtml(author.displayName || author.handle || HANDLE)}</span>
226            <span class="bluesky-feed__handle">@${escapeHtml(author.handle || HANDLE)}</span>
227          </div>
228          <a class="bluesky-feed__time" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(createdAt || "")}">
229            <time datetime="${escapeHtml(createdAt || "")}">${escapeHtml(formatRelativeTime(createdAt))}</time>
230          </a>
231        </header>
232        <div class="bluesky-feed__text bluesky-feed__text--preview">${renderFacets(previewText, facets)}</div>
233        <div class="bluesky-feed__text bluesky-feed__text--full">${renderFacets(text, facets)}</div>
234        ${embedHtml ? `<div class="bluesky-feed__extras">${embedHtml}</div>` : ""}
235        <div class="bluesky-feed__footer">
236          ${expandBtn}
237          <a class="bluesky-feed__permalink" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">View on Bluesky</a>
238        </div>
239      </article>`;
240  }
241
242  function bindToggles() {
243    listEl.querySelectorAll("[data-bluesky-toggle]").forEach((btn) => {
244      btn.addEventListener("click", () => {
245        const post = btn.closest(".bluesky-feed__post");
246        if (!post) return;
247        const uri = post.dataset.uri;
248        const open = !post.classList.contains("is-expanded");
249        post.classList.toggle("is-expanded", open);
250        btn.setAttribute("aria-expanded", String(open));
251        if (uri) {
252          if (open) expandedUris.add(uri);
253          else expandedUris.delete(uri);
254        }
255      });
256    });
257  }
258
259  function postCreatedAt(item) {
260    const iso = item.post?.record?.createdAt || item.post?.indexedAt;
261    const date = new Date(iso);
262    return Number.isNaN(date.getTime()) ? null : date;
263  }
264
265  function isSameLocalDay(date, now = new Date()) {
266    return (
267      date.getFullYear() === now.getFullYear() &&
268      date.getMonth() === now.getMonth() &&
269      date.getDate() === now.getDate()
270    );
271  }
272
273  // Show at least 3 posts. If more landed today (local calendar day), include those too (cap 5).
274  function selectFeed(items) {
275    if (!items.length) return [];
276
277    const fromToday = items.filter((item) => {
278      const created = postCreatedAt(item);
279      return created && isSameLocalDay(created);
280    });
281    const count = Math.min(MAX_POSTS, Math.max(MIN_POSTS, fromToday.length));
282    return items.slice(0, count);
283  }
284
285  async function loadFeed() {
286    setStatus(listEl.children.length ? "" : "Loading posts…", false);
287    listEl.setAttribute("aria-busy", "true");
288
289    try {
290      const res = await fetch(API, { headers: { Accept: "application/json" } });
291      if (!res.ok) throw new Error(`HTTP ${res.status}`);
292      const data = await res.json();
293      const feed = selectFeed(Array.isArray(data.feed) ? data.feed : []);
294
295      if (!feed.length) {
296        listEl.innerHTML = "";
297        setStatus("No recent posts to show.", false);
298        return;
299      }
300
301      listEl.innerHTML = feed.map(renderPost).join("");
302      bindToggles();
303      setStatus("", false);
304    } catch (err) {
305      console.warn("Bluesky feed failed:", err);
306      if (!listEl.children.length) {
307        setStatus(
308          `Couldn’t load the Bluesky feed right now. Follow @${HANDLE} on Bluesky for updates.`,
309          true
310        );
311      }
312    } finally {
313      listEl.removeAttribute("aria-busy");
314    }
315  }
316
317  function startRefresh() {
318    if (refreshTimer) clearInterval(refreshTimer);
319    refreshTimer = setInterval(() => {
320      if (document.visibilityState === "visible") loadFeed();
321    }, REFRESH_MS);
322  }
323
324  document.addEventListener("visibilitychange", () => {
325    if (document.visibilityState === "visible") loadFeed();
326  });
327
328  loadFeed();
329  startRefresh();
330})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.