Add a bsky post cid to have display comments directly in the blog post
+5
-4
.zed/settings.json
+5
-4
.zed/settings.json
···
2
2
"language_servers": ["deno", "..."],
3
3
"languages": {
4
4
"Vue.js": {
5
-
"language_servers": ["deno", "..."]
5
+
"formatter": { "language_server": { "name": "biome" } },
6
+
"language_servers": ["!deno", "biome", "..."]
6
7
},
7
8
8
9
"JavaScript": {
9
10
"formatter": { "language_server": { "name": "biome" } },
10
-
"language_servers": ["deno", "..."]
11
+
"language_servers": ["!deno", "..."]
11
12
},
12
13
13
14
"TypeScript": {
14
15
"formatter": { "language_server": { "name": "biome" } },
15
-
"language_servers": ["deno", "..."]
16
+
"language_servers": ["!deno", "..."]
16
17
},
17
18
18
19
"TSX": {
19
20
"formatter": { "language_server": { "name": "biome" } },
20
-
"language_servers": ["deno", "..."]
21
+
"language_servers": ["!deno", "..."]
21
22
},
22
23
"JSON": { "formatter": { "language_server": { "name": "biome" } } },
23
24
"JSONC": { "formatter": { "language_server": { "name": "biome" } } },
+118
app/components/BskyComments.vue
+118
app/components/BskyComments.vue
···
1
+
<script setup lang="ts">
2
+
import { getBskyReplies, type ReplyThread } from "~/util/atproto";
3
+
4
+
const props = defineProps({
5
+
cid: {
6
+
type: String,
7
+
required: true
8
+
}
9
+
});
10
+
const { cid } = toRefs(props);
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";
18
+
}
19
+
20
+
if (data.value.$type === "app.bsky.feed.defs#notFoundPost") {
21
+
err.value = "Post not found";
22
+
}
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
+
30
+
<template>
31
+
<div class="md:w-[80%] mx-auto mt-16">
32
+
<div class="flex items-baseline gap-4">
33
+
<h3 class="font-bold text-xl">Join the conversation!</h3>
34
+
<p class="text-gray-500 text-sm" title="Replies">
35
+
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
36
+
{{post.post.replyCount}}
37
+
</p>
38
+
<p class="text-gray-500 text-sm" title="Likes">
39
+
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
40
+
<span>
41
+
{{post.post.likeCount}}
42
+
</span>
43
+
</p>
44
+
<p class="text-gray-500 text-sm" title="Bookmarks">
45
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
46
+
{{post.post.bookmarkCount}}
47
+
</p>
48
+
</div>
49
+
50
+
<p class="text-gray-600 text-md mb-6">
51
+
<a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion.
52
+
</p>
53
+
54
+
<div v-if="err">
55
+
<div>{{ err }}</div>
56
+
</div>
57
+
58
+
<div v-if="post">
59
+
<div v-if="post.post.replyCount === 0">
60
+
<div>No replies yet!</div>
61
+
</div>
62
+
63
+
<div v-else v-for="reply in post.replies" class="mt-6">
64
+
<BskyPost :post="reply" :depth="0" />
65
+
66
+
<!-- <a :href="`https://bsky.app/profile/${reply.post.author.handle}`" class="flex items-center gap-2 text-blue-500 hover:underline w-fit">
67
+
<img :src="reply.post.author.avatar" :alt="reply.post.author.displayName" class="size-8 rounded-full" />
68
+
<span>
69
+
{{ reply.post.author.displayName }}
70
+
</span>
71
+
</a>
72
+
<div class="ml-10">{{ reply.post.record.text }}</div>
73
+
<div class="flex items-baseline gap-4 ml-10 mt-2">
74
+
<p class="text-gray-500 text-sm" title="Replies">
75
+
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
76
+
{{reply.post.replyCount}}
77
+
</p>
78
+
<p class="text-gray-500 text-sm" title="Likes">
79
+
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
80
+
<span>
81
+
{{reply.post.likeCount}}
82
+
</span>
83
+
</p>
84
+
<p class="text-gray-500 text-sm" title="Bookmarks">
85
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
86
+
{{reply.post.bookmarkCount}}
87
+
</p>
88
+
</div>
89
+
90
+
<div v-for="rep in reply.replies" class="mt-6 ml-10">
91
+
<a :href="`https://bsky.app/profile/${rep.post.author.handle}`" class="flex items-center gap-2 text-blue-500 hover:underline w-fit">
92
+
<img :src="rep.post.author.avatar" :alt="rep.post.author.displayName" class="size-8 rounded-full" />
93
+
<span>
94
+
{{ rep.post.author.displayName }}
95
+
</span>
96
+
</a>
97
+
<div class="ml-10">{{ rep.post.record.text }}</div>
98
+
<div class="flex items-baseline gap-4 ml-10 mt-2">
99
+
<p class="text-gray-500 text-sm" title="Replies">
100
+
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
101
+
{{rep.post.replyCount}}
102
+
</p>
103
+
<p class="text-gray-500 text-sm" title="Likes">
104
+
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
105
+
<span>
106
+
{{rep.post.likeCount}}
107
+
</span>
108
+
</p>
109
+
<p class="text-gray-500 text-sm" title="Bookmarks">
110
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
111
+
{{rep.post.bookmarkCount}}
112
+
</p>
113
+
</div>
114
+
</div> -->
115
+
</div>
116
+
</div>
117
+
</div>
118
+
</template>
+52
app/components/BskyPost.vue
+52
app/components/BskyPost.vue
···
1
+
<script setup lang="ts">
2
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
3
+
import { extractPostId } from "~/util/atproto";
4
+
5
+
const props = defineProps<{
6
+
post: AppBskyFeedDefs.ThreadViewPost;
7
+
depth: number;
8
+
}>();
9
+
const { post, depth } = toRefs(props);
10
+
11
+
const MAX_DEPTH = 2; // Max number of replies to a reply
12
+
</script>
13
+
14
+
<template>
15
+
<div v-if="post && depth <= MAX_DEPTH" :class="['mt-6', depth > 0 ? 'ml-10' : '']">
16
+
<a :href="`https://bsky.app/profile/${post.post.author.handle}`" class="flex items-center gap-2 text-blue-500 hover:underline w-fit">
17
+
<img :src="post.post.author.avatar" :alt="post.post.author.displayName" class="size-8 rounded-full" />
18
+
<span>
19
+
{{ post.post.author.displayName }}
20
+
</span>
21
+
</a>
22
+
<div class="ml-10">{{ post.post.record.text }}</div>
23
+
<div class="flex items-baseline gap-4 ml-10 mt-2">
24
+
<p class="text-gray-500 text-sm" title="Replies">
25
+
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
26
+
{{post.post.replyCount}}
27
+
</p>
28
+
<p class="text-gray-500 text-sm" title="Likes">
29
+
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
30
+
<span>
31
+
{{post.post.likeCount}}
32
+
</span>
33
+
</p>
34
+
<p class="text-gray-500 text-sm" title="Bookmarks">
35
+
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
36
+
{{post.post.bookmarkCount}}
37
+
</p>
38
+
</div>
39
+
40
+
<div v-if="post.replies">
41
+
<div v-if="depth === MAX_DEPTH">
42
+
<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 ml-10">
43
+
View more replies on Bluesky
44
+
<Icon name='ri:arrow-drop-right-line' />
45
+
</a>
46
+
</div>
47
+
<div v-for="reply in post.replies">
48
+
<BskyPost v-if="reply.$type === 'app.bsky.feed.defs#threadViewPost'" :post="reply" :depth="depth + 1" />
49
+
</div>
50
+
</div>
51
+
</div>
52
+
</template>
+8
app/pages/posts/[...slug].vue
+8
app/pages/posts/[...slug].vue
···
91
91
92
92
<ShareActions :title="post.title" :description="post.description" :author="post.authors[0]?.name" />
93
93
94
+
<Suspense>
95
+
<BskyComments v-if="post.bskyCid" :cid="post.bskyCid" />
96
+
97
+
<template #fallback>
98
+
<h1 class="text-xl font-bold text-stone-600">Loading comments...</h1>
99
+
</template>
100
+
</Suspense>
101
+
94
102
</article>
95
103
96
104
<div v-else class="flex items-center justify-center">
+46
app/util/atproto.ts
+46
app/util/atproto.ts
···
1
+
import { Client, simpleFetchHandler } from "@atcute/client";
2
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
3
+
import type { ResourceUri } from "@atcute/lexicons";
4
+
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 });
11
+
12
+
export type ReplyThread =
13
+
| AppBskyFeedDefs.ThreadViewPost
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}`;
20
+
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
+
}
32
+
33
+
if (ok) {
34
+
return data.thread;
35
+
}
36
+
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("/");
43
+
return parts.at(-1);
44
+
}
45
+
return "";
46
+
}
+1
blog.config.ts
+1
blog.config.ts
+1
content/posts/blog-template.md
+1
content/posts/blog-template.md
+1
content/posts/embracing-atproto-pt-1-hosting-pds.md
+1
content/posts/embracing-atproto-pt-1-hosting-pds.md
+1
content/posts/embracing-atproto-pt-2-tangled-knot.md
+1
content/posts/embracing-atproto-pt-2-tangled-knot.md
+1
content/posts/extending-openauth.md
+1
content/posts/extending-openauth.md
+2
-1
content.config.ts
+2
-1
content.config.ts
+2
-2
deno.jsonc
+2
-2
deno.jsonc
+2
globals.ts
+2
globals.ts
···
10
10
sharingProviders: SharingProvider[];
11
11
title: string;
12
12
author: string;
13
+
authorDid: `did:plc:${string}`;
13
14
meta: {
14
15
name: string;
15
16
content: string;
···
36
37
: { bluesky: true, clipboard: true, native: true },
37
38
title: config.title ?? "My Blog",
38
39
author: config.author ?? "finxol",
40
+
authorDid: config.authorDid,
39
41
meta: config.meta ?? [
40
42
{ name: "description", content: "My blog description" }
41
43
],
+4
-1
package.json
+4
-1
package.json
···
2
2
"private": true,
3
3
"name": "nuxt-app",
4
4
"type": "module",
5
-
"packageManager": "pnpm@10.20.0",
5
+
"packageManager": "pnpm@10.23.0",
6
6
"scripts": {
7
7
"build": "nuxt build",
8
8
"dev": "nuxt dev",
···
12
12
"format": "biome format --write"
13
13
},
14
14
"dependencies": {
15
+
"@atcute/bluesky": "^3.2.10",
16
+
"@atcute/client": "^4.0.5",
17
+
"@atcute/lexicons": "^1.2.4",
15
18
"@nuxt/content": "^3.8.0",
16
19
"@nuxt/icon": "1.11.0",
17
20
"@nuxtjs/tailwindcss": "^6.14.0",
+59
pnpm-lock.yaml
+59
pnpm-lock.yaml
···
8
8
9
9
.:
10
10
dependencies:
11
+
'@atcute/bluesky':
12
+
specifier: ^3.2.10
13
+
version: 3.2.10
14
+
'@atcute/client':
15
+
specifier: ^4.0.5
16
+
version: 4.0.5
17
+
'@atcute/lexicons':
18
+
specifier: ^1.2.4
19
+
version: 1.2.4
11
20
'@nuxt/content':
12
21
specifier: ^3.8.0
13
22
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
70
79
'@apidevtools/json-schema-ref-parser@11.9.3':
71
80
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
72
81
engines: {node: '>= 16'}
82
+
83
+
'@atcute/atproto@3.1.9':
84
+
resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==}
85
+
86
+
'@atcute/bluesky@3.2.10':
87
+
resolution: {integrity: sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg==}
88
+
89
+
'@atcute/client@4.0.5':
90
+
resolution: {integrity: sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA==}
91
+
92
+
'@atcute/identity@1.1.3':
93
+
resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==}
94
+
95
+
'@atcute/lexicons@1.2.4':
96
+
resolution: {integrity: sha512-s6fl/SVjQMv7jiitLCcZ434X+VrTsJt7Fl9iJg8WXHJIELRz/U0sNUoP++oWd7bvPy1Vcd2Wnm+YtTm/Zn7AIQ==}
73
97
74
98
'@babel/code-frame@7.27.1':
75
99
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
···
187
211
'@babel/types@7.28.5':
188
212
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
189
213
engines: {node: '>=6.9.0'}
214
+
215
+
'@badrap/valita@0.4.6':
216
+
resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==}
217
+
engines: {node: '>= 18'}
190
218
191
219
'@biomejs/biome@1.9.4':
192
220
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
···
2304
2332
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
2305
2333
engines: {node: '>=12'}
2306
2334
2335
+
esm-env@1.2.2:
2336
+
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
2337
+
2307
2338
estree-walker@2.0.2:
2308
2339
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
2309
2340
···
4802
4833
'@types/json-schema': 7.0.15
4803
4834
js-yaml: 4.1.0
4804
4835
4836
+
'@atcute/atproto@3.1.9':
4837
+
dependencies:
4838
+
'@atcute/lexicons': 1.2.4
4839
+
4840
+
'@atcute/bluesky@3.2.10':
4841
+
dependencies:
4842
+
'@atcute/atproto': 3.1.9
4843
+
'@atcute/lexicons': 1.2.4
4844
+
4845
+
'@atcute/client@4.0.5':
4846
+
dependencies:
4847
+
'@atcute/identity': 1.1.3
4848
+
'@atcute/lexicons': 1.2.4
4849
+
4850
+
'@atcute/identity@1.1.3':
4851
+
dependencies:
4852
+
'@atcute/lexicons': 1.2.4
4853
+
'@badrap/valita': 0.4.6
4854
+
4855
+
'@atcute/lexicons@1.2.4':
4856
+
dependencies:
4857
+
'@standard-schema/spec': 1.0.0
4858
+
esm-env: 1.2.2
4859
+
4805
4860
'@babel/code-frame@7.27.1':
4806
4861
dependencies:
4807
4862
'@babel/helper-validator-identifier': 7.28.5
···
4968
5023
dependencies:
4969
5024
'@babel/helper-string-parser': 7.27.1
4970
5025
'@babel/helper-validator-identifier': 7.28.5
5026
+
5027
+
'@badrap/valita@0.4.6': {}
4971
5028
4972
5029
'@biomejs/biome@1.9.4':
4973
5030
optionalDependencies:
···
7258
7315
escape-string-regexp@4.0.0: {}
7259
7316
7260
7317
escape-string-regexp@5.0.0: {}
7318
+
7319
+
esm-env@1.2.2: {}
7261
7320
7262
7321
estree-walker@2.0.2: {}
7263
7322
History
5 rounds
1 comment
10 commits
expand
collapse
chore: add bsky post cid to content schema
chore: add deps
feat: first draft at bsky replies
fix: add loading state
feat: improve appearance
feat: add icons and bsky stats
feat: add bsky post CIDs to more blog posts
feat: show reply replies, max depth 2
fix: properly align elements
fix: max depth truncate + post elements alignment
expand 0 comments
pull request successfully merged
8 commits
expand
collapse
chore: add bsky post cid to content schema
chore: add deps
feat: first draft at bsky replies
fix: add loading state
feat: improve appearance
feat: add icons and bsky stats
feat: add bsky post CIDs to more blog posts
feat: show reply replies, max depth 2
Still needs some minor adjustments