// Thread Explorer // Fetches a Bluesky post thread and renders it as an indented tree. // Accepts a bsky.app URL or an AT URI. // Demonstrates: getPostThread, parseAtUri, atUriToBskyUrl, formatPost, toBskyUrl. // // Usage: npx tsx examples/thread-explorer.ts // Examples: // npx tsx examples/thread-explorer.ts https://bsky.app/profile/jay.bsky.team/post/3mffccqrpx22t // npx tsx examples/thread-explorer.ts at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3mffccqrpx22t import { fetchGetPostThread, fetchResolveHandle, parseAtUri, buildAtUri, parseBskyUrl, extractPostViewInfo, formatEngagement, } from "../lib/index.js"; // --- Config --- const input = process.argv[2]; if (!input) { console.error("Usage: npx tsx examples/thread-explorer.ts "); console.error("Example: npx tsx examples/thread-explorer.ts https://bsky.app/profile/jay.bsky.team/post/3mffccqrpx22t"); process.exit(1); } // --- URL parsing --- const resolveDid = async (idOrHandle: string): Promise => idOrHandle.startsWith("did:") ? idOrHandle : fetchResolveHandle(idOrHandle); const resolveInput = async (raw: string): Promise => { if (raw.startsWith("at://")) { const parsed = parseAtUri(raw); if (!parsed) { console.error("Error: invalid AT URI format."); process.exit(1); } const did = await resolveDid(parsed.did); return buildAtUri(did, parsed.collection, parsed.rkey); } const parsed = parseBskyUrl(raw); if (!parsed) { console.error("Error: could not parse input as AT URI or bsky.app URL."); process.exit(1); } const did = await resolveDid(parsed.id); return buildAtUri(did, "app.bsky.feed.post", parsed.rkey); }; // --- Thread rendering --- const renderThread = (node: any, depth = 0) => { const indent = " ".repeat(depth); const prefix = depth === 0 ? ">>>" : "|"; if (node.$type === "app.bsky.feed.defs#notFoundPost") { console.log(`${indent}${prefix} [deleted post]`); return; } if (node.$type === "app.bsky.feed.defs#blockedPost") { console.log(`${indent}${prefix} [blocked post]`); return; } const post = node.post; if (!post) return; const info = extractPostViewInfo(post); const lines = [ `${indent}${prefix} ${info.displayName} (@${info.handle})`, `${indent} ${info.text}`, ]; const likes = post.likeCount ?? 0; const reposts = post.repostCount ?? 0; const replies = post.replyCount ?? 0; if (likes || reposts || replies) { lines.push(`${indent} ${formatEngagement({ likeCount: likes, repostCount: reposts, replyCount: replies })}`); } lines.push(`${indent} ${info.bskyUrl}`); console.log(lines.join("\n")); console.log(); // Render replies if (node.replies?.length) { for (const reply of node.replies) { renderThread(reply, depth + 1); } } }; // --- Main --- const main = async () => { const uri = await resolveInput(input); console.log(`\nFetching thread: ${uri}\n`); try { const thread = await fetchGetPostThread(uri, { depth: 10, parentHeight: 5 }); // Render parent chain first const parents: any[] = []; let current = thread.thread?.parent; while (current?.post) { parents.unshift(current); current = current.parent; } if (parents.length > 0) { console.log("--- Parent chain ---\n"); for (const p of parents) { renderThread(p, 0); } console.log("--- Original post ---\n"); } // Render the thread from the target post renderThread(thread.thread, 0); if (!thread.thread?.replies?.length && parents.length === 0) { console.log("(no replies yet)\n"); } } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }; main();