Add documentation and explanations for the bluesky integration #2

closed
opened by finxol.io targeting main from feat/add-bsky-comments
Changed files
+145 -39
app
components
util
+103 -26
README.md
··· 1 - # Nuxt Minimal Starter 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 + 2 66 3 - Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 67 + #### `app/components/BskyPost.vue` 4 68 5 - ## Setup 69 + This component displays the post author, their avatar, the post content, and its stats beautifully. 6 70 7 - Make sure to install dependencies: 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. 8 73 9 - ```bash 10 - # pnpm 11 - pnpm install 12 - ``` 13 74 14 - ## Development Server 75 + #### `app/pages/posts/[...slug].vue` 15 76 16 - Start the development server on `http://localhost:3000`: 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. 17 79 18 - ```bash 19 - # pnpm 20 - pnpm dev 21 - ``` 80 + #### Others 22 81 23 - ## Production 82 + Some other files saw modifications, to adapt to this integration addition, allowing for visual consistency. 24 83 25 - Build the application for production: 84 + ### Advantages of the approach 26 85 27 - ```bash 28 - # pnpm 29 - pnpm build 30 - ``` 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. 31 90 32 - Locally preview production build: 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. 33 93 34 - ```bash 35 - # pnpm 36 - pnpm preview 37 - ``` 94 + ### Limitations 38 95 39 - Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 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 1 <script setup lang="ts"> 2 - import { getBskyReplies, type ReplyThread } from "~/util/atproto"; 2 + import type { AppBskyFeedDefs } from "@atcute/bluesky"; 3 + import { getBskyReplies } from "~/util/atproto"; 3 4 4 5 const props = defineProps({ 5 6 cid: { ··· 11 12 12 13 const data = ref(await getBskyReplies(cid.value)); 13 14 const err = ref(""); 14 - const post = ref(); 15 + const post = ref<AppBskyFeedDefs.ThreadViewPost>(); 15 16 16 17 if (data.value.$type === "app.bsky.feed.defs#blockedPost") { 17 18 err.value = "Post is blocked"; ··· 23 24 24 25 if (data.value.$type === "app.bsky.feed.defs#threadViewPost") { 25 26 console.log(data.value); 26 - post.value = data.value; 27 + post.value = data.value as AppBskyFeedDefs.ThreadViewPost; 27 28 } 28 29 </script> 29 30 ··· 31 32 <div class="md:w-[80%] mx-auto mt-16"> 32 33 <div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0"> 33 34 <h3 class="font-bold text-xl">Join the conversation!</h3> 34 - <div class="flex items-center gap-2"> 35 + <div v-if="post" class="flex items-center gap-6"> 35 36 <p class="text-gray-500 text-sm" title="Replies"> 36 37 <Icon name="ri:reply-line" class="-mb-[2px] mr-1" /> 37 38 {{post.post.replyCount}} 38 39 </p> 39 40 <p class="text-gray-500 text-sm" title="Likes"> 40 41 <Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" /> 41 - <span> 42 - {{post.post.likeCount}} 43 - </span> 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}} 44 47 </p> 45 48 <p class="text-gray-500 text-sm" title="Bookmarks"> 46 49 <Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" /> ··· 49 52 </div> 50 53 </div> 51 54 52 - <p class="text-gray-600 text-md mb-6"> 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"> 53 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. 54 63 </p> 55 64 56 - <div v-if="err"> 57 - <div>{{ err }}</div> 58 - </div> 59 65 60 66 <div v-if="post"> 61 67 <div v-if="post.post.replyCount === 0"> ··· 64 70 65 71 <BskyPost 66 72 v-else 67 - v-for="reply in post.replies" 73 + v-for="reply in post.replies?.filter(reply => reply.$type === 'app.bsky.feed.defs#threadViewPost')" 68 74 :key="reply.post.cid" 69 75 :post="reply" 70 76 :depth="0"
+13 -1
app/util/atproto.ts
··· 5 5 import config from "@/../blog.config"; 6 6 7 7 const handler = simpleFetchHandler({ 8 + // Simply hit up the Bluesky API 8 9 service: "https://public.api.bsky.app" 9 10 }); 10 11 const rpc = new Client({ handler }); ··· 14 15 | AppBskyFeedDefs.BlockedPost 15 16 | AppBskyFeedDefs.NotFoundPost; 16 17 18 + /** 19 + * Fetch the first 10 replies to a post 20 + * @param cid 21 + * @returns 22 + */ 17 23 export async function getBskyReplies(cid: string) { 18 24 // uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx 19 25 const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`; ··· 21 27 const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", { 22 28 params: { 23 29 uri, 24 - depth: 10 30 + depth: 6 // default 25 31 } 26 32 }); 27 33 28 34 if (!ok) { 35 + // Handle fetch errors as 'not found'. Could be cleaner, but works just fine. 29 36 console.error("Error fetching thread:", data.error); 30 37 return { $type: "app.bsky.feed.defs#notFoundPost" }; 31 38 } ··· 37 44 return { $type: "app.bsky.feed.defs#notFoundPost" }; 38 45 } 39 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 + */ 40 52 export function extractPostId(uri: ResourceUri) { 41 53 if (uri.includes("app.bsky.feed.post")) { 42 54 const parts = uri.split("/");
+1
package.json
··· 15 15 "@atcute/bluesky": "^3.2.10", 16 16 "@atcute/client": "^4.0.5", 17 17 "@atcute/lexicons": "^1.2.4", 18 + "@iconify-json/bx": "^1.2.2", 18 19 "@nuxt/content": "^3.8.0", 19 20 "@nuxt/icon": "1.11.0", 20 21 "@nuxtjs/tailwindcss": "^6.14.0",
+10
pnpm-lock.yaml
··· 17 17 '@atcute/lexicons': 18 18 specifier: ^1.2.4 19 19 version: 1.2.4 20 + '@iconify-json/bx': 21 + specifier: ^1.2.2 22 + version: 1.2.2 20 23 '@nuxt/content': 21 24 specifier: ^3.8.0 22 25 version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1) ··· 458 461 459 462 '@iconify-json/ant-design@1.2.5': 460 463 resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==} 464 + 465 + '@iconify-json/bx@1.2.2': 466 + resolution: {integrity: sha512-hZVx6LMEkYckScdRdUuQWcmv8Lm2au6Cnf799TLoR6YgiAfFvaJ4M5ElwcnExvCu8ntsS7jW89r0W5LwBAfZXQ==} 461 467 462 468 '@iconify-json/ri@1.2.6': 463 469 resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==} ··· 5180 5186 optional: true 5181 5187 5182 5188 '@iconify-json/ant-design@1.2.5': 5189 + dependencies: 5190 + '@iconify/types': 2.0.0 5191 + 5192 + '@iconify-json/bx@1.2.2': 5183 5193 dependencies: 5184 5194 '@iconify/types': 2.0.0 5185 5195