Add Bluesky comments to blog posts #1

merged
opened by finxol.io targeting main from feat/add-bsky-comments

Add a bsky post cid to have display comments directly in the blog post

+5 -4
.zed/settings.json
···
"language_servers": ["deno", "..."],
"languages": {
"Vue.js": {
-
"language_servers": ["deno", "..."]
},
"JavaScript": {
"formatter": { "language_server": { "name": "biome" } },
-
"language_servers": ["deno", "..."]
},
"TypeScript": {
"formatter": { "language_server": { "name": "biome" } },
-
"language_servers": ["deno", "..."]
},
"TSX": {
"formatter": { "language_server": { "name": "biome" } },
-
"language_servers": ["deno", "..."]
},
"JSON": { "formatter": { "language_server": { "name": "biome" } } },
"JSONC": { "formatter": { "language_server": { "name": "biome" } } },
···
"language_servers": ["deno", "..."],
"languages": {
"Vue.js": {
+
"formatter": { "language_server": { "name": "biome" } },
+
"language_servers": ["!deno", "biome", "..."]
},
"JavaScript": {
"formatter": { "language_server": { "name": "biome" } },
+
"language_servers": ["!deno", "..."]
},
"TypeScript": {
"formatter": { "language_server": { "name": "biome" } },
+
"language_servers": ["!deno", "..."]
},
"TSX": {
"formatter": { "language_server": { "name": "biome" } },
+
"language_servers": ["!deno", "..."]
},
"JSON": { "formatter": { "language_server": { "name": "biome" } } },
"JSONC": { "formatter": { "language_server": { "name": "biome" } } },
+74
app/components/BskyComments.vue
···
···
+
<script setup lang="ts">
+
import { getBskyReplies, type ReplyThread } from "~/util/atproto";
+
+
const props = defineProps({
+
cid: {
+
type: String,
+
required: true
+
}
+
});
+
const { cid } = toRefs(props);
+
+
const data = ref(await getBskyReplies(cid.value));
+
const err = ref("");
+
const post = ref();
+
+
if (data.value.$type === "app.bsky.feed.defs#blockedPost") {
+
err.value = "Post is blocked";
+
}
+
+
if (data.value.$type === "app.bsky.feed.defs#notFoundPost") {
+
err.value = "Post not found";
+
}
+
+
if (data.value.$type === "app.bsky.feed.defs#threadViewPost") {
+
console.log(data.value);
+
post.value = data.value;
+
}
+
</script>
+
+
<template>
+
<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">
+
<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>
+
</p>
+
<p class="text-gray-500 text-sm" title="Bookmarks">
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
+
{{post.post.bookmarkCount}}
+
</p>
+
</div>
+
</div>
+
+
<p class="text-gray-600 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">
+
<div>No replies yet!</div>
+
</div>
+
+
<BskyPost
+
v-else
+
v-for="reply in post.replies"
+
:key="reply.post.cid"
+
:post="reply"
+
:depth="0"
+
/>
+
</div>
+
</div>
+
</template>
+79
app/components/BskyPost.vue
···
···
+
<script setup lang="ts">
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
+
import { extractPostId } from "~/util/atproto";
+
+
const props = defineProps<{
+
post: AppBskyFeedDefs.ThreadViewPost;
+
depth: number;
+
}>();
+
const { post, depth } = toRefs(props);
+
+
const MAX_DEPTH = 3; // Max number of replies to a reply
+
+
console.log(post);
+
</script>
+
+
<template>
+
<div v-if="post && depth < MAX_DEPTH" class="mt-6 bsky-post">
+
<a :href="`https://bsky.app/profile/${post.post.author.handle}`">
+
<img :src="post.post.author.avatar" :alt="post.post.author.displayName" class="size-8 rounded-full" />
+
</a>
+
<div class="flex flex-col gap-1">
+
<a :href="`https://bsky.app/profile/${post.post.author.handle}`" class="text-md font-bold w-fit">
+
{{ post.post.author.displayName }}
+
</a>
+
<div>
+
{{ post.post.record.text }}
+
</div>
+
<div class="flex items-baseline gap-4 mt-2">
+
<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>
+
</p>
+
<p class="text-gray-500 text-sm" title="Bookmarks">
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
+
{{post.post.bookmarkCount}}
+
</p>
+
</div>
+
+
<div v-if="post.replies && post.replies.length > 0">
+
<div v-if="depth+1 === MAX_DEPTH">
+
<a :href="`https://bsky.app/profile/${post.post.author.handle}/post/${extractPostId(post.post.uri)}`" class="text-gray-500 text-sm flex items-center gap-2 mt-4">
+
View more replies on Bluesky
+
<Icon name='ri:arrow-drop-right-line' />
+
</a>
+
</div>
+
<div v-else v-for="reply in post.replies">
+
<BskyPost v-if="reply.$type === 'app.bsky.feed.defs#threadViewPost'" :post="reply" :depth="depth + 1" />
+
</div>
+
</div>
+
</div>
+
</div>
+
</template>
+
+
<style scoped>
+
.bsky-post {
+
@apply gap-2 md:gap-4;
+
+
display: grid;
+
grid-template-columns: auto 1fr;
+
grid-template-areas:
+
"avatar content";
+
align-items: start;
+
+
& > a:has(> img) {
+
grid-area: avatar;
+
}
+
+
& > div {
+
grid-area: content;
+
}
+
+
}
+
</style>
+1 -1
app/components/ShareActions.vue
···
<template>
<aside
-
class="print:hidden flex flex-row justify-center items-center gap-4 mt-24 md:w-[80%] mx-auto"
>
<div class="h-1 bg-stone-200 dark:bg-stone-700 grow" />
<div class="whitespace-nowrap px-4 flex flex-row items-center gap-4">
···
<template>
<aside
+
class="print:hidden flex flex-row justify-center items-center gap-4 mt-16 md:w-[80%] mx-auto"
>
<div class="h-1 bg-stone-200 dark:bg-stone-700 grow" />
<div class="whitespace-nowrap px-4 flex flex-row items-center gap-4">
+8
app/pages/posts/[...slug].vue
···
<ShareActions :title="post.title" :description="post.description" :author="post.authors[0]?.name" />
</article>
<div v-else class="flex items-center justify-center">
···
<ShareActions :title="post.title" :description="post.description" :author="post.authors[0]?.name" />
+
<Suspense>
+
<BskyComments v-if="post.bskyCid" :cid="post.bskyCid" />
+
+
<template #fallback>
+
<h1 class="md:w-[80%] mx-auto mt-16 text-xl font-bold text-stone-600">Loading comments...</h1>
+
</template>
+
</Suspense>
+
</article>
<div v-else class="flex items-center justify-center">
+46
app/util/atproto.ts
···
···
+
import { Client, simpleFetchHandler } from "@atcute/client";
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
+
import type { ResourceUri } from "@atcute/lexicons";
+
+
import config from "@/../blog.config";
+
+
const handler = simpleFetchHandler({
+
service: "https://public.api.bsky.app"
+
});
+
const rpc = new Client({ handler });
+
+
export type ReplyThread =
+
| AppBskyFeedDefs.ThreadViewPost
+
| AppBskyFeedDefs.BlockedPost
+
| AppBskyFeedDefs.NotFoundPost;
+
+
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
+
}
+
});
+
+
if (!ok) {
+
console.error("Error fetching thread:", data.error);
+
return { $type: "app.bsky.feed.defs#notFoundPost" };
+
}
+
+
if (ok) {
+
return data.thread;
+
}
+
+
return { $type: "app.bsky.feed.defs#notFoundPost" };
+
}
+
+
export function extractPostId(uri: ResourceUri) {
+
if (uri.includes("app.bsky.feed.post")) {
+
const parts = uri.split("/");
+
return parts.at(-1);
+
}
+
return "";
+
}
+1
blog.config.ts
···
site: "https://finxol.io",
title: "finxol's blog",
author: "finxol",
meta: [
{
name: "description",
···
site: "https://finxol.io",
title: "finxol's blog",
author: "finxol",
+
authorDid: "did:plc:hpmpe3pzpdtxbmvhlwrevhju",
meta: [
{
name: "description",
+1
content/posts/blog-template.md
···
tags:
- open source
published: true
---
Are you enjoying this blog?
···
tags:
- open source
published: true
+
bskyCid: 3lqatrbwkjk2a
---
Are you enjoying this blog?
+1
content/posts/embracing-atproto-pt-1-hosting-pds.md
···
- atproto
- self-hosting
published: true
---
The [Atmosphere Protocol](https://atproto.com/), or atproto for short, is the protocol behind the Bluesky social network.
···
- atproto
- self-hosting
published: true
+
bskyCid: 3lxwbhjz4l22i
---
The [Atmosphere Protocol](https://atproto.com/), or atproto for short, is the protocol behind the Bluesky social network.
+1
content/posts/embracing-atproto-pt-2-tangled-knot.md
···
- atproto
- self-hosting
published: true
---
I recently set up my own atproto PDS, for use with Bluesky and all other atproto apps.
···
- atproto
- self-hosting
published: true
+
bskyCid: 3lyzhrumfu22n
---
I recently set up my own atproto PDS, for use with Bluesky and all other atproto apps.
+1
content/posts/extending-openauth.md
···
- auth
- open source
published: true
---
I'm currently building [Karr](https://karr.mobi/?utm_source=finxol-blog&utm_content=openauth-post), an open-source federated carpool platform—it's still very early days, not much there yet.
···
- auth
- open source
published: true
+
bskyCid: 3llnnj4z2xs2u
---
I'm currently building [Karr](https://karr.mobi/?utm_source=finxol-blog&utm_content=openauth-post), an open-source federated carpool platform—it's still very early days, not much there yet.
+2 -1
content.config.ts
···
})
),
tags: z.array(z.string()),
-
published: z.boolean().optional()
})
})
)
···
})
),
tags: z.array(z.string()),
+
published: z.boolean().optional(),
+
bskyCid: z.string().optional()
})
})
)
+2 -2
deno.jsonc
···
{
"deploy": {
-
"org": "finxol",
-
"app": "blogging"
}
}
···
{
"deploy": {
+
"org": "finxol",
+
"app": "blogging"
}
}
+2
globals.ts
···
sharingProviders: SharingProvider[];
title: string;
author: string;
meta: {
name: string;
content: string;
···
: { bluesky: true, clipboard: true, native: true },
title: config.title ?? "My Blog",
author: config.author ?? "finxol",
meta: config.meta ?? [
{ name: "description", content: "My blog description" }
],
···
sharingProviders: SharingProvider[];
title: string;
author: string;
+
authorDid: `did:plc:${string}`;
meta: {
name: string;
content: string;
···
: { bluesky: true, clipboard: true, native: true },
title: config.title ?? "My Blog",
author: config.author ?? "finxol",
+
authorDid: config.authorDid,
meta: config.meta ?? [
{ name: "description", content: "My blog description" }
],
+4 -1
package.json
···
"private": true,
"name": "nuxt-app",
"type": "module",
-
"packageManager": "pnpm@10.20.0",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
···
"format": "biome format --write"
},
"dependencies": {
"@nuxt/content": "^3.8.0",
"@nuxt/icon": "1.11.0",
"@nuxtjs/tailwindcss": "^6.14.0",
···
"private": true,
"name": "nuxt-app",
"type": "module",
+
"packageManager": "pnpm@10.23.0",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
···
"format": "biome format --write"
},
"dependencies": {
+
"@atcute/bluesky": "^3.2.10",
+
"@atcute/client": "^4.0.5",
+
"@atcute/lexicons": "^1.2.4",
"@nuxt/content": "^3.8.0",
"@nuxt/icon": "1.11.0",
"@nuxtjs/tailwindcss": "^6.14.0",
+59
pnpm-lock.yaml
···
.:
dependencies:
'@nuxt/content':
specifier: ^3.8.0
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
···
'@babel/types@7.28.5':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
···
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
···
'@types/json-schema': 7.0.15
js-yaml: 4.1.0
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
···
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@1.9.4':
optionalDependencies:
···
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
estree-walker@2.0.2: {}
···
.:
dependencies:
+
'@atcute/bluesky':
+
specifier: ^3.2.10
+
version: 3.2.10
+
'@atcute/client':
+
specifier: ^4.0.5
+
version: 4.0.5
+
'@atcute/lexicons':
+
specifier: ^1.2.4
+
version: 1.2.4
'@nuxt/content':
specifier: ^3.8.0
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
+
+
'@atcute/atproto@3.1.9':
+
resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==}
+
+
'@atcute/bluesky@3.2.10':
+
resolution: {integrity: sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg==}
+
+
'@atcute/client@4.0.5':
+
resolution: {integrity: sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA==}
+
+
'@atcute/identity@1.1.3':
+
resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==}
+
+
'@atcute/lexicons@1.2.4':
+
resolution: {integrity: sha512-s6fl/SVjQMv7jiitLCcZ434X+VrTsJt7Fl9iJg8WXHJIELRz/U0sNUoP++oWd7bvPy1Vcd2Wnm+YtTm/Zn7AIQ==}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
···
'@babel/types@7.28.5':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+
+
'@badrap/valita@0.4.6':
+
resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==}
+
engines: {node: '>= 18'}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
···
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
+
esm-env@1.2.2:
+
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
+
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
···
'@types/json-schema': 7.0.15
js-yaml: 4.1.0
+
'@atcute/atproto@3.1.9':
+
dependencies:
+
'@atcute/lexicons': 1.2.4
+
+
'@atcute/bluesky@3.2.10':
+
dependencies:
+
'@atcute/atproto': 3.1.9
+
'@atcute/lexicons': 1.2.4
+
+
'@atcute/client@4.0.5':
+
dependencies:
+
'@atcute/identity': 1.1.3
+
'@atcute/lexicons': 1.2.4
+
+
'@atcute/identity@1.1.3':
+
dependencies:
+
'@atcute/lexicons': 1.2.4
+
'@badrap/valita': 0.4.6
+
+
'@atcute/lexicons@1.2.4':
+
dependencies:
+
'@standard-schema/spec': 1.0.0
+
esm-env: 1.2.2
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
···
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+
+
'@badrap/valita@0.4.6': {}
'@biomejs/biome@1.9.4':
optionalDependencies:
···
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
+
+
esm-env@1.2.2: {}
estree-walker@2.0.2: {}
+4 -1
tsconfig.json
···
{
// https://nuxt.com/docs/guide/concepts/typescript
-
"extends": "./.nuxt/tsconfig.json"
}
···
{
// https://nuxt.com/docs/guide/concepts/typescript
+
"extends": "./.nuxt/tsconfig.json",
+
"compilerOptions": {
+
"types": ["@atcute/bluesky"]
+
}
}