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
···
-
# Nuxt Minimal Starter
+
# finxol blog
+
+
This is the repo for finxol's blog.
+
+
All posts are in `content/`.
+
Configuration is in `blog.config.ts`.
+
+
## Technology stack
+
+
- Nuxt v4
+
- Nuxt Content
+
- TailwindCSS
+
- Deno (Deploy EA)
+
+
## Bluesky integration
+
+
Tracking PR: [#1](https://tangled.org/finxol.io/blog/pulls/1/)
+
+
Comments on this blog are directly integrated with Bluesky, the atproto-based micro-blogging social network.
+
+
This integration relies on the `@atcute/` library collection for interaction with Bluesky/atproto.
+
+
The idea was originally inspired from [natalie's blog](https://natalie.sh/posts/bluesky-comments/).
+
Although I ended up using mostly the same tools and strategies, I didn't follow her post to build it here.
+
+
### How it works in practice
+
+
The author of the blog writes a post and publishes it.
+
They can then post about it on Bluesky, find the post id, and add it to the `bskyCid` field in the post frontmatter.
+
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.
+
+
### How it works technically
+
+
The [AT Protocol](https://atproto.com/) is an open internet protocol for social applications.
+
All the data is decentralised and public ([for now](https://pfrazee.leaflet.pub/3lzhui2zbxk2b)).
+
This openness allows us to reuse and build things based on that data very easily, in a built-in way, without hacky workarounds.
+
+
This integration works in several parts:
+
+
#### `app/util/atproto.ts`
+
+
Contains the utility functions for retrieving all replies to a post, and extracting a post id from an atproto uri.
+
+
Uses `@atcute/client` to fetch using the `app.bsky.feed.getPostThread` RPC on the Bluesky public API.
+
Everything is strongly typed, although fetch errors are handled as `post not found` to make handling simpler in the Vue component.
+
+
#### `blog.config.ts`
+
+
The author DID is set blog-wide in the config file through `authorDid`, as it is primarily intended as a personal blog.
+
If need be, I can always move the DID parameter to the post frontmatter, allowing for guest authors or secondary accounts too.
+
+
#### `content.config.ts`
+
+
Since the Bluesky post CID needs to be set for each blog post independently,
+
I added a `bskyCid` field in the post frontmatter.
+
+
#### `app/components/BskyComments.vue`
+
+
This is the core component to display the replies.
+
+
The component simply fetches the replies by calling `getBskyReplies`, passing in the post CID passed as prop,
+
and displays the content using the `BskyPost` component.
+
+
The reply, like, repost, and bookmark counts of the original Bluesky post are also displayed.
+
-
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
+
#### `app/components/BskyPost.vue`
-
## Setup
+
This component displays the post author, their avatar, the post content, and its stats beautifully.
-
Make sure to install dependencies:
+
Replies to replies are indented accordingly to visually thread replies together, using `BskyPost` recursively,
+
with a `MAX_DEPTH` to set a limit to the number of replies to show.
-
```bash
-
# pnpm
-
pnpm install
-
```
-
## Development Server
+
#### `app/pages/posts/[...slug].vue`
-
Start the development server on `http://localhost:3000`:
+
The actual post page only had some minor adjustments to integrate the `BskyComments` component,
+
using a `Suspense` boundary with a fallback to avoid blocking the rendering of the actual content.
-
```bash
-
# pnpm
-
pnpm dev
-
```
+
#### Others
-
## Production
+
Some other files saw modifications, to adapt to this integration addition, allowing for visual consistency.
-
Build the application for production:
+
### Advantages of the approach
-
```bash
-
# pnpm
-
pnpm build
-
```
+
Since this blog is built with Nuxt, everything is SSRed.
+
This makes the Bluesky integration a wonderful progressive enhancement.
+
The comments will still display and show up as intended if the client has Javascript disabled,
+
without blocking rendering of the actual content through the use of a `Suspense` boundary.
-
Locally preview production build:
+
Using Bluesky as a comment platform allows me to integrate conversations about my posts directly alongside them,
+
without bearing the load of moderation and user accounts.
-
```bash
-
# pnpm
-
pnpm preview
-
```
+
### Limitations
-
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+
As briefly mentioned, fetch errors are normalised to `#notFoundPost`,
+
this could be refined for better reporting in the UI.
+
+
This integration also only handles plain text content.
+
All embedded and rich media is effectively ignored for now.
+
+
## Install locally
+
+
```sh
+
# Install dependencies
+
pnpm i
+
+
# Run the development server
+
deno task dev
+
+
# Build for production
+
deno task build
+
+
# Deploy to Deno Deploy EA. Add `--prod` to deploy to production
+
deno deploy
+
```
+18 -12
app/components/BskyComments.vue
···
<script setup lang="ts">
-
import { getBskyReplies, type ReplyThread } from "~/util/atproto";
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
+
import { getBskyReplies } from "~/util/atproto";
const props = defineProps({
cid: {
···
const data = ref(await getBskyReplies(cid.value));
const err = ref("");
-
const post = ref();
+
const post = ref<AppBskyFeedDefs.ThreadViewPost>();
if (data.value.$type === "app.bsky.feed.defs#blockedPost") {
err.value = "Post is blocked";
···
if (data.value.$type === "app.bsky.feed.defs#threadViewPost") {
console.log(data.value);
-
post.value = data.value;
+
post.value = data.value as AppBskyFeedDefs.ThreadViewPost;
}
</script>
···
<div class="md:w-[80%] mx-auto mt-16">
<div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0">
<h3 class="font-bold text-xl">Join the conversation!</h3>
-
<div class="flex items-center gap-2">
+
<div v-if="post" class="flex items-center gap-6">
<p class="text-gray-500 text-sm" title="Replies">
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
{{post.post.replyCount}}
</p>
<p class="text-gray-500 text-sm" title="Likes">
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
-
<span>
-
{{post.post.likeCount}}
-
</span>
+
{{post.post.likeCount}}
+
</p>
+
<p class="text-gray-500 text-sm" title="Reposts">
+
<Icon name="bx:repost" class="-mb-[2px] mr-1" />
+
{{post.post.repostCount}}
</p>
<p class="text-gray-500 text-sm" title="Bookmarks">
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
···
</div>
</div>
-
<p class="text-gray-600 text-md mb-6">
+
<div v-if="err">
+
<p class="mt-2 text-gray-700 dark:text-gray-500">
+
{{ err }}
+
</p>
+
</div>
+
+
<p v-if="post" class="text-gray-600 dark:text-gray-500 text-md mb-6">
<a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion.
</p>
-
<div v-if="err">
-
<div>{{ err }}</div>
-
</div>
<div v-if="post">
<div v-if="post.post.replyCount === 0">
···
<BskyPost
v-else
-
v-for="reply in post.replies"
+
v-for="reply in post.replies?.filter(reply => reply.$type === 'app.bsky.feed.defs#threadViewPost')"
:key="reply.post.cid"
:post="reply"
:depth="0"
+13 -1
app/util/atproto.ts
···
import config from "@/../blog.config";
const handler = simpleFetchHandler({
+
// Simply hit up the Bluesky API
service: "https://public.api.bsky.app"
});
const rpc = new Client({ handler });
···
| AppBskyFeedDefs.BlockedPost
| AppBskyFeedDefs.NotFoundPost;
+
/**
+
* Fetch the first 10 replies to a post
+
* @param cid
+
* @returns
+
*/
export async function getBskyReplies(cid: string) {
// uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`;
···
const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", {
params: {
uri,
-
depth: 10
+
depth: 6 // default
}
});
if (!ok) {
+
// Handle fetch errors as 'not found'. Could be cleaner, but works just fine.
console.error("Error fetching thread:", data.error);
return { $type: "app.bsky.feed.defs#notFoundPost" };
}
···
return { $type: "app.bsky.feed.defs#notFoundPost" };
}
+
/**
+
* Extract post id from an atproto uri
+
* @param uri The atproto uri, such as at://did:plc:user/app.bsky.feed.post/xxxxx`
+
* @returns The post id
+
*/
export function extractPostId(uri: ResourceUri) {
if (uri.includes("app.bsky.feed.post")) {
const parts = uri.split("/");
+1
package.json
···
"@atcute/bluesky": "^3.2.10",
"@atcute/client": "^4.0.5",
"@atcute/lexicons": "^1.2.4",
+
"@iconify-json/bx": "^1.2.2",
"@nuxt/content": "^3.8.0",
"@nuxt/icon": "1.11.0",
"@nuxtjs/tailwindcss": "^6.14.0",
+10
pnpm-lock.yaml
···
'@atcute/lexicons':
specifier: ^1.2.4
version: 1.2.4
+
'@iconify-json/bx':
+
specifier: ^1.2.2
+
version: 1.2.2
'@nuxt/content':
specifier: ^3.8.0
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
'@iconify-json/ant-design@1.2.5':
resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==}
+
+
'@iconify-json/bx@1.2.2':
+
resolution: {integrity: sha512-hZVx6LMEkYckScdRdUuQWcmv8Lm2au6Cnf799TLoR6YgiAfFvaJ4M5ElwcnExvCu8ntsS7jW89r0W5LwBAfZXQ==}
'@iconify-json/ri@1.2.6':
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
···
optional: true
'@iconify-json/ant-design@1.2.5':
+
dependencies:
+
'@iconify/types': 2.0.0
+
+
'@iconify-json/bx@1.2.2':
dependencies:
'@iconify/types': 2.0.0