Last active
August 23, 2026 13:01
-
-
Save kidGodzilla/29c6aee01f82b05ff481f89a5b68863a to your computer and use it in GitHub Desktop.
An example of a Bluesky thread as a simple web guestbook page
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <!-- | |
| A guestbook with no backend, no database and no sign-up. | |
| It is one Bluesky post. Every reply to that post is a signature, read live | |
| from the public AppView each time the page loads. Drop this file on any static | |
| host — or open it straight off your disk — and it works. | |
| To use it: post a thread, paste its URL into POST_URL below. That is the whole | |
| setup. The API needs no key, no account and no CORS proxy. | |
| What you get for free by not building it yourself: | |
| Identity every signer is a real account with a profile and an avatar | |
| Anti-spam Bluesky's moderation runs long before anything reaches here | |
| Moderation block someone and their signature vanishes on the next load; | |
| delete a reply and it is gone. There is no admin UI to build | |
| Hosting none. This is a static file talking to a public read API | |
| --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Guestbook</title> | |
| <style> | |
| :root { | |
| --bg: #f8f4ee; | |
| --paper: #fffdf9; | |
| --ink: #2b2620; | |
| --muted: #8a8077; | |
| --line: #e8e0d6; | |
| --accent: #b85c38; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg: #15120e; --paper: #1e1a15; --ink: #ece6dd; | |
| --muted: #988d80; --line: #2c2620; --accent: #db8a5d; | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0 auto; | |
| padding: clamp(28px, 6vw, 64px) clamp(22px, 5vw, 32px); | |
| max-width: 640px; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font: 17px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| h1 { | |
| margin: 0 0 14px; | |
| font: 400 clamp(2.1rem, 6vw, 3rem)/1.05 "Iowan Old Style", Palatino, Georgia, serif; | |
| letter-spacing: -0.018em; | |
| } | |
| .intro { margin: 0; color: var(--muted); text-wrap: pretty; } | |
| /* Small text is the body face at a small size with letter-spacing. A | |
| monospaced face here would read as code, and none of this is code. */ | |
| .label { | |
| font-size: 10.5px; | |
| font-weight: 500; | |
| letter-spacing: 0.13em; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| } | |
| /* Signing sits above the signatures, not under them. It is what the page is | |
| for, and burying it below a hundred entries hides it exactly as the book | |
| fills up and the link starts to matter. */ | |
| .sign { | |
| display: inline-block; | |
| margin: 24px 0 8px; | |
| padding: 11px 20px; | |
| border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line)); | |
| border-radius: 999px; | |
| background: color-mix(in srgb, var(--accent) 7%, transparent); | |
| color: var(--accent); | |
| text-decoration: none; | |
| font-size: 10.5px; | |
| font-weight: 500; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| transition: border-color 0.18s ease; | |
| } | |
| .sign:hover { border-color: var(--accent); } | |
| .status { padding: 30px 0; color: var(--muted); font-style: italic; } | |
| .sig { padding: 22px 0; border-bottom: 1px solid var(--line); } | |
| .sig__who { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| color: inherit; | |
| text-decoration: none; | |
| } | |
| .sig__face { | |
| flex: none; | |
| width: 34px; | |
| height: 34px; | |
| border-radius: 999px; | |
| object-fit: cover; | |
| background: var(--paper); | |
| } | |
| /* An account with no avatar gets its initial, so the row keeps its shape. */ | |
| .sig__face--none { | |
| display: grid; | |
| place-items: center; | |
| border: 1px solid var(--line); | |
| font: 15px "Iowan Old Style", Palatino, Georgia, serif; | |
| color: var(--muted); | |
| } | |
| .sig__name { | |
| font: 17px "Iowan Old Style", Palatino, Georgia, serif; | |
| transition: color 0.18s ease; | |
| } | |
| .sig__who:hover .sig__name { color: var(--accent); } | |
| .sig__handle { text-transform: none; letter-spacing: 0.02em; } | |
| @media (max-width: 460px) { .sig__handle { display: none; } } | |
| .sig__text { | |
| margin: 12px 0 0 44px; | |
| font-size: 15px; | |
| /* Somebody wrote their line breaks on purpose. Keep them. */ | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| text-wrap: pretty; | |
| } | |
| .sig__date { | |
| display: inline-block; | |
| margin: 10px 0 0 44px; | |
| color: var(--muted); | |
| text-decoration: none; | |
| transition: color 0.18s ease; | |
| } | |
| .sig__date:hover { color: var(--accent); } | |
| /* Your own reply to a signature belongs to that signature, so it is indented | |
| under it and gives back the divider it would otherwise draw. */ | |
| .sig--reply { | |
| margin: 18px 0 0 44px; | |
| padding-left: 16px; | |
| border-bottom: 0; | |
| border-left: 2px solid color-mix(in srgb, var(--accent) 55%, var(--line)); | |
| } | |
| .sig--reply .sig__text, | |
| .sig--reply .sig__date { margin-left: 0; } | |
| .sig--reply .sig__face { width: 22px; height: 22px; } | |
| .sig--reply .sig__name { font-size: 15px; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Guestbook</h1> | |
| <p class="intro">There is no form here and no account to make. This guestbook is | |
| one Bluesky post, and every reply to it is a signature.</p> | |
| <div id="sign"></div> | |
| <section id="book"><p class="status">Reading the thread…</p></section> | |
| <script> | |
| /* ------------------------------------------------------------------ setup */ | |
| /* The only thing to change: the URL of the post people reply to. */ | |
| const POST_URL = 'https://bsky.app/profile/jamesfuthey.com/post/3mtqtme2y3k2p'; | |
| const API = 'https://public.api.bsky.app/xrpc/'; | |
| const THREAD_NODE = 'app.bsky.feed.defs#threadViewPost'; | |
| const book = document.getElementById('book'); | |
| const status = (message) => { book.innerHTML = `<p class="status">${message}</p>`; }; | |
| const esc = (s) => String(s ?? '') | |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') | |
| .replace(/"/g, '"').replace(/'/g, '''); | |
| const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', | |
| 'July', 'August', 'September', 'October', 'November', 'December']; | |
| const longDate = (iso) => { | |
| const [y, m, d] = iso.slice(0, 10).split('-'); | |
| return `${d} ${MONTHS[Number(m) - 1]} ${y}`; | |
| }; | |
| const get = async (method, params) => { | |
| const url = new URL(method, API); | |
| Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`${method} ${res.status}`); | |
| return res.json(); | |
| }; | |
| /* ---------------------------------------------------------------- content */ | |
| /* Bluesky stores a link as a facet and leaves the visible text truncated to | |
| `github.com/some-org/some-re...`, so the text on its own is not what was | |
| written. Facets index the UTF-8 *bytes*, not the characters — doing this in | |
| string offsets quietly corrupts any post containing an emoji, which is most | |
| of them — so it happens on the encoded bytes and back. */ | |
| const BYTES = new TextEncoder(); | |
| const CHARS = new TextDecoder(); | |
| function expandLinks(text, facets) { | |
| const links = (facets || []) | |
| .map((f) => ({ ...f.index, uri: (f.features || []).find((x) => x.$type?.endsWith('#link'))?.uri })) | |
| .filter((f) => f.uri) | |
| .sort((a, b) => b.byteStart - a.byteStart); // back to front, so offsets stay valid | |
| if (!links.length) return text; | |
| let buf = BYTES.encode(text); | |
| for (const link of links) { | |
| const uri = BYTES.encode(link.uri); | |
| const out = new Uint8Array(buf.length - (link.byteEnd - link.byteStart) + uri.length); | |
| out.set(buf.subarray(0, link.byteStart), 0); | |
| out.set(uri, link.byteStart); | |
| out.set(buf.subarray(link.byteEnd), link.byteStart + uri.length); | |
| buf = out; | |
| } | |
| return CHARS.decode(buf); | |
| } | |
| /* Anything that came back as something other than a #threadViewPost is a post | |
| that was deleted, hidden or blocked. Dropping those here is the whole of the | |
| moderation story: block an account and its signature stops being rendered. */ | |
| const oldestFirst = (a, b) => (a.post.record.createdAt < b.post.record.createdAt ? -1 : 1); | |
| const signaturesIn = (node) => | |
| (node.replies || []).filter((r) => r.$type === THREAD_NODE).sort(oldestFirst); | |
| /* A signature is expanded but deliberately never linkified. The text is a | |
| stranger's, and turning their URLs into live links is the single thing that | |
| would give spam a reason to turn up at all. */ | |
| function signature(node, nested) { | |
| const { author, record, uri } = node.post; | |
| const name = author.displayName || author.handle; | |
| const profile = `https://bsky.app/profile/${encodeURIComponent(author.handle)}`; | |
| const replies = nested ? '' : signaturesIn(node).map((r) => signature(r, true)).join(''); | |
| return `<div class="sig${nested ? ' sig--reply' : ''}"> | |
| <a class="sig__who" href="${esc(profile)}"> | |
| ${author.avatar | |
| ? `<img class="sig__face" src="${esc(author.avatar)}" alt="" width="34" height="34" | |
| loading="lazy" decoding="async" referrerpolicy="no-referrer">` | |
| : `<span class="sig__face sig__face--none" aria-hidden="true">${esc([...name][0].toUpperCase())}</span>`} | |
| <span class="sig__name">${esc(name)}</span> | |
| <span class="sig__handle label">@${esc(author.handle)}</span> | |
| </a> | |
| <p class="sig__text">${esc(expandLinks(record.text || '', record.facets))}</p> | |
| <a class="sig__date label" href="${esc(`${profile}/post/${uri.split('/').pop()}`)}">${longDate(record.createdAt)} ↗</a> | |
| ${replies} | |
| </div>`; | |
| } | |
| /* The AppView keeps the reply count and the reply list in different places, and | |
| they can disagree: a reply lands, `replyCount` goes up, and the thread comes | |
| back without it — permanently, not for a few seconds. getPostThreadV2 and the | |
| hidden-replies endpoint miss it too, so it is absent from the thread in the | |
| Bluesky app as well, and no parameter brings it back. Expect to meet this. | |
| The author feed is a second index over the same records, and it is not | |
| missing them. When the count says signatures are unaccounted for, that feed | |
| fills in the ones the thread's author wrote. | |
| Deliberately only theirs. A reply from someone they blocked is *supposed* to | |
| be absent from the thread, and a backfill that reached for everyone's posts | |
| would undo that quietly — restoring exactly the thing the block was for. Your | |
| own replies cannot be someone you blocked, so this can only ever recover | |
| posts that should have been there in the first place. */ | |
| async function withMissing(thread, did) { | |
| const signatures = signaturesIn(thread); | |
| if ((thread.post.replyCount || 0) - signatures.length < 1) return signatures; | |
| const have = new Set(signatures.map((r) => r.post.uri)); | |
| try { | |
| const { feed } = await get('app.bsky.feed.getAuthorFeed', { | |
| actor: did, filter: 'posts_with_replies', limit: 100, | |
| }); | |
| const found = feed | |
| /* A repost is somebody else's post surfacing in the feed, not a reply. */ | |
| .filter((i) => !i.reason | |
| && i.post.record.reply?.parent?.uri === thread.post.uri | |
| && !have.has(i.post.uri)) | |
| .map((i) => ({ post: i.post, replies: [] })); | |
| return [...signatures, ...found].sort(oldestFirst); | |
| } catch (err) { | |
| /* A backfill that fails is not worth losing the page over — the signatures | |
| the thread did return are still good. */ | |
| console.error(err); | |
| return signatures; | |
| } | |
| } | |
| /* ------------------------------------------------------------------- boot */ | |
| (async () => { | |
| const [, handle, rkey] = POST_URL.match(/profile\/([^/]+)\/post\/([^/?#]+)/) || []; | |
| if (!rkey) return status('POST_URL is not a bsky.app post URL.'); | |
| /* bsky.app has a compose intent but no reply intent, so signing is a link to | |
| the thread itself — one tap and the reply box is already open. It is the | |
| one link here that opens in a new tab: signing is an errand you are meant | |
| to come back from, and losing the page to do it is how guestbooks go | |
| unsigned. */ | |
| document.getElementById('sign').innerHTML = | |
| `<a class="sign" href="${esc(POST_URL)}" target="_blank" rel="noopener">Sign the guestbook ↗</a>`; | |
| let thread, did; | |
| try { | |
| /* The record is keyed by DID, not by handle, so the handle is resolved | |
| first — which also means this file keeps working if you change it. */ | |
| ({ did } = await get('com.atproto.identity.resolveHandle', { handle })); | |
| ({ thread } = await get('app.bsky.feed.getPostThread', { | |
| uri: `at://${did}/app.bsky.feed.post/${rkey}`, | |
| depth: 2, // 1 would be the signatures alone; 2 leaves room to reply to one | |
| parentHeight: 0, | |
| })); | |
| } catch (err) { | |
| console.error(err); | |
| /* The page is static and its contents are not. Say so plainly rather than | |
| showing an empty book — the way to sign is still sitting above this. */ | |
| return status('The guestbook could not be reached just now.'); | |
| } | |
| const signatures = await withMissing(thread, did); | |
| if (!signatures.length) return status('Nobody has signed it yet. Be the first.'); | |
| book.innerHTML = signatures.map((s) => signature(s, false)).join(''); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Make sure to update the
const POST_URL ...section to use it with your own Bluesky thread