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