Skip to content

Commit 6774771

Browse files
committed
feat: add hardcoded bluesky comment view
1 parent ed96391 commit 6774771

File tree

7 files changed

+482
-13
lines changed

7 files changed

+482
-13
lines changed

app/components/BlogPostWrapper.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,25 @@ useSeoMeta({
1212
ogDescription: props.frontmatter?.description || props.frontmatter?.excerpt,
1313
ogType: 'article',
1414
})
15+
16+
// TODO: Hardcoded for testing - waiting on constellation/slingshot work
17+
// Using Daniel Roe's post for testing: https://bsky.app/profile/danielroe.dev/post/3mcg6svsgsm2k
18+
const BSKY_DID = 'did:plc:jbeaa5kdaladzwq3r7f5xgwe'
19+
// const BSKY_DID = 'did:plc:5ixnpdbogli5f7fbbee5fmuq'
20+
const BSKY_POST_ID = '3mcg6svsgsm2k'
21+
// const BSKY_POST_ID = '3mdoijswyz22u'
22+
23+
const blueskyPostUri = computed(() =>
24+
BSKY_POST_ID ? `at://${BSKY_DID}/app.bsky.feed.post/${BSKY_POST_ID}` : null,
25+
)
1526
</script>
27+
1628
<template>
1729
<main class="container w-full py-8">
1830
<article class="prose dark:prose-invert mx-auto">
1931
<slot />
2032
</article>
33+
34+
<LazyBlueskyComments v-if="blueskyPostUri" :post-uri="blueskyPostUri" />
2135
</main>
2236
</template>

