+283
components/bsky-comments/Comment.tsx
+283
components/bsky-comments/Comment.tsx
···
1
+
import { AppBskyFeedDefs, AppBskyFeedPost } from 'npm:@atproto/api';
2
+
3
+
type CommentProps = {
4
+
comment: AppBskyFeedDefs.ThreadViewPost;
5
+
filters?: Array<(arg: any) => boolean>;
6
+
};
7
+
8
+
export const Comment = ({ comment, filters }: CommentProps) => {
9
+
const author = comment.post.author;
10
+
const avatarClassName = "avatar";
11
+
12
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) return null;
13
+
// filter out replies that match any of the commentFilters, by ensuring they all return false
14
+
if (filters && !filters.every((filter) => !filter(comment))) return null;
15
+
16
+
17
+
const styles = `
18
+
.container {
19
+
max-width: 740px;
20
+
margin: 0 auto;
21
+
}
22
+
23
+
.statsBar {
24
+
display: flex;
25
+
align-items: center;
26
+
gap: 0.5rem;
27
+
}
28
+
29
+
.statsBar:hover {
30
+
text-decoration: underline;
31
+
}
32
+
33
+
.statItem {
34
+
display: flex;
35
+
align-items: center;
36
+
gap: 0.25rem;
37
+
white-space: nowrap;
38
+
}
39
+
40
+
.container a.link {
41
+
text-decoration: underline;
42
+
}
43
+
44
+
.container a.link:hover {
45
+
text-decoration: underline;
46
+
}
47
+
48
+
.icon {
49
+
width: 1.25rem;
50
+
height: 1.25rem;
51
+
}
52
+
53
+
.errorText, .loadingText {
54
+
text-align: center;
55
+
}
56
+
57
+
.commentsTitle {
58
+
margin-top: 1.5rem;
59
+
font-size: 1.25rem;
60
+
font-weight: bold;
61
+
}
62
+
63
+
.replyText {
64
+
margin-top: 0.5rem;
65
+
font-size: 0.875rem;
66
+
}
67
+
68
+
.divider {
69
+
margin-top: 0.5rem;
70
+
}
71
+
72
+
.commentsList {
73
+
margin-top: 0.5rem;
74
+
display: flex;
75
+
flex-direction: column;
76
+
gap: 0.5rem;
77
+
}
78
+
79
+
.container .showMoreButton {
80
+
margin-top: 0.5rem;
81
+
font-size: 0.875rem;
82
+
text-decoration: underline;
83
+
}
84
+
85
+
.container .showMoreButton:hover {
86
+
text-decoration: underline;
87
+
}
88
+
89
+
.commentContainer {
90
+
margin: 1rem 0;
91
+
font-size: 0.875rem;
92
+
}
93
+
94
+
.commentContent {
95
+
display: flex;
96
+
max-width: 36rem;
97
+
flex-direction: column;
98
+
gap: 0.5rem;
99
+
}
100
+
101
+
.authorLink {
102
+
display: flex;
103
+
flex-direction: row;
104
+
justify-content: flex-start;
105
+
align-items: center;
106
+
gap: 0.5rem;
107
+
}
108
+
109
+
.authorLink:hover {
110
+
text-decoration: underline;
111
+
}
112
+
113
+
.avatar {
114
+
height: 1rem;
115
+
width: 1rem;
116
+
flex-shrink: 0;
117
+
border-radius: 9999px;
118
+
background-color: #d1d5db;
119
+
}
120
+
121
+
.authorName {
122
+
overflow: hidden;
123
+
text-overflow: ellipsis;
124
+
white-space: nowrap;
125
+
display: -webkit-box;
126
+
-webkit-line-clamp: 1;
127
+
-webkit-box-orient: vertical;
128
+
}
129
+
130
+
.container a {
131
+
text-decoration: none;
132
+
color: inherit;
133
+
}
134
+
135
+
.container a:hover {
136
+
text-decoration: none;
137
+
}
138
+
139
+
.commentContent .handle {
140
+
color: #6b7280;
141
+
}
142
+
.repliesContainer {
143
+
border-left: 2px solid #525252;
144
+
padding-left: 0.5rem;
145
+
}
146
+
147
+
.actionsContainer {
148
+
margin-top: 0.5rem;
149
+
display: flex;
150
+
width: 100%;
151
+
max-width: 150px;
152
+
flex-direction: row;
153
+
align-items: center;
154
+
justify-content: space-between;
155
+
opacity: 0.6;
156
+
}
157
+
158
+
159
+
.actionsRow {
160
+
display: flex;
161
+
align-items: center;
162
+
gap: 0.25rem;
163
+
}
164
+
`;
165
+
166
+
return (
167
+
<div className="commentContainer">
168
+
<style>{styles}</style>
169
+
<div className="commentContent">
170
+
<a
171
+
className="authorLink"
172
+
href={`https://bsky.app/profile/${author.did}`}
173
+
target="_blank"
174
+
rel="noreferrer noopener"
175
+
>
176
+
{author.avatar ? (
177
+
<img
178
+
src={comment.post.author.avatar}
179
+
alt="avatar"
180
+
className={avatarClassName}
181
+
/>
182
+
) : (
183
+
<div className={avatarClassName} />
184
+
)}
185
+
<p className="authorName">
186
+
{author.displayName ?? author.handle}{' '}
187
+
<span className="handle">@{author.handle}</span>
188
+
</p>
189
+
</a>
190
+
<a
191
+
href={`https://bsky.app/profile/${author.did}/post/${comment.post.uri
192
+
.split('/')
193
+
.pop()}`}
194
+
target="_blank"
195
+
rel="noreferrer noopener"
196
+
>
197
+
<p>{(comment.post.record as AppBskyFeedPost.Record).text}</p>
198
+
<Actions post={comment.post} />
199
+
</a>
200
+
</div>
201
+
{comment.replies && comment.replies.length > 0 && (
202
+
<div className="repliesContainer">
203
+
{comment.replies.sort(sortByLikes).map((reply) => {
204
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
205
+
return (
206
+
<Comment key={reply.post.uri} comment={reply} filters={filters} />
207
+
);
208
+
})}
209
+
</div>
210
+
)}
211
+
</div>
212
+
);
213
+
};
214
+
215
+
const Actions = ({ post }: { post: AppBskyFeedDefs.PostView }) => (
216
+
<div className="actionsContainer">
217
+
<div className="actionsRow">
218
+
<svg
219
+
className="icon"
220
+
xmlns="http://www.w3.org/2000/svg"
221
+
fill="none"
222
+
viewBox="0 0 24 24"
223
+
strokeWidth="1.5"
224
+
stroke="currentColor"
225
+
>
226
+
<path
227
+
strokeLinecap="round"
228
+
strokeLinejoin="round"
229
+
d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z"
230
+
/>
231
+
</svg>
232
+
<p className="text-xs">{post.replyCount ?? 0}</p>
233
+
</div>
234
+
<div className="actionsRow">
235
+
<svg
236
+
className="icon"
237
+
xmlns="http://www.w3.org/2000/svg"
238
+
fill="none"
239
+
viewBox="0 0 24 24"
240
+
strokeWidth="1.5"
241
+
stroke="currentColor"
242
+
>
243
+
<path
244
+
strokeLinecap="round"
245
+
strokeLinejoin="round"
246
+
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"
247
+
/>
248
+
</svg>
249
+
<p className="text-xs">{post.repostCount ?? 0}</p>
250
+
</div>
251
+
<div className="actionsRow">
252
+
<svg
253
+
className="icon"
254
+
xmlns="http://www.w3.org/2000/svg"
255
+
fill="none"
256
+
viewBox="0 0 24 24"
257
+
strokeWidth="1.5"
258
+
stroke="currentColor"
259
+
>
260
+
<path
261
+
strokeLinecap="round"
262
+
strokeLinejoin="round"
263
+
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
264
+
/>
265
+
</svg>
266
+
<p className="text-xs">{post.likeCount ?? 0}</p>
267
+
</div>
268
+
</div>
269
+
);
270
+
271
+
const sortByLikes = (a: unknown, b: unknown) => {
272
+
if (
273
+
!AppBskyFeedDefs.isThreadViewPost(a) ||
274
+
!AppBskyFeedDefs.isThreadViewPost(b) ||
275
+
!('post' in a) ||
276
+
!('post' in b)
277
+
) {
278
+
return 0;
279
+
}
280
+
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
281
+
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
282
+
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
283
+
};
+67
components/bsky-comments/CommentFilters.tsx
+67
components/bsky-comments/CommentFilters.tsx
···
1
+
import { AppBskyFeedPost, type AppBskyFeedDefs } from 'npm:@atproto/api';
2
+
3
+
const MinLikeCountFilter = (
4
+
min: number
5
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
6
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
7
+
return (comment.post.likeCount ?? 0) < min;
8
+
};
9
+
};
10
+
11
+
const MinCharacterCountFilter = (
12
+
min: number
13
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
14
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
15
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
16
+
return false;
17
+
}
18
+
const record = comment.post.record as AppBskyFeedPost.Record;
19
+
return record.text.length < min;
20
+
};
21
+
};
22
+
23
+
const TextContainsFilter = (
24
+
text: string
25
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
26
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
27
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
28
+
return false;
29
+
}
30
+
const record = comment.post.record as AppBskyFeedPost.Record;
31
+
return record.text.toLowerCase().includes(text.toLowerCase());
32
+
};
33
+
};
34
+
35
+
const ExactMatchFilter = (
36
+
text: string
37
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
38
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
39
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
40
+
return false;
41
+
}
42
+
const record = comment.post.record as AppBskyFeedPost.Record;
43
+
return record.text.toLowerCase() === text.toLowerCase();
44
+
};
45
+
};
46
+
47
+
/*
48
+
* This function allows you to filter out comments based on likes,
49
+
* characters, text, pins, or exact matches.
50
+
*/
51
+
export const Filters: {
52
+
MinLikeCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
53
+
MinCharacterCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
54
+
TextContainsFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
55
+
ExactMatchFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
56
+
NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
57
+
NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
58
+
} = {
59
+
MinLikeCountFilter,
60
+
MinCharacterCountFilter,
61
+
TextContainsFilter,
62
+
ExactMatchFilter,
63
+
NoLikes: MinLikeCountFilter(0),
64
+
NoPins: ExactMatchFilter('📌'),
65
+
};
66
+
67
+
export default Filters;
+232
components/bsky-comments/PostSummary.tsx
+232
components/bsky-comments/PostSummary.tsx
···
1
+
import { AppBskyFeedDefs } from 'npm:@atproto/api';
2
+
3
+
type PostSummaryProps = {
4
+
postUrl: string;
5
+
post: AppBskyFeedDefs.PostView;
6
+
};
7
+
8
+
export const PostSummary = ({ postUrl, post }: PostSummaryProps) => {
9
+
const styles = `
10
+
.container {
11
+
max-width: 740px;
12
+
margin: 0 auto;
13
+
}
14
+
15
+
.statsBar {
16
+
display: flex;
17
+
align-items: center;
18
+
gap: 0.5rem;
19
+
}
20
+
21
+
.statsBar:hover {
22
+
text-decoration: underline;
23
+
}
24
+
25
+
.statItem {
26
+
display: flex;
27
+
align-items: center;
28
+
gap: 0.25rem;
29
+
white-space: nowrap;
30
+
}
31
+
32
+
.container a.link {
33
+
text-decoration: underline;
34
+
}
35
+
36
+
.container a.link:hover {
37
+
text-decoration: underline;
38
+
}
39
+
40
+
.icon {
41
+
width: 1.25rem;
42
+
height: 1.25rem;
43
+
}
44
+
45
+
.errorText, .loadingText {
46
+
text-align: center;
47
+
}
48
+
49
+
.commentsTitle {
50
+
margin-top: 1.5rem;
51
+
font-size: 1.25rem;
52
+
font-weight: bold;
53
+
}
54
+
55
+
.replyText {
56
+
margin-top: 0.5rem;
57
+
font-size: 0.875rem;
58
+
}
59
+
60
+
.divider {
61
+
margin-top: 0.5rem;
62
+
}
63
+
64
+
.commentsList {
65
+
margin-top: 0.5rem;
66
+
display: flex;
67
+
flex-direction: column;
68
+
gap: 0.5rem;
69
+
}
70
+
71
+
.container .showMoreButton {
72
+
margin-top: 0.5rem;
73
+
font-size: 0.875rem;
74
+
text-decoration: underline;
75
+
}
76
+
77
+
.container .showMoreButton:hover {
78
+
text-decoration: underline;
79
+
}
80
+
81
+
.commentContainer {
82
+
margin: 1rem 0;
83
+
font-size: 0.875rem;
84
+
}
85
+
86
+
.commentContent {
87
+
display: flex;
88
+
max-width: 36rem;
89
+
flex-direction: column;
90
+
gap: 0.5rem;
91
+
}
92
+
93
+
.authorLink {
94
+
display: flex;
95
+
flex-direction: row;
96
+
justify-content: flex-start;
97
+
align-items: center;
98
+
gap: 0.5rem;
99
+
}
100
+
101
+
.authorLink:hover {
102
+
text-decoration: underline;
103
+
}
104
+
105
+
.avatar {
106
+
height: 1rem;
107
+
width: 1rem;
108
+
flex-shrink: 0;
109
+
border-radius: 9999px;
110
+
background-color: #d1d5db;
111
+
}
112
+
113
+
.authorName {
114
+
overflow: hidden;
115
+
text-overflow: ellipsis;
116
+
white-space: nowrap;
117
+
display: -webkit-box;
118
+
-webkit-line-clamp: 1;
119
+
-webkit-box-orient: vertical;
120
+
}
121
+
122
+
.container a {
123
+
text-decoration: none;
124
+
color: inherit;
125
+
}
126
+
127
+
.container a:hover {
128
+
text-decoration: none;
129
+
}
130
+
131
+
.commentContent .handle {
132
+
color: #6b7280;
133
+
}
134
+
.repliesContainer {
135
+
border-left: 2px solid #525252;
136
+
padding-left: 0.5rem;
137
+
}
138
+
139
+
.actionsContainer {
140
+
margin-top: 0.5rem;
141
+
display: flex;
142
+
width: 100%;
143
+
max-width: 150px;
144
+
flex-direction: row;
145
+
align-items: center;
146
+
justify-content: space-between;
147
+
opacity: 0.6;
148
+
}
149
+
150
+
151
+
.actionsRow {
152
+
display: flex;
153
+
align-items: center;
154
+
gap: 0.25rem;
155
+
}
156
+
`;
157
+
158
+
return (
159
+
<>
160
+
<style>{styles}</style>
161
+
<a href={postUrl} target="_blank" rel="noreferrer noopener">
162
+
<p className="statsBar">
163
+
<span className="statItem">
164
+
<svg
165
+
className="icon"
166
+
xmlns="http://www.w3.org/2000/svg"
167
+
fill="pink"
168
+
viewBox="0 0 24 24"
169
+
strokeWidth="1.5"
170
+
stroke="pink"
171
+
flood-color="pink"
172
+
>
173
+
<path
174
+
strokeLinecap="round"
175
+
strokeLinejoin="round"
176
+
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
177
+
/>
178
+
</svg>
179
+
<span>{post.likeCount ?? 0} likes</span>
180
+
</span>
181
+
<span className="statItem">
182
+
<svg
183
+
className="icon"
184
+
xmlns="http://www.w3.org/2000/svg"
185
+
fill="none"
186
+
viewBox="0 0 24 24"
187
+
strokeWidth="1.5"
188
+
stroke="green"
189
+
>
190
+
<path
191
+
strokeLinecap="round"
192
+
strokeLinejoin="round"
193
+
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"
194
+
/>
195
+
</svg>
196
+
<span>{post.repostCount ?? 0} reposts</span>
197
+
</span>
198
+
<span className="statItem">
199
+
<svg
200
+
className="icon"
201
+
xmlns="http://www.w3.org/2000/svg"
202
+
fill="#7FBADC"
203
+
viewBox="0 0 24 24"
204
+
strokeWidth="1.5"
205
+
stroke="#7FBADC"
206
+
>
207
+
<path
208
+
strokeLinecap="round"
209
+
strokeLinejoin="round"
210
+
d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z"
211
+
/>
212
+
</svg>
213
+
<span>{post.replyCount ?? 0} replies</span>
214
+
</span>
215
+
</p>
216
+
</a>
217
+
<h2 className="commentsTitle">Comments</h2>
218
+
<p className="replyText">
219
+
Join the conversation by{' '}
220
+
<a
221
+
className="link"
222
+
href={postUrl}
223
+
target="_blank"
224
+
rel="noreferrer noopener"
225
+
>
226
+
replying on Bluesky
227
+
</a>
228
+
.
229
+
</p>
230
+
</>
231
+
);
232
+
};
+11
components/bsky-comments/types.tsx
+11
components/bsky-comments/types.tsx
+1
-2
components/post-info.tsx
+1
-2
components/post-info.tsx
+2
fresh.gen.ts
+2
fresh.gen.ts
···
7
7
import * as $index from "./routes/index.tsx";
8
8
import * as $post_slug_ from "./routes/post/[slug].tsx";
9
9
import * as $rss from "./routes/rss.ts";
10
+
import * as $CommentSection from "./islands/CommentSection.tsx";
10
11
import * as $post_list from "./islands/post-list.tsx";
11
12
import type { Manifest } from "$fresh/server.ts";
12
13
···
19
20
"./routes/rss.ts": $rss,
20
21
},
21
22
islands: {
23
+
"./islands/CommentSection.tsx": $CommentSection,
22
24
"./islands/post-list.tsx": $post_list,
23
25
},
24
26
baseUrl: import.meta.url,
+405
islands/CommentSection.tsx
+405
islands/CommentSection.tsx
···
1
+
import { useState, useEffect } from 'preact/hooks';
2
+
import { AppBskyFeedDefs, type AppBskyFeedGetPostThread } from 'npm:@atproto/api';
3
+
import { CommentOptions } from '../components/bsky-comments/types.tsx';
4
+
import { PostSummary } from '../components/bsky-comments/PostSummary.tsx';
5
+
import { Comment } from '../components/bsky-comments/Comment.tsx';
6
+
7
+
const getAtUri = (uri: string): string => {
8
+
if (!uri.startsWith('at://') && uri.includes('bsky.app/profile/')) {
9
+
const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/);
10
+
if (match) {
11
+
const [, did, postId] = match;
12
+
return `at://${did}/app.bsky.feed.post/${postId}`;
13
+
}
14
+
}
15
+
return uri;
16
+
};
17
+
18
+
/**
19
+
* This component displays a comment section for a post.
20
+
* It fetches the comments for a post and displays them in a threaded format.
21
+
*/
22
+
export const CommentSection = ({
23
+
uri: propUri,
24
+
author,
25
+
onEmpty,
26
+
commentFilters,
27
+
}: CommentOptions): any => {
28
+
const [uri, setUri] = useState<string | null>(null);
29
+
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(null);
30
+
const [error, setError] = useState<string | null>(null);
31
+
const [visibleCount, setVisibleCount] = useState(5);
32
+
33
+
const styles = `
34
+
.container {
35
+
max-width: 740px;
36
+
margin: 0 auto;
37
+
}
38
+
39
+
.statsBar {
40
+
display: flex;
41
+
align-items: center;
42
+
gap: 0.5rem;
43
+
}
44
+
45
+
.statsBar:hover {
46
+
text-decoration: underline;
47
+
}
48
+
49
+
.statItem {
50
+
display: flex;
51
+
align-items: center;
52
+
gap: 0.25rem;
53
+
white-space: nowrap;
54
+
}
55
+
56
+
.container a.link {
57
+
text-decoration: underline;
58
+
}
59
+
60
+
.container a.link:hover {
61
+
text-decoration: underline;
62
+
}
63
+
64
+
.icon {
65
+
width: 1.25rem;
66
+
height: 1.25rem;
67
+
}
68
+
69
+
.errorText, .loadingText {
70
+
text-align: center;
71
+
}
72
+
73
+
.commentsTitle {
74
+
margin-top: 1.5rem;
75
+
font-size: 1.25rem;
76
+
font-weight: bold;
77
+
}
78
+
79
+
.replyText {
80
+
margin-top: 0.5rem;
81
+
font-size: 0.875rem;
82
+
}
83
+
84
+
.divider {
85
+
margin-top: 0.5rem;
86
+
}
87
+
88
+
.commentsList {
89
+
margin-top: 0.5rem;
90
+
display: flex;
91
+
flex-direction: column;
92
+
gap: 0.5rem;
93
+
}
94
+
95
+
.container .showMoreButton {
96
+
margin-top: 0.5rem;
97
+
font-size: 0.875rem;
98
+
text-decoration: underline;
99
+
cursor: pointer;
100
+
}
101
+
102
+
.container .showMoreButton:hover {
103
+
text-decoration: underline;
104
+
}
105
+
106
+
.commentContainer {
107
+
margin: 1rem 0;
108
+
font-size: 0.875rem;
109
+
}
110
+
111
+
.commentContent {
112
+
display: flex;
113
+
max-width: 36rem;
114
+
flex-direction: column;
115
+
gap: 0.5rem;
116
+
}
117
+
118
+
.authorLink {
119
+
display: flex;
120
+
flex-direction: row;
121
+
justify-content: flex-start;
122
+
align-items: center;
123
+
gap: 0.5rem;
124
+
}
125
+
126
+
.authorLink:hover {
127
+
text-decoration: underline;
128
+
}
129
+
130
+
.avatar {
131
+
height: 1rem;
132
+
width: 1rem;
133
+
flex-shrink: 0;
134
+
border-radius: 9999px;
135
+
background-color: #d1d5db;
136
+
}
137
+
138
+
.authorName {
139
+
overflow: hidden;
140
+
text-overflow: ellipsis;
141
+
white-space: nowrap;
142
+
display: -webkit-box;
143
+
-webkit-line-clamp: 1;
144
+
-webkit-box-orient: vertical;
145
+
}
146
+
147
+
.container a {
148
+
text-decoration: none;
149
+
color: inherit;
150
+
}
151
+
152
+
.container a:hover {
153
+
text-decoration: none;
154
+
}
155
+
156
+
.commentContent .handle {
157
+
color: #6b7280;
158
+
}
159
+
.repliesContainer {
160
+
border-left: 2px solid #525252;
161
+
padding-left: 0.5rem;
162
+
}
163
+
164
+
.actionsContainer {
165
+
margin-top: 0.5rem;
166
+
display: flex;
167
+
width: 100%;
168
+
max-width: 150px;
169
+
flex-direction: row;
170
+
align-items: center;
171
+
justify-content: space-between;
172
+
opacity: 0.6;
173
+
}
174
+
175
+
176
+
.actionsRow {
177
+
display: flex;
178
+
align-items: center;
179
+
gap: 0.25rem;
180
+
}
181
+
182
+
183
+
.font-sans { font-family: var(--font-sans); }
184
+
.font-serif { font-family: var(--font-serif); }
185
+
.font-mono { font-family: var(--font-mono); }
186
+
187
+
h1 {
188
+
font-family: var(--font-serif);
189
+
text-transform: uppercase;
190
+
font-size: 2.25rem;
191
+
}
192
+
193
+
h2 {
194
+
font-family: var(--font-serif);
195
+
text-transform: uppercase;
196
+
font-size: 1.75rem;
197
+
}
198
+
199
+
h3 {
200
+
font-family: var(--font-serif);
201
+
text-transform: uppercase;
202
+
font-size: 1.5rem;
203
+
}
204
+
205
+
h4 {
206
+
font-family: var(--font-serif);
207
+
text-transform: uppercase;
208
+
font-size: 1.25rem;
209
+
}
210
+
211
+
h5 {
212
+
font-family: var(--font-serif);
213
+
text-transform: uppercase;
214
+
font-size: 1rem;
215
+
}
216
+
217
+
h6 {
218
+
font-family: var(--font-serif);
219
+
text-transform: uppercase;
220
+
font-size: 0.875rem;
221
+
}
222
+
`;
223
+
224
+
useEffect(() => {
225
+
let isSubscribed = true;
226
+
227
+
const initializeUri = async () => {
228
+
if (propUri) {
229
+
setUri(propUri);
230
+
return;
231
+
}
232
+
233
+
if (author) {
234
+
try {
235
+
const currentUrl = window.location.href;
236
+
const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent(
237
+
currentUrl
238
+
)}&author=${author}&sort=top`;
239
+
240
+
const response = await fetch(apiUrl);
241
+
const data = await response.json();
242
+
243
+
if (isSubscribed) {
244
+
if (data.posts && data.posts.length > 0) {
245
+
const post = data.posts[0];
246
+
setUri(post.uri);
247
+
} else {
248
+
setError('No matching post found');
249
+
if (onEmpty) {
250
+
onEmpty({ code: 'not_found', message: 'No matching post found' });
251
+
}
252
+
}
253
+
}
254
+
} catch (err) {
255
+
if (isSubscribed) {
256
+
setError('Error fetching post');
257
+
if (onEmpty) {
258
+
onEmpty({ code: 'fetching_error', message: 'Error fetching post' });
259
+
}
260
+
}
261
+
}
262
+
}
263
+
};
264
+
265
+
initializeUri();
266
+
267
+
return () => {
268
+
isSubscribed = false;
269
+
};
270
+
}, [propUri, author, onEmpty]);
271
+
272
+
useEffect(() => {
273
+
let isSubscribed = true;
274
+
275
+
const fetchThreadData = async () => {
276
+
if (!uri) return;
277
+
278
+
try {
279
+
const thread = await getPostThread(uri);
280
+
if (isSubscribed) {
281
+
setThread(thread);
282
+
}
283
+
} catch (err) {
284
+
if (isSubscribed) {
285
+
setError('Error loading comments');
286
+
if (onEmpty) {
287
+
onEmpty({
288
+
code: 'comment_loading_error',
289
+
message: 'Error loading comments',
290
+
});
291
+
}
292
+
}
293
+
}
294
+
};
295
+
296
+
fetchThreadData();
297
+
298
+
return () => {
299
+
isSubscribed = false;
300
+
};
301
+
}, [uri, onEmpty]);
302
+
303
+
const showMore = () => {
304
+
setVisibleCount((prevCount) => prevCount + 5);
305
+
};
306
+
307
+
if (!uri) return null;
308
+
309
+
if (error) {
310
+
return <div className="container"><style>{styles}</style><p className="errorText">{error}</p></div>;
311
+
}
312
+
313
+
if (!thread) {
314
+
return <div className="container"><style>{styles}</style><p className="loadingText">Loading comments...</p></div>;
315
+
}
316
+
317
+
let postUrl: string = uri;
318
+
if (uri.startsWith('at://')) {
319
+
const [, , did, _, rkey] = uri.split('/');
320
+
postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
321
+
}
322
+
323
+
if (!thread.replies || thread.replies.length === 0) {
324
+
return (
325
+
<div className="container">
326
+
<style>{styles}</style>
327
+
<PostSummary postUrl={postUrl} post={thread.post} />
328
+
</div>
329
+
);
330
+
}
331
+
332
+
// Safe sort - ensure we're working with valid objects
333
+
const sortedReplies = [...thread.replies].filter(reply =>
334
+
AppBskyFeedDefs.isThreadViewPost(reply)
335
+
).sort(sortByLikes);
336
+
337
+
return (
338
+
<div className="container">
339
+
<style>{styles}</style>
340
+
<PostSummary postUrl={postUrl} post={thread.post} />
341
+
<hr className="divider" />
342
+
<div className="commentsList">
343
+
{sortedReplies.slice(0, visibleCount).map((reply) => {
344
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
345
+
return (
346
+
<Comment
347
+
key={reply.post.uri}
348
+
comment={reply}
349
+
filters={commentFilters}
350
+
/>
351
+
);
352
+
})}
353
+
{visibleCount < sortedReplies.length && (
354
+
<button onClick={showMore} className="showMoreButton">
355
+
Show more comments
356
+
</button>
357
+
)}
358
+
</div>
359
+
</div>
360
+
);
361
+
};
362
+
363
+
const getPostThread = async (uri: string): Promise<AppBskyFeedDefs.ThreadViewPost> => {
364
+
const atUri = getAtUri(uri);
365
+
const params = new URLSearchParams({ uri: atUri });
366
+
367
+
const res = await fetch(
368
+
'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?' +
369
+
params.toString(),
370
+
{
371
+
method: 'GET',
372
+
headers: {
373
+
Accept: 'application/json',
374
+
},
375
+
cache: 'no-store',
376
+
}
377
+
);
378
+
379
+
if (!res.ok) {
380
+
console.error(await res.text());
381
+
throw new Error('Failed to fetch post thread');
382
+
}
383
+
384
+
const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema;
385
+
386
+
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
387
+
throw new Error('Could not find thread');
388
+
}
389
+
390
+
return data.thread;
391
+
};
392
+
393
+
const sortByLikes = (a: unknown, b: unknown) => {
394
+
if (
395
+
!AppBskyFeedDefs.isThreadViewPost(a) ||
396
+
!AppBskyFeedDefs.isThreadViewPost(b) ||
397
+
!('post' in a) ||
398
+
!('post' in b)
399
+
) {
400
+
return 0;
401
+
}
402
+
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
403
+
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
404
+
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
405
+
};
+4
routes/post/[slug].tsx
+4
routes/post/[slug].tsx
···
7
7
import { Title } from "../../components/typography.tsx";
8
8
import { getPost } from "../../lib/api.ts";
9
9
import { Head } from "$fresh/runtime.ts";
10
+
import { CommentSection } from "../../islands/CommentSection.tsx";
10
11
11
12
interface Post {
12
13
uri: string;
···
145
146
/>
146
147
</div>
147
148
</article>
149
+
<CommentSection
150
+
author="knotbin.xyz"
151
+
/>
148
152
</main>
149
153
<Footer />
150
154
</div>