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, "<") 35 .replace(/>/g, ">") 36 .replace(/"/g, """); 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.