app/components/BlueskyComment.vue

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<script setup lang="ts">
2+
import type { AppBskyRichtextFacet } from '@atproto/api'
3+
import { segmentize } from '@atcute/bluesky-richtext-segmenter'
4+
import type { Comment } from '#shared/types/blog-post'
5+
6+
type RichtextFeature =
7+
| AppBskyRichtextFacet.Link
8+
| AppBskyRichtextFacet.Mention
9+
| AppBskyRichtextFacet.Tag
10+
11+
function getCommentUrl(comment: Comment): string {
12+
return atUriToWebUrl(comment.uri) ?? '#'
13+
}
14+
const props = defineProps<{
15+
comment: Comment
16+
depth: number
17+
}>()
18+
19+
const MaxDepth = 4
20+
21+
function getFeatureUrl(feature: RichtextFeature): string | undefined {
22+
if (feature.$type === 'app.bsky.richtext.facet#link') return feature.uri
23+
if (feature.$type === 'app.bsky.richtext.facet#mention')
24+
return `https://bsky.app/profile/${feature.did}`
25+
if (feature.$type === 'app.bsky.richtext.facet#tag')
26+
return `https://bsky.app/hashtag/${feature.tag}`
27+
}
28+
29+
const processedSegments = segmentize(props.comment.text, props.comment.facets).map(segment => ({
30+
text: segment.text,
31+
url: segment.features?.[0] ? getFeatureUrl(segment.features[0] as RichtextFeature) : undefined,
32+
}))
33+
34+
function getHostname(uri: string): string {
35+
try {
36+
return new URL(uri).hostname
37+
} catch {
38+
return uri
39+
}
40+
}
41+
</script>
42+
43+
<template>
44+
<div :class="depth === 0 ? 'flex gap-3' : 'flex gap-3 mt-3'">
45+
<!-- Avatar -->
46+
<a
47+
:href="`https://bsky.app/profile/${comment.author.handle}`"
48+
target="_blank"
49+
rel="noopener noreferrer"
50+
class="shrink-0"
51+
>
52+
<img
53+
v-if="comment.author.avatar"
54+
:src="comment.author.avatar"
55+
:alt="comment.author.displayName || comment.author.handle"
56+
:class="['rounded-full m-0', depth === 0 ? 'w-10 h-10' : 'w-8 h-8']"
57+
width="40"
58+
height="40"
59+
loading="lazy"
60+
/>
61+
<div
62+
v-else
63+
:class="[
64+
'rounded-full bg-border flex items-center justify-center text-fg-muted',
65+
depth === 0 ? 'w-10 h-10' : 'w-8 h-8 text-sm',
66+
]"
67+
>
68+
{{ (comment.author.displayName || comment.author.handle).charAt(0).toUpperCase() }}
69+
</div>
70+
</a>
71+
72+
<div class="flex-1 min-w-0">
73+
<!-- Author info + timestamp -->
74+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0">
75+
<a
76+
:href="`https://bsky.app/profile/${comment.author.handle}`"
77+
target="_blank"
78+
rel="noopener noreferrer"
79+
class="font-medium text-fg hover:underline"
80+
>
81+
{{ comment.author.displayName || comment.author.handle }}
82+
</a>
83+
<span class="text-fg-subtle text-sm">@{{ comment.author.handle }}</span>
84+
<span class="text-fg-subtle text-sm">·</span>
85+
<a
86+
:href="getCommentUrl(props.comment)"
87+
target="_blank"
88+
rel="noopener noreferrer"
89+
class="text-fg-subtle text-sm hover:underline"
90+
>
91+
<NuxtTime relative :datetime="comment.createdAt" />
92+
</a>
93+
</div>
94+
95+
<!-- Comment text with rich segments -->
96+
<p class="text-fg-muted whitespace-pre-wrap">
97+
<template v-for="(segment, i) in processedSegments" :key="i">
98+
<a
99+
v-if="segment.url"
100+
:href="segment.url"
101+
target="_blank"
102+
rel="noopener noreferrer"
103+
class="link"
104+
>{{ segment.text }}</a
105+
>
106+
<template v-else>{{ segment.text }}</template>
107+
</template>
108+
</p>
109+
110+
<!-- Embedded images -->
111+
<div
112+
v-if="comment.embed?.type === 'images' && comment.embed.images"
113+
class="flex flex-wrap gap-2"
114+
>
115+
<a
116+
v-for="(img, i) in comment.embed.images"
117+
:key="i"
118+
:href="img.fullsize"
119+
target="_blank"
120+
rel="noopener noreferrer"
121+
class="block"
122+
>
123+
<img
124+
:src="img.thumb"
125+
:alt="img.alt || 'Embedded image'"
126+
class="rounded-lg max-w-48 max-h-36 object-cover m-0"
127+
loading="lazy"
128+
/>
129+
</a>
130+
</div>
131+
132+
<!-- Embedded external link card -->
133+
<a
134+
v-if="comment.embed?.type === 'external' && comment.embed.external"
135+
:href="comment.embed.external.uri"
136+
target="_blank"
137+
rel="noopener noreferrer"
138+
class="flex gap-3 p-3 border border-border rounded-lg bg-bg-subtle hover:bg-bg-subtle/80 transition-colors no-underline"
139+
>
140+
<img
141+
v-if="comment.embed.external.thumb"
142+
:src="comment.embed.external.thumb"
143+
:alt="comment.embed.external.title"
144+
class="w-20 h-20 rounded object-cover shrink-0 m-0"
145+
loading="lazy"
146+
/>
147+
<div class="min-w-0">
148+
<div class="font-medium text-fg truncate">
149+
{{ comment.embed.external.title }}
150+
</div>
151+
<div class="text-sm text-fg-muted line-clamp-2">
152+
{{ comment.embed.external.description }}
153+
</div>
154+
<div class="text-xs text-fg-subtle mt-1 truncate">
155+
{{ getHostname(comment.embed.external.uri) }}
156+
</div>
157+
</div>
158+
</a>
159+
160+
<!-- Like/repost counts -->
161+
<div
162+
v-if="comment.likeCount > 0 || comment.repostCount > 0"
163+
class="mt-2 flex gap-4 text-sm text-fg-subtle"
164+
>
165+
<span v-if="comment.likeCount > 0">
166+
{{ comment.likeCount }} {{ comment.likeCount === 1 ? 'like' : 'likes' }}
167+
</span>
168+
<span v-if="comment.repostCount > 0">
169+
{{ comment.repostCount }} {{ comment.repostCount === 1 ? 'repost' : 'reposts' }}
170+
</span>
171+
</div>
172+
173+
<!-- Nested replies -->
174+
<template v-if="comment.replies.length > 0">
175+
<div v-if="depth < MaxDepth" class="mt-2 pl-2 border-l-2 border-border flex flex-col">
176+
<BlueskyComment
177+
v-for="reply in comment.replies"
178+
:key="reply.uri"
179+
:comment="reply"
180+
:depth="depth + 1"
181+
/>
182+
</div>
183+
<a
184+
v-else
185+
:href="getCommentUrl(comment.replies[0]!)"
186+
target="_blank"
187+
rel="noopener noreferrer"
188+
class="mt-2 block text-sm link"
189+
>
190+
{{ comment.replies.length }} more
191+
{{ comment.replies.length === 1 ? 'reply' : 'replies' }}...
192+
</a>
193+
</template>
194+
</div>
195+
</div>
196+
</template>

0 commit comments

Comments
 (0)