Skip to content

Instantly share code, notes, and snippets.

@kidGodzilla
Last active August 23, 2026 13:01
Show Gist options
  • Select an option

  • Save kidGodzilla/29c6aee01f82b05ff481f89a5b68863a to your computer and use it in GitHub Desktop.

Select an option

Save kidGodzilla/29c6aee01f82b05ff481f89a5b68863a to your computer and use it in GitHub Desktop.
An example of a Bluesky thread as a simple web guestbook page
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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>
@kidGodzilla

Copy link
Copy Markdown
Author

Make sure to update the const POST_URL ... section to use it with your own Bluesky thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment