Personal blog finxol.io
blog

Add Bluesky integration docs #3

merged opened by finxol.io targeting main from chore/add-bsky-integration-docs
Labels

None yet.

Participants 1
AT URI
at://did:plc:hpmpe3pzpdtxbmvhlwrevhju/sh.tangled.repo.pull/3m6ho4u47rr22
+145 -39
Diff #0
+103 -26
README.md
··· 1 - # Nuxt Minimal Starter 2 3 - Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 5 - ## Setup 6 7 - Make sure to install dependencies: 8 9 - ```bash 10 - # pnpm 11 - pnpm install 12 - ``` 13 14 - ## Development Server 15 16 - Start the development server on `http://localhost:3000`: 17 18 - ```bash 19 - # pnpm 20 - pnpm dev 21 - ``` 22 23 - ## Production 24 25 - Build the application for production: 26 27 - ```bash 28 - # pnpm 29 - pnpm build 30 - ``` 31 32 - Locally preview production build: 33 34 - ```bash 35 - # pnpm 36 - pnpm preview 37 - ``` 38 39 - Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
··· 1 + # finxol blog 2 + 3 + This is the repo for finxol's blog. 4 + 5 + All posts are in `content/`. 6 + Configuration is in `blog.config.ts`. 7 + 8 + ## Technology stack 9 + 10 + - Nuxt v4 11 + - Nuxt Content 12 + - TailwindCSS 13 + - Deno (Deploy EA) 14 + 15 + ## Bluesky integration 16 + 17 + Tracking PR: [#1](https://tangled.org/finxol.io/blog/pulls/1/) 18 + 19 + Comments on this blog are directly integrated with Bluesky, the atproto-based micro-blogging social network. 20 + 21 + This integration relies on the `@atcute/` library collection for interaction with Bluesky/atproto. 22 + 23 + The idea was originally inspired from [natalie's blog](https://natalie.sh/posts/bluesky-comments/). 24 + Although I ended up using mostly the same tools and strategies, I didn't follow her post to build it here. 25 + 26 + ### How it works in practice 27 + 28 + The author of the blog writes a post and publishes it. 29 + They can then post about it on Bluesky, find the post id, and add it to the `bskyCid` field in the post frontmatter. 30 + Any Bluesky post below the one identified will now be displayed at the bottom of the blog post, allowing for integrated conversation about the post. 31 + 32 + ### How it works technically 33 + 34 + The [AT Protocol](https://atproto.com/) is an open internet protocol for social applications. 35 + All the data is decentralised and public ([for now](https://pfrazee.leaflet.pub/3lzhui2zbxk2b)). 36 + This openness allows us to reuse and build things based on that data very easily, in a built-in way, without hacky workarounds. 37 + 38 + This integration works in several parts: 39 + 40 + #### `app/util/atproto.ts` 41 + 42 + Contains the utility functions for retrieving all replies to a post, and extracting a post id from an atproto uri. 43 + 44 + Uses `@atcute/client` to fetch using the `app.bsky.feed.getPostThread` RPC on the Bluesky public API. 45 + Everything is strongly typed, although fetch errors are handled as `post not found` to make handling simpler in the Vue component. 46 + 47 + #### `blog.config.ts` 48 + 49 + The author DID is set blog-wide in the config file through `authorDid`, as it is primarily intended as a personal blog. 50 + If need be, I can always move the DID parameter to the post frontmatter, allowing for guest authors or secondary accounts too. 51 + 52 + #### `content.config.ts` 53 + 54 + Since the Bluesky post CID needs to be set for each blog post independently, 55 + I added a `bskyCid` field in the post frontmatter. 56 + 57 + #### `app/components/BskyComments.vue` 58 + 59 + This is the core component to display the replies. 60 + 61 + The component simply fetches the replies by calling `getBskyReplies`, passing in the post CID passed as prop, 62 + and displays the content using the `BskyPost` component. 63 + 64 + The reply, like, repost, and bookmark counts of the original Bluesky post are also displayed. 65 + 66 67 + #### `app/components/BskyPost.vue` 68 69 + This component displays the post author, their avatar, the post content, and its stats beautifully. 70 71 + Replies to replies are indented accordingly to visually thread replies together, using `BskyPost` recursively, 72 + with a `MAX_DEPTH` to set a limit to the number of replies to show. 73 74 75 + #### `app/pages/posts/[...slug].vue` 76 77 + The actual post page only had some minor adjustments to integrate the `BskyComments` component, 78 + using a `Suspense` boundary with a fallback to avoid blocking the rendering of the actual content. 79 80 + #### Others 81 82 + Some other files saw modifications, to adapt to this integration addition, allowing for visual consistency. 83 84 + ### Advantages of the approach 85 86 + Since this blog is built with Nuxt, everything is SSRed. 87 + This makes the Bluesky integration a wonderful progressive enhancement. 88 + The comments will still display and show up as intended if the client has Javascript disabled, 89 + without blocking rendering of the actual content through the use of a `Suspense` boundary. 90 91 + Using Bluesky as a comment platform allows me to integrate conversations about my posts directly alongside them, 92 + without bearing the load of moderation and user accounts. 93 94 + ### Limitations 95 96 + As briefly mentioned, fetch errors are normalised to `#notFoundPost`, 97 + this could be refined for better reporting in the UI. 98 + 99 + This integration also only handles plain text content. 100 + All embedded and rich media is effectively ignored for now. 101 + 102 + ## Install locally 103 + 104 + ```sh 105 + # Install dependencies 106 + pnpm i 107 + 108 + # Run the development server 109 + deno task dev 110 + 111 + # Build for production 112 + deno task build 113 + 114 + # Deploy to Deno Deploy EA. Add `--prod` to deploy to production 115 + deno deploy 116 + ```
+18 -12
app/components/BskyComments.vue
··· 1 <script setup lang="ts"> 2 - import { getBskyReplies, type ReplyThread } from "~/util/atproto"; 3 4 const props = defineProps({ 5 cid: { ··· 11 12 const data = ref(await getBskyReplies(cid.value)); 13 const err = ref(""); 14 - const post = ref(); 15 16 if (data.value.$type === "app.bsky.feed.defs#blockedPost") { 17 err.value = "Post is blocked"; ··· 23 24 if (data.value.$type === "app.bsky.feed.defs#threadViewPost") { 25 console.log(data.value); 26 - post.value = data.value; 27 } 28 </script> 29 ··· 31 <div class="md:w-[80%] mx-auto mt-16"> 32 <div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0"> 33 <h3 class="font-bold text-xl">Join the conversation!</h3> 34 - <div class="flex items-center gap-2"> 35 <p class="text-gray-500 text-sm" title="Replies"> 36 <Icon name="ri:reply-line" class="-mb-[2px] mr-1" /> 37 {{post.post.replyCount}} 38 </p> 39 <p class="text-gray-500 text-sm" title="Likes"> 40 <Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" /> 41 - <span> 42 - {{post.post.likeCount}} 43 - </span> 44 </p> 45 <p class="text-gray-500 text-sm" title="Bookmarks"> 46 <Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" /> ··· 49 </div> 50 </div> 51 52 - <p class="text-gray-600 text-md mb-6"> 53 <a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion. 54 </p> 55 56 - <div v-if="err"> 57 - <div>{{ err }}</div> 58 - </div> 59 60 <div v-if="post"> 61 <div v-if="post.post.replyCount === 0"> ··· 64 65 <BskyPost 66 v-else 67 - v-for="reply in post.replies" 68 :key="reply.post.cid" 69 :post="reply" 70 :depth="0"
··· 1 <script setup lang="ts"> 2 + import type { AppBskyFeedDefs } from "@atcute/bluesky"; 3 + import { getBskyReplies } from "~/util/atproto"; 4 5 const props = defineProps({ 6 cid: { ··· 12 13 const data = ref(await getBskyReplies(cid.value)); 14 const err = ref(""); 15 + const post = ref<AppBskyFeedDefs.ThreadViewPost>(); 16 17 if (data.value.$type === "app.bsky.feed.defs#blockedPost") { 18 err.value = "Post is blocked"; ··· 24 25 if (data.value.$type === "app.bsky.feed.defs#threadViewPost") { 26 console.log(data.value); 27 + post.value = data.value as AppBskyFeedDefs.ThreadViewPost; 28 } 29 </script> 30 ··· 32 <div class="md:w-[80%] mx-auto mt-16"> 33 <div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0"> 34 <h3 class="font-bold text-xl">Join the conversation!</h3> 35 + <div v-if="post" class="flex items-center gap-6"> 36 <p class="text-gray-500 text-sm" title="Replies"> 37 <Icon name="ri:reply-line" class="-mb-[2px] mr-1" /> 38 {{post.post.replyCount}} 39 </p> 40 <p class="text-gray-500 text-sm" title="Likes"> 41 <Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" /> 42 + {{post.post.likeCount}} 43 + </p> 44 + <p class="text-gray-500 text-sm" title="Reposts"> 45 + <Icon name="bx:repost" class="-mb-[2px] mr-1" /> 46 + {{post.post.repostCount}} 47 </p> 48 <p class="text-gray-500 text-sm" title="Bookmarks"> 49 <Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" /> ··· 52 </div> 53 </div> 54 55 + <div v-if="err"> 56 + <p class="mt-2 text-gray-700 dark:text-gray-500"> 57 + {{ err }} 58 + </p> 59 + </div> 60 + 61 + <p v-if="post" class="text-gray-600 dark:text-gray-500 text-md mb-6"> 62 <a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion. 63 </p> 64 65 66 <div v-if="post"> 67 <div v-if="post.post.replyCount === 0"> ··· 70 71 <BskyPost 72 v-else 73 + v-for="reply in post.replies?.filter(reply => reply.$type === 'app.bsky.feed.defs#threadViewPost')" 74 :key="reply.post.cid" 75 :post="reply" 76 :depth="0"
+13 -1
app/util/atproto.ts
··· 5 import config from "@/../blog.config"; 6 7 const handler = simpleFetchHandler({ 8 service: "https://public.api.bsky.app" 9 }); 10 const rpc = new Client({ handler }); ··· 14 | AppBskyFeedDefs.BlockedPost 15 | AppBskyFeedDefs.NotFoundPost; 16 17 export async function getBskyReplies(cid: string) { 18 // uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx 19 const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`; ··· 21 const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", { 22 params: { 23 uri, 24 - depth: 10 25 } 26 }); 27 28 if (!ok) { 29 console.error("Error fetching thread:", data.error); 30 return { $type: "app.bsky.feed.defs#notFoundPost" }; 31 } ··· 37 return { $type: "app.bsky.feed.defs#notFoundPost" }; 38 } 39 40 export function extractPostId(uri: ResourceUri) { 41 if (uri.includes("app.bsky.feed.post")) { 42 const parts = uri.split("/");
··· 5 import config from "@/../blog.config"; 6 7 const handler = simpleFetchHandler({ 8 + // Simply hit up the Bluesky API 9 service: "https://public.api.bsky.app" 10 }); 11 const rpc = new Client({ handler }); ··· 15 | AppBskyFeedDefs.BlockedPost 16 | AppBskyFeedDefs.NotFoundPost; 17 18 + /** 19 + * Fetch the first 10 replies to a post 20 + * @param cid 21 + * @returns 22 + */ 23 export async function getBskyReplies(cid: string) { 24 // uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx 25 const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`; ··· 27 const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", { 28 params: { 29 uri, 30 + depth: 6 // default 31 } 32 }); 33 34 if (!ok) { 35 + // Handle fetch errors as 'not found'. Could be cleaner, but works just fine. 36 console.error("Error fetching thread:", data.error); 37 return { $type: "app.bsky.feed.defs#notFoundPost" }; 38 } ··· 44 return { $type: "app.bsky.feed.defs#notFoundPost" }; 45 } 46 47 + /** 48 + * Extract post id from an atproto uri 49 + * @param uri The atproto uri, such as at://did:plc:user/app.bsky.feed.post/xxxxx` 50 + * @returns The post id 51 + */ 52 export function extractPostId(uri: ResourceUri) { 53 if (uri.includes("app.bsky.feed.post")) { 54 const parts = uri.split("/");
+1
package.json
··· 15 "@atcute/bluesky": "^3.2.10", 16 "@atcute/client": "^4.0.5", 17 "@atcute/lexicons": "^1.2.4", 18 "@nuxt/content": "^3.8.0", 19 "@nuxt/icon": "1.11.0", 20 "@nuxtjs/tailwindcss": "^6.14.0",
··· 15 "@atcute/bluesky": "^3.2.10", 16 "@atcute/client": "^4.0.5", 17 "@atcute/lexicons": "^1.2.4", 18 + "@iconify-json/bx": "^1.2.2", 19 "@nuxt/content": "^3.8.0", 20 "@nuxt/icon": "1.11.0", 21 "@nuxtjs/tailwindcss": "^6.14.0",
+10
pnpm-lock.yaml
··· 17 '@atcute/lexicons': 18 specifier: ^1.2.4 19 version: 1.2.4 20 '@nuxt/content': 21 specifier: ^3.8.0 22 version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1) ··· 458 459 '@iconify-json/ant-design@1.2.5': 460 resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==} 461 462 '@iconify-json/ri@1.2.6': 463 resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==} ··· 5180 optional: true 5181 5182 '@iconify-json/ant-design@1.2.5': 5183 dependencies: 5184 '@iconify/types': 2.0.0 5185
··· 17 '@atcute/lexicons': 18 specifier: ^1.2.4 19 version: 1.2.4 20 + '@iconify-json/bx': 21 + specifier: ^1.2.2 22 + version: 1.2.2 23 '@nuxt/content': 24 specifier: ^3.8.0 25 version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1) ··· 461 462 '@iconify-json/ant-design@1.2.5': 463 resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==} 464 + 465 + '@iconify-json/bx@1.2.2': 466 + resolution: {integrity: sha512-hZVx6LMEkYckScdRdUuQWcmv8Lm2au6Cnf799TLoR6YgiAfFvaJ4M5ElwcnExvCu8ntsS7jW89r0W5LwBAfZXQ==} 467 468 '@iconify-json/ri@1.2.6': 469 resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==} ··· 5186 optional: true 5187 5188 '@iconify-json/ant-design@1.2.5': 5189 + dependencies: 5190 + '@iconify/types': 2.0.0 5191 + 5192 + '@iconify-json/bx@1.2.2': 5193 dependencies: 5194 '@iconify/types': 2.0.0 5195

History

1 round 0 comments
sign up or login to add to the discussion
finxol.io submitted #0
3 commits
expand
chore: improve README, with detailed atproto integration explanation
feat: display repost count
chore: improve typing and documentation
expand 0 comments
pull request successfully merged