nuxt-bluesky-comments 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # nuxt-bluesky-comments
2
+
3
+ [![npm version](https://img.shields.io/npm/v/nuxt-bluesky-comments.svg)](https://www.npmjs.com/package/nuxt-bluesky-comments)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ A Nuxt module to display Bluesky post comments on your website. Perfect for adding a comments section to your blog powered by Bluesky discussions.
7
+
8
+ ## Features
9
+
10
+ - πŸ¦‹ Display comments from any public Bluesky post
11
+ - 🎨 Customizable via CSS variables
12
+ - πŸŒ™ Dark mode support
13
+ - 🧡 Smart thread flattening for same-author replies
14
+ - πŸ“Š Engagement stats (likes, reposts, replies)
15
+ - ♾️ Full thread depth support
16
+ - πŸ“± Responsive design
17
+ - πŸ–ΌοΈ Avatar fallback with author initials
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ pnpm add nuxt-bluesky-comments
23
+ ```
24
+
25
+ ```typescript
26
+ // nuxt.config.ts
27
+ export default defineNuxtConfig({
28
+ modules: ['nuxt-bluesky-comments'],
29
+ });
30
+ ```
31
+
32
+ ```vue
33
+ <BlueskyComments url="https://bsky.app/profile/user.bsky.social/post/abc123" />
34
+ ```
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # pnpm
40
+ pnpm add nuxt-bluesky-comments
41
+
42
+ # npm
43
+ npm install nuxt-bluesky-comments
44
+
45
+ # yarn
46
+ yarn add nuxt-bluesky-comments
47
+ ```
48
+
49
+ ## Props
50
+
51
+ | Prop | Type | Default | Description |
52
+ | -------------------------- | --------- | ------- | ------------------------------------------------- |
53
+ | `url` | `string` | - | Bluesky web URL of the post |
54
+ | `uri` | `string` | - | AT Protocol URI (alternative to url) |
55
+ | `limit` | `number` | `5` | Top-level comments shown initially |
56
+ | `flattenSameAuthorThreads` | `boolean` | `true` | Keep consecutive same-author replies at one level |
57
+
58
+ ## Composable
59
+
60
+ For custom implementations:
61
+
62
+ ```vue
63
+ <script setup>
64
+ const { loading, error, comments, stats, postUrl, refresh } = useBlueskyComments(
65
+ 'https://bsky.app/profile/user.bsky.social/post/abc123'
66
+ );
67
+ </script>
68
+ ```
69
+
70
+ **Returns:** `loading`, `error`, `comments`, `stats`, `postUrl`, `refresh()`
71
+
72
+ ## Styling
73
+
74
+ ### CSS Variables
75
+
76
+ Text colors inherit from your page automatically. Configure these variables:
77
+
78
+ | Variable | Description | Light | Dark |
79
+ | --------------- | ----------------------------- | --------- | ----------------------- |
80
+ | `--bsky-bg` | Background (for thread lines) | `#ffffff` | `#0a0a0a` |
81
+ | `--bsky-border` | Border/divider color | `#e5e5e5` | `rgba(255,255,255,0.1)` |
82
+ | `--bsky-link` | Link color | `#2563eb` | `#38bdf8` |
83
+
84
+ > **Important:** `--bsky-bg` must match your page background for thread lines to render correctly.
85
+
86
+ ### Example
87
+
88
+ ```css
89
+ /* Light theme */
90
+ .bluesky-comments-wrapper {
91
+ --bsky-bg: #ffffff;
92
+ --bsky-border: #e5e7eb;
93
+ --bsky-link: #2563eb;
94
+ }
95
+
96
+ /* Dark theme */
97
+ .dark .bluesky-comments-wrapper {
98
+ --bsky-bg: #0a0a0a;
99
+ --bsky-border: rgba(255, 255, 255, 0.1);
100
+ --bsky-link: #38bdf8;
101
+ }
102
+ ```
103
+
104
+ ### Inline Styles
105
+
106
+ ```vue
107
+ <BlueskyComments
108
+ url="https://bsky.app/profile/user.bsky.social/post/abc123"
109
+ :style="{
110
+ '--bsky-bg': '#fafafa',
111
+ '--bsky-border': '#e5e5e5',
112
+ '--bsky-link': '#0284c7',
113
+ }"
114
+ />
115
+ ```
116
+
117
+ ## Thread Flattening
118
+
119
+ Consecutive replies from the same author stay at one visual level:
120
+
121
+ ```
122
+ UserA: "Great post!"
123
+ └─ UserB: "Thanks!"
124
+ └─ UserB: "Also wanted to add..." ← Same level, not nested deeper
125
+ └─ UserB: "One more thing..."
126
+ ```
127
+
128
+ ## Blog Integration
129
+
130
+ Add `blueskyUrl` to your frontmatter:
131
+
132
+ ```markdown
133
+ ---
134
+ title: My Blog Post
135
+ blueskyUrl: https://bsky.app/profile/user.bsky.social/post/abc123
136
+ ---
137
+ ```
138
+
139
+ ```vue
140
+ <template>
141
+ <article>
142
+ <ContentRenderer :value="page" />
143
+ <BlueskyComments v-if="page.blueskyUrl" :url="page.blueskyUrl" />
144
+ </article>
145
+ </template>
146
+ ```
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ pnpm install # Install dependencies
152
+ pnpm dev:prepare # Prepare development
153
+ pnpm dev # Run playground
154
+ pnpm prepack # Build module
155
+ ```
156
+
157
+ ## Credits
158
+
159
+ Inspired by Emily Liu's blog post [Using Bluesky posts as blog comments](https://emilyliu.me/blog/comments) β€” the idea that Bluesky's open network could power comment sections across the web.
160
+
161
+ ## License
162
+
163
+ MIT License Β© Patryk Tomczyk
@@ -0,0 +1,7 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+ import { ModuleOptions } from '../dist/runtime/types.js';
3
+ export { ModuleOptions } from '../dist/runtime/types.js';
4
+
5
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
6
+
7
+ export { _default as default };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "nuxt-bluesky-comments",
3
+ "configKey": "blueskyComments",
4
+ "compatibility": {
5
+ "nuxt": ">=3.0.0"
6
+ },
7
+ "version": "0.1.0",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.2",
10
+ "unbuild": "unknown"
11
+ }
12
+ }
@@ -0,0 +1,31 @@
1
+ import { defineNuxtModule, createResolver, addComponentsDir, addImports } from '@nuxt/kit';
2
+
3
+ const module$1 = defineNuxtModule({
4
+ meta: {
5
+ name: "nuxt-bluesky-comments",
6
+ configKey: "blueskyComments",
7
+ compatibility: {
8
+ nuxt: ">=3.0.0"
9
+ }
10
+ },
11
+ defaults: {
12
+ apiService: "https://public.api.bsky.app"
13
+ },
14
+ async setup(_options, nuxt) {
15
+ const resolver = createResolver(import.meta.url);
16
+ addComponentsDir({
17
+ path: resolver.resolve("./runtime/components"),
18
+ pathPrefix: false,
19
+ prefix: "",
20
+ global: true
21
+ });
22
+ addImports({
23
+ name: "useBlueskyComments",
24
+ as: "useBlueskyComments",
25
+ from: resolver.resolve("./runtime/composables/useBlueskyComments")
26
+ });
27
+ nuxt.options.build.transpile.push("@atproto/api");
28
+ }
29
+ });
30
+
31
+ export { module$1 as default };
@@ -0,0 +1,14 @@
1
+ import type { FlattenedComment } from "../types.js";
2
+ type __VLS_Props = {
3
+ comment: FlattenedComment;
4
+ depth?: number;
5
+ parentAuthorDid?: string;
6
+ /**
7
+ * True when this comment is the last *indented* sibling at its depth.
8
+ * Used to visually terminate the parent thread line (so it doesn't look like it continues).
9
+ */
10
+ isLastSibling?: boolean;
11
+ };
12
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
@@ -0,0 +1,388 @@
1
+ <script setup>
2
+ import { ref, computed, provide, inject } from "vue";
3
+ const props = defineProps({
4
+ comment: { type: Object, required: true },
5
+ depth: { type: Number, required: false, default: 0 },
6
+ parentAuthorDid: { type: String, required: false },
7
+ isLastSibling: { type: Boolean, required: false, default: false }
8
+ });
9
+ const collapsed = ref(false);
10
+ const avatarError = ref(false);
11
+ function handleAvatarError() {
12
+ avatarError.value = true;
13
+ }
14
+ const avatarLetter = computed(() => {
15
+ const handle = props.comment.author.handle || props.comment.author.displayName || "?";
16
+ return handle.charAt(0).toUpperCase();
17
+ });
18
+ const isSameAuthor = computed(
19
+ () => props.parentAuthorDid === props.comment.author.did
20
+ );
21
+ const shouldIndent = computed(() => props.depth > 0 && !isSameAuthor.value);
22
+ const HIDE_GLOBE_FOR_HANDLE_SUFFIXES = [".bsky.social"];
23
+ const shouldShowHandleGlobe = computed(() => {
24
+ const handle = props.comment.author.handle?.toLowerCase() ?? "";
25
+ return !HIDE_GLOBE_FOR_HANDLE_SUFFIXES.some(
26
+ (suffix) => handle.endsWith(suffix)
27
+ );
28
+ });
29
+ const hasReplies = computed(
30
+ () => props.comment.replies && props.comment.replies.length > 0
31
+ );
32
+ const isNested = computed(() => props.depth > 0 && shouldIndent.value);
33
+ const replyItems = computed(() => {
34
+ const replies = props.comment.replies || [];
35
+ let lastIndentedIndex = -1;
36
+ for (let i = replies.length - 1; i >= 0; i -= 1) {
37
+ const reply = replies[i];
38
+ if (reply && reply.author.did !== props.comment.author.did) {
39
+ lastIndentedIndex = i;
40
+ break;
41
+ }
42
+ }
43
+ return replies.map((reply, index) => ({
44
+ reply,
45
+ isLastIndentedSibling: index === lastIndentedIndex && reply.author.did !== props.comment.author.did
46
+ }));
47
+ });
48
+ const isMainBranchHovered = ref(false);
49
+ const parentHoverState = inject(
50
+ "parentThreadState",
51
+ null
52
+ );
53
+ if (hasReplies.value) {
54
+ provide("parentThreadState", {
55
+ isHovered: isMainBranchHovered,
56
+ toggleCollapse
57
+ });
58
+ }
59
+ const isConnectedHovered = computed(() => {
60
+ if (isNested.value && parentHoverState) {
61
+ return parentHoverState.isHovered.value;
62
+ }
63
+ return isMainBranchHovered.value;
64
+ });
65
+ function countAllReplies(comment) {
66
+ let count = comment.replies?.length || 0;
67
+ for (const reply of comment.replies || []) {
68
+ count += countAllReplies(reply);
69
+ }
70
+ return count;
71
+ }
72
+ const totalNestedReplies = computed(() => countAllReplies(props.comment));
73
+ function toggleCollapse() {
74
+ collapsed.value = !collapsed.value;
75
+ }
76
+ function handleConnectionClick() {
77
+ if (parentHoverState) {
78
+ parentHoverState.toggleCollapse();
79
+ }
80
+ }
81
+ const shouldCutParentThreadLine = computed(() => {
82
+ return isNested.value && props.isLastSibling;
83
+ });
84
+ function formatRelativeTime(dateString) {
85
+ const date = new Date(dateString);
86
+ const now = /* @__PURE__ */ new Date();
87
+ const diffMs = now.getTime() - date.getTime();
88
+ const diffSecs = Math.floor(diffMs / 1e3);
89
+ const diffMins = Math.floor(diffSecs / 60);
90
+ const diffHours = Math.floor(diffMins / 60);
91
+ const diffDays = Math.floor(diffHours / 24);
92
+ const diffWeeks = Math.floor(diffDays / 7);
93
+ const diffMonths = Math.floor(diffDays / 30);
94
+ const diffYears = Math.floor(diffDays / 365);
95
+ if (diffYears > 0) return `${diffYears}y`;
96
+ if (diffMonths > 0) return `${diffMonths}mo`;
97
+ if (diffWeeks > 0) return `${diffWeeks}w`;
98
+ if (diffDays > 0) return `${diffDays}d`;
99
+ if (diffHours > 0) return `${diffHours}h`;
100
+ if (diffMins > 0) return `${diffMins}m`;
101
+ return "now";
102
+ }
103
+ function getCommentUrl(comment) {
104
+ const match = comment.uri.match(
105
+ /at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)/
106
+ );
107
+ if (!match) return "";
108
+ const [, did, rkey] = match;
109
+ const identifier = comment.author.handle || did;
110
+ return `https://bsky.app/profile/${identifier}/post/${rkey}`;
111
+ }
112
+ const commentUrl = getCommentUrl(props.comment);
113
+ const relativeTime = formatRelativeTime(props.comment.createdAt);
114
+ </script>
115
+
116
+ <template>
117
+ <div class="bsky-comment" :class="{ 'nested': isNested }">
118
+ <!-- Connection line from nested comment to main branch - overlays without causing indentation -->
119
+ <div
120
+ v-if="isNested"
121
+ class="connection-line-wrapper"
122
+ @click="handleConnectionClick"
123
+ @mouseenter="() => {
124
+ if (parentHoverState) parentHoverState.isHovered.value = true;
125
+ }"
126
+ @mouseleave="() => {
127
+ if (parentHoverState) parentHoverState.isHovered.value = false;
128
+ }"
129
+ >
130
+ <svg
131
+ class="connection-svg"
132
+ :class="{ 'hovered': isConnectedHovered }"
133
+ width="100%"
134
+ height="100%"
135
+ viewBox="0 0 29 32"
136
+ preserveAspectRatio="none"
137
+ >
138
+ <!-- Rounded "L" connector (vertical + horizontal) with avatar-like corner radius -->
139
+ <!-- Kept within avatar height (32px) so it never goes above the avatar -->
140
+ <!-- Use a true quarter-circle arc for the elbow (radius = 16px) -->
141
+ <!-- SVG width is tuned so the horizontal segment ends exactly at the avatar's left edge -->
142
+ <!-- Inset by 1px to avoid stroke clipping (keeps thickness consistent with 2px rails) -->
143
+ <path
144
+ d="M 1 1 V 15 A 16 16 0 0 0 17 31"
145
+ fill="none"
146
+ :stroke="isConnectedHovered ? '#2563eb' : '#d1d5db'"
147
+ stroke-width="2"
148
+ stroke-linecap="butt"
149
+ stroke-linejoin="round"
150
+ />
151
+ </svg>
152
+ </div>
153
+
154
+ <!-- Mask to terminate the parent thread line for the last sibling at this depth -->
155
+ <div v-if="shouldCutParentThreadLine" class="thread-line-cut" aria-hidden="true" />
156
+
157
+ <div class="comment-row">
158
+ <!-- Grid column 1: avatar -->
159
+ <div class="avatar-cell">
160
+ <a
161
+ :href="`https://bsky.app/profile/${comment.author.handle}`"
162
+ target="_blank"
163
+ rel="noopener noreferrer"
164
+ class="avatar-link"
165
+ >
166
+ <img
167
+ v-if="comment.author.avatar && !avatarError"
168
+ :src="comment.author.avatar"
169
+ :alt="`${comment.author.displayName || comment.author.handle}'s avatar`"
170
+ class="avatar-img"
171
+ @error="handleAvatarError"
172
+ />
173
+ <div v-else class="avatar-placeholder">
174
+ <span class="avatar-letter">{{ avatarLetter }}</span>
175
+ </div>
176
+ </a>
177
+ </div>
178
+
179
+ <!-- Grid column 1: rail spans below avatar -->
180
+ <div v-if="hasReplies" class="thread-line-wrapper" aria-hidden="true">
181
+ <div
182
+ v-if="!collapsed"
183
+ class="thread-line"
184
+ :class="{ 'hovered': isMainBranchHovered }"
185
+ @click="toggleCollapse"
186
+ @mouseenter="isMainBranchHovered = true"
187
+ @mouseleave="isMainBranchHovered = false"
188
+ />
189
+ </div>
190
+
191
+ <!-- Grid column 1: toggle button in the SAME grid row as stats-row -->
192
+ <button
193
+ v-if="hasReplies"
194
+ class="thread-toggle-btn"
195
+ :class="{ 'collapsed': collapsed }"
196
+ :title="collapsed ? 'Expand replies' : 'Collapse replies'"
197
+ @click="toggleCollapse"
198
+ >
199
+ <svg
200
+ v-if="collapsed"
201
+ class="toggle-icon"
202
+ viewBox="0 0 24 24"
203
+ fill="none"
204
+ stroke="currentColor"
205
+ stroke-width="3"
206
+ >
207
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
208
+ </svg>
209
+ <svg
210
+ v-else
211
+ class="toggle-icon"
212
+ viewBox="0 0 24 24"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ stroke-width="3"
216
+ >
217
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
218
+ </svg>
219
+ </button>
220
+
221
+ <!-- Grid column 2: author -->
222
+ <div class="author-row">
223
+ <div class="author-meta">
224
+ <a
225
+ :href="`https://bsky.app/profile/${comment.author.handle}`"
226
+ target="_blank"
227
+ rel="noopener noreferrer"
228
+ class="author-name"
229
+ >
230
+ {{ comment.author.displayName || comment.author.handle }}
231
+ </a>
232
+ <div class="author-handle-row">
233
+ <a
234
+ :href="`https://bsky.app/profile/${comment.author.handle}`"
235
+ target="_blank"
236
+ rel="noopener noreferrer"
237
+ class="author-handle"
238
+ >
239
+ @{{ comment.author.handle }}
240
+ </a>
241
+ <a
242
+ v-if="shouldShowHandleGlobe"
243
+ class="author-handle-site"
244
+ :href="`https://${comment.author.handle}`"
245
+ target="_blank"
246
+ rel="noopener noreferrer"
247
+ :aria-label="`Open ${comment.author.handle} website`"
248
+ :title="`Open website of ${comment.author.handle}`"
249
+ >
250
+ <svg
251
+ class="globe-icon"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ stroke-width="1.8"
256
+ >
257
+ <path
258
+ stroke-linecap="round"
259
+ stroke-linejoin="round"
260
+ d="M12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9-9 4.03-9 9 4.03 9 9 9z"
261
+ />
262
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.6 9h16.8" />
263
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.6 15h16.8" />
264
+ <path
265
+ stroke-linecap="round"
266
+ stroke-linejoin="round"
267
+ d="M12 3c2.2 2.7 3.5 5.7 3.5 9s-1.3 6.3-3.5 9c-2.2-2.7-3.5-5.7-3.5-9S9.8 5.7 12 3z"
268
+ />
269
+ </svg>
270
+ </a>
271
+ </div>
272
+ </div>
273
+ <a
274
+ :href="commentUrl"
275
+ target="_blank"
276
+ rel="noopener noreferrer"
277
+ class="timestamp"
278
+ :title="new Date(comment.createdAt).toLocaleString()"
279
+ >
280
+ {{ relativeTime }}
281
+ </a>
282
+ </div>
283
+
284
+ <!-- Grid column 2: text -->
285
+ <div class="comment-text">
286
+ {{ comment.text }}
287
+ </div>
288
+
289
+ <!-- Grid column 2: stats (link to this comment on Bluesky) -->
290
+ <a
291
+ class="stats-row stats-row-link"
292
+ :href="commentUrl"
293
+ target="_blank"
294
+ rel="noopener noreferrer"
295
+ aria-label="Open this comment on Bluesky to reply"
296
+ title="Open this comment on Bluesky to reply"
297
+ >
298
+ <span class="stat">
299
+ <svg
300
+ class="stat-icon"
301
+ viewBox="0 0 24 24"
302
+ fill="none"
303
+ stroke="currentColor"
304
+ stroke-width="2"
305
+ >
306
+ <path
307
+ d="M12 21l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.18L12 21z"
308
+ stroke-linecap="round"
309
+ stroke-linejoin="round"
310
+ />
311
+ </svg>
312
+ {{ comment.likeCount }}
313
+ </span>
314
+ <span class="stat">
315
+ <svg
316
+ class="stat-icon"
317
+ viewBox="0 0 24 24"
318
+ fill="none"
319
+ stroke="currentColor"
320
+ stroke-width="2"
321
+ >
322
+ <path
323
+ stroke-linecap="round"
324
+ stroke-linejoin="round"
325
+ d="M17 1l4 4-4 4M21 5H10a4 4 0 00-4 4v1"
326
+ />
327
+ <path
328
+ stroke-linecap="round"
329
+ stroke-linejoin="round"
330
+ d="M7 23l-4-4 4-4M3 19h11a4 4 0 004-4v-1"
331
+ />
332
+ </svg>
333
+ {{ comment.repostCount }}
334
+ </span>
335
+ <span class="stat">
336
+ <svg
337
+ class="stat-icon"
338
+ viewBox="0 0 24 24"
339
+ fill="none"
340
+ stroke="currentColor"
341
+ stroke-width="2"
342
+ >
343
+ <path
344
+ d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"
345
+ stroke-linecap="round"
346
+ stroke-linejoin="round"
347
+ />
348
+ </svg>
349
+ {{ comment.replyCount }}
350
+ </span>
351
+ <svg
352
+ class="stats-link-indicator"
353
+ viewBox="0 0 24 24"
354
+ fill="none"
355
+ stroke="currentColor"
356
+ stroke-width="2"
357
+ aria-hidden="true"
358
+ >
359
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7" />
360
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10 7h7v7" />
361
+ </svg>
362
+ </a>
363
+
364
+ <!-- Grid column 2: collapsed indicator -->
365
+ <div v-if="collapsed && hasReplies" class="collapsed-info">
366
+ <button class="collapsed-link" @click="toggleCollapse">
367
+ {{ totalNestedReplies }} more {{ totalNestedReplies === 1 ? "reply" : "replies" }}
368
+ </button>
369
+ </div>
370
+
371
+ <!-- Grid column 2: replies -->
372
+ <div v-if="!collapsed && hasReplies" class="replies-list">
373
+ <BlueskyComment
374
+ v-for="item in replyItems"
375
+ :key="item.reply.id"
376
+ :comment="item.reply"
377
+ :depth="depth + 1"
378
+ :parent-author-did="comment.author.did"
379
+ :is-last-sibling="item.isLastIndentedSibling"
380
+ />
381
+ </div>
382
+ </div>
383
+ </div>
384
+ </template>
385
+
386
+ <style scoped>
387
+ .bsky-comment{padding-top:8px;position:relative;--bsky-avatar-size:32px;--bsky-avatar-center:16px;--bsky-connector-top:0px}.bsky-comment.nested{padding-top:12px}.bsky-comment.nested .comment-row{margin-left:-9px}.comment-row{align-items:start;-moz-column-gap:10px;column-gap:10px;display:grid;grid-template-columns:32px 1fr;grid-template-rows:auto auto auto auto auto}.avatar-cell{display:flex;grid-column:1;grid-row:1;justify-content:center}.author-row{grid-column:2;grid-row:1}.comment-text{grid-column:2;grid-row:2}.stats-row{grid-column:2;grid-row:3}.collapsed-info{grid-column:2;grid-row:4}.replies-list{grid-column:2;grid-row:5}.avatar-link{color:inherit;display:block;flex-shrink:0;text-decoration:none}.avatar-img{-o-object-fit:cover;object-fit:cover}.avatar-img,.avatar-placeholder{background:var(--bsky-border,#e5e5e5);border-radius:50%;height:32px;width:32px}.avatar-placeholder{align-items:center;display:flex;justify-content:center}.avatar-letter{color:inherit;font-size:14px;font-weight:500;line-height:1;text-transform:uppercase}.thread-line-wrapper{align-self:stretch;display:flex;grid-column:1;grid-row:2/6;justify-content:center;margin-top:4px}.thread-line{background:#d1d5db;border-radius:1px;cursor:pointer;height:100%;min-height:20px;transition:background-color .15s;width:2px}.thread-line.hovered,.thread-line:hover{background:#2563eb}.connection-line-wrapper{cursor:pointer;height:var(--bsky-avatar-size);left:-27px;overflow:visible;pointer-events:all;position:absolute;top:var(--bsky-connector-top);width:29px;z-index:1}.thread-line-cut{background:var(--bsky-bg,#fff);border-radius:999px;bottom:0;left:-25px;pointer-events:none;position:absolute;top:calc(var(--bsky-connector-top) + var(--bsky-avatar-center));transform:translateX(-50%);width:14px;z-index:0}.connection-svg{display:block;height:100%;overflow:visible;pointer-events:all;transition:opacity .15s;width:100%}.connection-svg path{transition:stroke .15s}.thread-toggle-btn{align-items:center;align-self:center;background:transparent;border:1px solid var(--bsky-toggle-border,rgba(17,24,39,.18));border-radius:50%;box-shadow:0 0 0 2px var(--bsky-bg,#fff),0 2px 8px rgba(0,0,0,.18);color:var(--bsky-toggle-icon,#111827);cursor:pointer;display:flex;grid-column:1;grid-row:3;height:22px;isolation:isolate;justify-content:center;justify-self:center;position:relative;transition:all .15s;width:22px;z-index:2}.thread-toggle-btn:before{background:var(--bsky-bg,#fff);z-index:-2}.thread-toggle-btn:after,.thread-toggle-btn:before{border-radius:inherit;content:"";inset:0;pointer-events:none;position:absolute}.thread-toggle-btn:after{background:var(--bsky-toggle-surface,rgba(229,231,235,.85));z-index:-1}.thread-toggle-btn:hover{--bsky-toggle-surface:rgba(37,99,235,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.thread-toggle-btn.collapsed{--bsky-toggle-surface:rgba(219,234,254,.75);--bsky-toggle-border:rgba(37,99,235,.28);--bsky-toggle-icon:#2563eb}.thread-toggle-btn.collapsed:hover{--bsky-toggle-surface:rgba(37,99,235,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.toggle-icon{height:14px;width:14px}.content-column{flex:1;min-width:0}.author-row{align-items:flex-start;display:flex;font-size:14px;gap:10px;justify-content:space-between;line-height:20px}.author-meta{display:flex;flex-direction:column;min-width:0}.author-name{color:inherit;font-weight:500}.author-name:hover{text-decoration:underline}.author-handle{color:inherit;font-size:12px;line-height:14px;opacity:.6;text-decoration:none}.author-handle-row{align-items:center;display:flex;gap:6px}.author-handle-site{align-items:center;color:inherit;display:inline-flex;justify-content:center;opacity:.5;text-decoration:none}.author-handle-site:hover{color:var(--bsky-link,#2563eb)}.globe-icon{height:12px;width:12px}.author-handle:hover{color:var(--bsky-link,#2563eb);text-decoration:underline}.timestamp{color:inherit;opacity:.6}.timestamp:hover{color:var(--bsky-link,#2563eb);text-decoration:underline}.comment-text{color:inherit;font-size:14px;line-height:20px;margin-top:4px;opacity:.85;white-space:pre-wrap;word-break:break-word}.stats-row{align-items:center;color:inherit;display:flex;font-size:12px;gap:12px;margin-top:6px;min-height:18px;opacity:.6}.stats-row-link{text-decoration:none}.stats-row-link:hover{color:var(--bsky-link,#2563eb)}.stats-link-indicator{height:14px;margin-left:2px;opacity:.65;width:14px}.stats-row-link:hover .stats-link-indicator{opacity:1}.stat{align-items:center;display:flex;gap:4px}.stat-icon{height:14px;width:14px}.collapsed-info{margin-top:4px}.collapsed-link{background:none;border:none;color:var(--bsky-link,#2563eb);cursor:pointer;font-size:12px;padding:0}.collapsed-link:hover{text-decoration:underline}.replies-list{margin-top:0}.dark .thread-toggle-btn,.dark-theme .thread-toggle-btn,:root.dark .thread-toggle-btn{--bsky-toggle-surface:rgba(55,65,81,.82);--bsky-toggle-border:hsla(0,0%,100%,.14);--bsky-toggle-icon:#e5e7eb}.dark .thread-toggle-btn:hover,.dark-theme .thread-toggle-btn:hover,:root.dark .thread-toggle-btn:hover{--bsky-toggle-surface:rgba(59,130,246,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.dark .thread-toggle-btn.collapsed,.dark-theme .thread-toggle-btn.collapsed,:root.dark .thread-toggle-btn.collapsed{--bsky-toggle-surface:rgba(30,58,95,.75);--bsky-toggle-border:rgba(96,165,250,.35);--bsky-toggle-icon:#60a5fa}.dark .thread-line,.dark-theme .thread-line,:root.dark .thread-line{background:#4b5563}.dark .thread-line.hovered,.dark .thread-line:hover,.dark-theme .thread-line.hovered,.dark-theme .thread-line:hover,:root.dark .thread-line.hovered,:root.dark .thread-line:hover{background:#3b82f6}.dark .connection-line,.dark-theme .connection-line,:root.dark .connection-line{background:#4b5563}.dark .connection-line.hovered,.dark .connection-line:hover,.dark-theme .connection-line.hovered,.dark-theme .connection-line:hover,:root.dark .connection-line.hovered,:root.dark .connection-line:hover{background:#3b82f6}.dark .connection-svg path,.dark-theme .connection-svg path,:root.dark .connection-svg path{stroke:#4b5563}.dark .connection-svg.hovered path,.dark-theme .connection-svg.hovered path,:root.dark .connection-svg.hovered path{stroke:#3b82f6}
388
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { FlattenedComment } from "../types.js";
2
+ type __VLS_Props = {
3
+ comment: FlattenedComment;
4
+ depth?: number;
5
+ parentAuthorDid?: string;
6
+ /**
7
+ * True when this comment is the last *indented* sibling at its depth.
8
+ * Used to visually terminate the parent thread line (so it doesn't look like it continues).
9
+ */
10
+ isLastSibling?: boolean;
11
+ };
12
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
@@ -0,0 +1,21 @@
1
+ type __VLS_Props = {
2
+ /**
3
+ * AT Protocol URI of the post
4
+ */
5
+ uri?: string;
6
+ /**
7
+ * Bluesky web URL of the post
8
+ */
9
+ url?: string;
10
+ /**
11
+ * Maximum number of top-level comments to show initially
12
+ */
13
+ limit?: number;
14
+ /**
15
+ * Render same-author continuation replies at the same visual level (no nested wrapping).
16
+ */
17
+ flattenSameAuthorThreads?: boolean;
18
+ };
19
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ declare const _default: typeof __VLS_export;
21
+ export default _default;
@@ -0,0 +1,165 @@
1
+ <script setup>
2
+ import { ref, computed } from "vue";
3
+ import { useBlueskyComments } from "../composables/useBlueskyComments";
4
+ import BlueskyComment from "./BlueskyComment.vue";
5
+ const props = defineProps({
6
+ uri: { type: String, required: false },
7
+ url: { type: String, required: false },
8
+ limit: { type: Number, required: false, default: 5 },
9
+ flattenSameAuthorThreads: { type: Boolean, required: false, default: true }
10
+ });
11
+ const postIdentifier = computed(() => {
12
+ if (props.uri) return props.uri;
13
+ if (props.url) return props.url;
14
+ return "";
15
+ });
16
+ const hasIdentifier = computed(() => !!postIdentifier.value);
17
+ const { loading, error, comments, stats, postUrl, refresh } = hasIdentifier.value ? useBlueskyComments(postIdentifier.value, {
18
+ flattenSameAuthorThreads: props.flattenSameAuthorThreads
19
+ }) : {
20
+ loading: ref(false),
21
+ error: ref("No Bluesky post URL or URI provided"),
22
+ comments: ref([]),
23
+ stats: ref({ likeCount: 0, repostCount: 0, replyCount: 0, quoteCount: 0 }),
24
+ postUrl: ref(""),
25
+ refresh: async () => {
26
+ }
27
+ };
28
+ const showAll = ref(false);
29
+ const visibleComments = computed(() => {
30
+ if (showAll.value) return comments.value;
31
+ return comments.value.slice(0, props.limit);
32
+ });
33
+ const hasMoreComments = computed(() => {
34
+ return comments.value.length > props.limit && !showAll.value;
35
+ });
36
+ const remainingCount = computed(() => {
37
+ return comments.value.length - props.limit;
38
+ });
39
+ function showMoreComments() {
40
+ showAll.value = true;
41
+ }
42
+ function formatCount(count) {
43
+ if (count >= 1e6) {
44
+ return `${(count / 1e6).toFixed(1)}M`;
45
+ }
46
+ if (count >= 1e3) {
47
+ return `${(count / 1e3).toFixed(1)}K`;
48
+ }
49
+ return count.toString();
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div
55
+ class="bsky-comments"
56
+ style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--bsky-border, #e5e5e5);"
57
+ >
58
+ <!-- Stats bar -->
59
+ <a
60
+ v-if="postUrl && stats.likeCount + stats.repostCount + stats.replyCount > 0"
61
+ :href="postUrl"
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ class="bsky-stats-bar"
65
+ >
66
+ <span v-if="stats.likeCount > 0" class="bsky-stat">
67
+ <svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
68
+ <path
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ stroke-width="2"
72
+ d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
73
+ />
74
+ </svg>
75
+ {{ formatCount(stats.likeCount) }} {{ stats.likeCount === 1 ? "like" : "likes" }}
76
+ </span>
77
+ <span v-if="stats.repostCount > 0" class="bsky-stat">
78
+ <svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
79
+ <path
80
+ stroke-linecap="round"
81
+ stroke-linejoin="round"
82
+ stroke-width="2"
83
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
84
+ />
85
+ </svg>
86
+ {{ formatCount(stats.repostCount) }} {{ stats.repostCount === 1 ? "repost" : "reposts" }}
87
+ </span>
88
+ <span v-if="stats.replyCount > 0" class="bsky-stat">
89
+ <svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
90
+ <path
91
+ stroke-linecap="round"
92
+ stroke-linejoin="round"
93
+ stroke-width="2"
94
+ d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
95
+ />
96
+ </svg>
97
+ {{ formatCount(stats.replyCount) }} {{ stats.replyCount === 1 ? "reply" : "replies" }}
98
+ </span>
99
+ </a>
100
+
101
+ <!-- Section header -->
102
+ <h2 class="bsky-heading">Comments</h2>
103
+
104
+ <!-- CTA to comment -->
105
+ <p class="bsky-cta">
106
+ Reply on Bluesky
107
+ <a v-if="postUrl" :href="postUrl" target="_blank" rel="noopener noreferrer" class="bsky-link"
108
+ >here</a
109
+ >
110
+ to join the conversation.
111
+ </p>
112
+
113
+ <!-- Loading state -->
114
+ <div v-if="loading" class="bsky-loading">
115
+ <svg class="bsky-spinner" fill="none" viewBox="0 0 24 24">
116
+ <circle
117
+ style="opacity: 0.25"
118
+ cx="12"
119
+ cy="12"
120
+ r="10"
121
+ stroke="currentColor"
122
+ stroke-width="4"
123
+ />
124
+ <path
125
+ style="opacity: 0.75"
126
+ fill="currentColor"
127
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
128
+ />
129
+ </svg>
130
+ Loading comments...
131
+ </div>
132
+
133
+ <!-- Error state -->
134
+ <div v-else-if="error" class="bsky-state-message">
135
+ <p>{{ error }}</p>
136
+ <button v-if="hasIdentifier" class="bsky-link bsky-button" @click="refresh">Try again</button>
137
+ </div>
138
+
139
+ <!-- Empty state -->
140
+ <div v-else-if="comments.length === 0" class="bsky-state-message">
141
+ <p>No comments yet. Be the first to reply!</p>
142
+ </div>
143
+
144
+ <!-- Comments list -->
145
+ <div v-else class="bsky-comments-list">
146
+ <BlueskyComment
147
+ v-for="comment in visibleComments"
148
+ :key="comment.id"
149
+ :comment="comment"
150
+ :depth="0"
151
+ />
152
+ </div>
153
+
154
+ <!-- Show more button -->
155
+ <div v-if="hasMoreComments" class="bsky-show-more">
156
+ <button class="bsky-link bsky-button" @click="showMoreComments">
157
+ {{ remainingCount }} more {{ remainingCount === 1 ? "comment" : "comments" }}
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </template>
162
+
163
+ <style scoped>
164
+ .bsky-stats-bar{align-items:center;color:inherit;display:inline-flex;font-size:.875rem;gap:1rem;margin-bottom:1rem;opacity:.6;text-decoration:none;transition:opacity .2s}.bsky-stats-bar:hover{color:var(--bsky-link,#2563eb);opacity:1}.bsky-stat{align-items:center;display:flex;gap:.25rem}.bsky-stat-icon{height:1rem;width:1rem}.bsky-heading{color:inherit;font-size:1.25rem;font-weight:600;margin-bottom:.5rem}.bsky-cta{font-size:.875rem;margin-bottom:1.5rem;opacity:.6}.bsky-link{color:var(--bsky-link,#2563eb);text-decoration:none}.bsky-link:hover{text-decoration:underline}.bsky-button{background:transparent;border:none;cursor:pointer;font-size:.875rem;padding:0}.bsky-loading{align-items:center;display:flex;justify-content:center;opacity:.6;padding:2rem 0}.bsky-spinner{animation:bsky-spin 1s linear infinite;height:1.5rem;margin-right:.5rem;width:1.5rem}@keyframes bsky-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.bsky-state-message{opacity:.6;padding:2rem 0;text-align:center}.bsky-show-more{margin-top:1rem}
165
+ </style>
@@ -0,0 +1,21 @@
1
+ type __VLS_Props = {
2
+ /**
3
+ * AT Protocol URI of the post
4
+ */
5
+ uri?: string;
6
+ /**
7
+ * Bluesky web URL of the post
8
+ */
9
+ url?: string;
10
+ /**
11
+ * Maximum number of top-level comments to show initially
12
+ */
13
+ limit?: number;
14
+ /**
15
+ * Render same-author continuation replies at the same visual level (no nested wrapping).
16
+ */
17
+ flattenSameAuthorThreads?: boolean;
18
+ };
19
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ declare const _default: typeof __VLS_export;
21
+ export default _default;
@@ -0,0 +1,26 @@
1
+ import { AppBskyFeedDefs } from "@atproto/api";
2
+ import type { FlattenedComment } from "../types.js";
3
+ /**
4
+ * Parse a Bluesky web URL into handle/DID and rkey.
5
+ */
6
+ export declare function parseBlueskyUrl(url: string): {
7
+ identifier: string;
8
+ rkey: string;
9
+ } | null;
10
+ /**
11
+ * Convert an AT Protocol URI to a Bluesky web URL.
12
+ */
13
+ export declare function uriToUrl(uri: string, handle?: string): string;
14
+ /**
15
+ * Process thread replies into flattened comments.
16
+ * Handles same-author consecutive reply flattening and engagement sorting.
17
+ */
18
+ export type ProcessRepliesOptions = {
19
+ /**
20
+ * When true, same-author continuation replies are promoted to the same level as their parent comment
21
+ * (so they don't render as nested wrappers).
22
+ * @default true
23
+ */
24
+ flattenSameAuthorThreads?: boolean;
25
+ };
26
+ export declare function processReplies(replies: AppBskyFeedDefs.ThreadViewPost["replies"], parentAuthorDid?: string, depth?: number, options?: ProcessRepliesOptions): FlattenedComment[];
@@ -0,0 +1,74 @@
1
+ import { AppBskyFeedDefs } from "@atproto/api";
2
+ export function parseBlueskyUrl(url) {
3
+ const match = url.match(/https?:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/);
4
+ if (!match) return null;
5
+ const [, identifier, rkey] = match;
6
+ return { identifier, rkey };
7
+ }
8
+ export function uriToUrl(uri, handle) {
9
+ const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)/);
10
+ if (!match) return "";
11
+ const [, did, rkey] = match;
12
+ const identifier = handle || did;
13
+ return `https://bsky.app/profile/${identifier}/post/${rkey}`;
14
+ }
15
+ export function processReplies(replies, parentAuthorDid, depth = 0, options = {}) {
16
+ if (!replies || replies.length === 0) return [];
17
+ const flattenSameAuthorThreads = options.flattenSameAuthorThreads ?? true;
18
+ const groups = [];
19
+ for (const reply of replies) {
20
+ if (!AppBskyFeedDefs.isThreadViewPost(reply)) continue;
21
+ const post = reply.post;
22
+ const author = post.author;
23
+ const record = post.record;
24
+ const isSameAuthor = parentAuthorDid === author.did;
25
+ const comment = {
26
+ id: post.cid,
27
+ uri: post.uri,
28
+ author: {
29
+ did: author.did,
30
+ handle: author.handle,
31
+ displayName: author.displayName,
32
+ avatar: author.avatar
33
+ },
34
+ text: record?.text || "",
35
+ createdAt: record?.createdAt || post.indexedAt,
36
+ likeCount: post.likeCount || 0,
37
+ replyCount: post.replyCount || 0,
38
+ repostCount: post.repostCount || 0,
39
+ depth: isSameAuthor ? depth : depth,
40
+ replies: [],
41
+ parentAuthorDid
42
+ };
43
+ if (reply.replies && reply.replies.length > 0) {
44
+ const nestedReplies = processReplies(reply.replies, author.did, depth + 1, options);
45
+ if (flattenSameAuthorThreads) {
46
+ const continuations = [];
47
+ const nestedUnderComment = [];
48
+ for (const nestedReply of nestedReplies) {
49
+ if (nestedReply.author.did === author.did) {
50
+ continuations.push({
51
+ ...nestedReply,
52
+ depth,
53
+ // Preserve "continuation" semantics for avatar/indent logic
54
+ parentAuthorDid: author.did
55
+ });
56
+ } else {
57
+ nestedUnderComment.push(nestedReply);
58
+ }
59
+ }
60
+ comment.replies = nestedUnderComment;
61
+ groups.push({ head: comment, continuations });
62
+ continue;
63
+ }
64
+ comment.replies = nestedReplies;
65
+ }
66
+ groups.push({ head: comment, continuations: [] });
67
+ }
68
+ groups.sort((a, b) => {
69
+ const scoreA = a.head.likeCount + a.head.replyCount;
70
+ const scoreB = b.head.likeCount + b.head.replyCount;
71
+ return scoreB - scoreA;
72
+ });
73
+ return groups.flatMap((g) => [g.head, ...g.continuations]);
74
+ }
@@ -0,0 +1,6 @@
1
+ import type { BlueskyCommentsResult } from "../types.js";
2
+ import { type ProcessRepliesOptions } from "./blueskyComments.logic.js";
3
+ /**
4
+ * Composable to fetch and manage Bluesky comments
5
+ */
6
+ export declare function useBlueskyComments(uriOrUrl: string, options?: Pick<ProcessRepliesOptions, "flattenSameAuthorThreads">): BlueskyCommentsResult;
@@ -0,0 +1,89 @@
1
+ import { ref, computed } from "vue";
2
+ import { AtpAgent, AppBskyFeedDefs } from "@atproto/api";
3
+ import {
4
+ parseBlueskyUrl,
5
+ processReplies,
6
+ uriToUrl
7
+ } from "./blueskyComments.logic.js";
8
+ const agent = new AtpAgent({
9
+ service: "https://public.api.bsky.app"
10
+ });
11
+ export function useBlueskyComments(uriOrUrl, options = {}) {
12
+ const loading = ref(true);
13
+ const error = ref(null);
14
+ const post = ref(null);
15
+ const comments = ref([]);
16
+ const stats = ref({
17
+ likeCount: 0,
18
+ repostCount: 0,
19
+ replyCount: 0,
20
+ quoteCount: 0
21
+ });
22
+ const postUrl = ref("");
23
+ async function resolveUri(input) {
24
+ if (input.startsWith("at://")) {
25
+ return input;
26
+ }
27
+ const parsed = parseBlueskyUrl(input);
28
+ if (!parsed) {
29
+ throw new Error("Invalid Bluesky URL format");
30
+ }
31
+ const { identifier, rkey } = parsed;
32
+ if (identifier.startsWith("did:")) {
33
+ return `at://${identifier}/app.bsky.feed.post/${rkey}`;
34
+ }
35
+ const { data } = await agent.resolveHandle({ handle: identifier });
36
+ return `at://${data.did}/app.bsky.feed.post/${rkey}`;
37
+ }
38
+ async function fetchComments() {
39
+ loading.value = true;
40
+ error.value = null;
41
+ try {
42
+ const uri = await resolveUri(uriOrUrl);
43
+ const response = await agent.getPostThread({
44
+ uri,
45
+ depth: 1e3,
46
+ // Fetch full depth
47
+ parentHeight: 0
48
+ // We don't need parent context
49
+ });
50
+ if (!response.success) {
51
+ throw new Error(`Failed to fetch post: ${uri}`);
52
+ }
53
+ const { data } = response;
54
+ if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
55
+ if (AppBskyFeedDefs.isBlockedPost(data.thread)) {
56
+ throw new Error("This post is from a blocked account");
57
+ }
58
+ if (AppBskyFeedDefs.isNotFoundPost(data.thread)) {
59
+ throw new Error(`Post not found: ${uri}`);
60
+ }
61
+ throw new Error("Post not found or not accessible");
62
+ }
63
+ const thread = data.thread;
64
+ post.value = thread.post;
65
+ stats.value = {
66
+ likeCount: thread.post.likeCount || 0,
67
+ repostCount: thread.post.repostCount || 0,
68
+ replyCount: thread.post.replyCount || 0,
69
+ quoteCount: thread.post.quoteCount || 0
70
+ };
71
+ postUrl.value = uriToUrl(uri, thread.post.author.handle);
72
+ comments.value = processReplies(thread.replies, thread.post.author.did, 0, options);
73
+ } catch (e) {
74
+ error.value = e instanceof Error ? `Failed to load comments from ${uriOrUrl}: ${e.message}` : "Failed to load comments";
75
+ } finally {
76
+ loading.value = false;
77
+ }
78
+ }
79
+ fetchComments();
80
+ return {
81
+ loading: computed(() => loading.value),
82
+ error: computed(() => error.value),
83
+ post: computed(() => post.value),
84
+ comments: computed(() => comments.value),
85
+ stats: computed(() => stats.value),
86
+ postUrl: computed(() => postUrl.value),
87
+ refresh: fetchComments
88
+ };
89
+ }
@@ -0,0 +1,104 @@
1
+ import type { AppBskyFeedDefs, AppBskyFeedPost, AppBskyActorDefs } from "@atproto/api";
2
+ import type { ComputedRef, Ref } from "vue";
3
+ /**
4
+ * Module configuration options
5
+ */
6
+ export interface ModuleOptions {
7
+ /**
8
+ * Default Bluesky API service URL
9
+ * @default 'https://public.api.bsky.app'
10
+ */
11
+ apiService?: string;
12
+ }
13
+ /**
14
+ * Props for the BlueskyComments component
15
+ */
16
+ export interface BlueskyCommentsProps {
17
+ /**
18
+ * AT Protocol URI of the post (e.g., at://did:plc:.../app.bsky.feed.post/...)
19
+ */
20
+ uri?: string;
21
+ /**
22
+ * Bluesky web URL of the post (e.g., https://bsky.app/profile/user.bsky.social/post/abc123)
23
+ * Will be converted to URI internally
24
+ */
25
+ url?: string;
26
+ /**
27
+ * Maximum number of top-level comments to show initially
28
+ * @default 5
29
+ */
30
+ limit?: number;
31
+ /**
32
+ * Render same-author continuation replies at the same level (no nested wrapping).
33
+ * @default true
34
+ */
35
+ flattenSameAuthorThreads?: boolean;
36
+ }
37
+ /**
38
+ * Represents a flattened comment for display
39
+ */
40
+ export interface FlattenedComment {
41
+ /** Unique comment ID (CID) */
42
+ id: string;
43
+ /** AT Protocol URI of the comment */
44
+ uri: string;
45
+ /** Author information */
46
+ author: {
47
+ did: string;
48
+ handle: string;
49
+ displayName?: string;
50
+ avatar?: string;
51
+ };
52
+ /** Comment text content */
53
+ text: string;
54
+ /** Timestamp when the comment was created */
55
+ createdAt: string;
56
+ /** Number of likes */
57
+ likeCount: number;
58
+ /** Number of replies */
59
+ replyCount: number;
60
+ /** Number of reposts */
61
+ repostCount: number;
62
+ /** Nesting depth level (0 = top level reply to main post) */
63
+ depth: number;
64
+ /** Child comments (flattened for same-author threads) */
65
+ replies: FlattenedComment[];
66
+ /** Parent comment author DID (for flattening logic) */
67
+ parentAuthorDid?: string;
68
+ }
69
+ /**
70
+ * Post engagement statistics
71
+ */
72
+ export interface PostStats {
73
+ likeCount: number;
74
+ repostCount: number;
75
+ replyCount: number;
76
+ quoteCount: number;
77
+ }
78
+ /**
79
+ * Result from useBlueskyComments composable
80
+ */
81
+ export type MaybeRef<T> = Ref<T> | ComputedRef<T>;
82
+ export interface BlueskyCommentsResult {
83
+ /** Loading state */
84
+ loading: MaybeRef<boolean>;
85
+ /** Error message if any */
86
+ error: MaybeRef<string | null>;
87
+ /** The main post data */
88
+ post: MaybeRef<AppBskyFeedDefs.PostView | null>;
89
+ /** Flattened and processed comments */
90
+ comments: MaybeRef<FlattenedComment[]>;
91
+ /** Post engagement stats */
92
+ stats: MaybeRef<PostStats>;
93
+ /** URL to the post on Bluesky */
94
+ postUrl: MaybeRef<string>;
95
+ /** Refresh comments */
96
+ refresh: () => Promise<void>;
97
+ }
98
+ /**
99
+ * Re-export useful types from @atproto/api
100
+ */
101
+ export type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
102
+ export type PostView = AppBskyFeedDefs.PostView;
103
+ export type PostRecord = AppBskyFeedPost.Record;
104
+ export type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic;
File without changes
@@ -0,0 +1,3 @@
1
+ export { type ModuleOptions } from '../dist/runtime/types.js'
2
+
3
+ export { default } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "nuxt-bluesky-comments",
3
+ "version": "0.1.0",
4
+ "description": "Nuxt module to display Bluesky post comments on your website",
5
+ "keywords": [
6
+ "atproto",
7
+ "bluesky",
8
+ "comments",
9
+ "nuxt",
10
+ "nuxt-module",
11
+ "social"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Patryk Tomczyk <patzick.dev>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/patzick/nuxt-bluesky-comments.git"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/module.mjs",
24
+ "types": "./dist/types.d.mts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/types.d.mts",
28
+ "import": "./dist/module.mjs"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@atproto/api": "0.18.8",
33
+ "@nuxt/kit": "4.2.2"
34
+ },
35
+ "devDependencies": {
36
+ "@nuxt/module-builder": "1.0.2",
37
+ "@nuxt/schema": "4.2.2",
38
+ "@types/node": "24.3.0",
39
+ "@unocss/nuxt": "66.5.10",
40
+ "nuxt": "4.2.2",
41
+ "oxfmt": "0.21.0",
42
+ "oxlint": "1.36.0",
43
+ "typescript": "5.9.3",
44
+ "vitest": "4.0.16",
45
+ "vue-tsc": "3.2.1"
46
+ },
47
+ "peerDependencies": {
48
+ "@unocss/nuxt": ">=66.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@unocss/nuxt": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "scripts": {
56
+ "dev": "pnpm -C playground dev",
57
+ "dev:build": "pnpm -C playground build",
58
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && (cd playground && nuxi prepare)",
59
+ "release": "npm run prepack && npm publish",
60
+ "typecheck": "tsc --noEmit",
61
+ "lint": "oxlint --fix",
62
+ "format": "oxfmt",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest"
65
+ }
66
+ }