Leaflet Blog in Deno Fresh

comments

Changed files
+1005 -2
components
islands
routes
post
+283
components/bsky-comments/Comment.tsx
···
+
import { AppBskyFeedDefs, AppBskyFeedPost } from 'npm:@atproto/api';
+
+
type CommentProps = {
+
comment: AppBskyFeedDefs.ThreadViewPost;
+
filters?: Array<(arg: any) => boolean>;
+
};
+
+
export const Comment = ({ comment, filters }: CommentProps) => {
+
const author = comment.post.author;
+
const avatarClassName = "avatar";
+
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) return null;
+
// filter out replies that match any of the commentFilters, by ensuring they all return false
+
if (filters && !filters.every((filter) => !filter(comment))) return null;
+
+
+
const styles = `
+
.container {
+
max-width: 740px;
+
margin: 0 auto;
+
}
+
+
.statsBar {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.statsBar:hover {
+
text-decoration: underline;
+
}
+
+
.statItem {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
white-space: nowrap;
+
}
+
+
.container a.link {
+
text-decoration: underline;
+
}
+
+
.container a.link:hover {
+
text-decoration: underline;
+
}
+
+
.icon {
+
width: 1.25rem;
+
height: 1.25rem;
+
}
+
+
.errorText, .loadingText {
+
text-align: center;
+
}
+
+
.commentsTitle {
+
margin-top: 1.5rem;
+
font-size: 1.25rem;
+
font-weight: bold;
+
}
+
+
.replyText {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
}
+
+
.divider {
+
margin-top: 0.5rem;
+
}
+
+
.commentsList {
+
margin-top: 0.5rem;
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.container .showMoreButton {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
text-decoration: underline;
+
}
+
+
.container .showMoreButton:hover {
+
text-decoration: underline;
+
}
+
+
.commentContainer {
+
margin: 1rem 0;
+
font-size: 0.875rem;
+
}
+
+
.commentContent {
+
display: flex;
+
max-width: 36rem;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.authorLink {
+
display: flex;
+
flex-direction: row;
+
justify-content: flex-start;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.authorLink:hover {
+
text-decoration: underline;
+
}
+
+
.avatar {
+
height: 1rem;
+
width: 1rem;
+
flex-shrink: 0;
+
border-radius: 9999px;
+
background-color: #d1d5db;
+
}
+
+
.authorName {
+
overflow: hidden;
+
text-overflow: ellipsis;
+
white-space: nowrap;
+
display: -webkit-box;
+
-webkit-line-clamp: 1;
+
-webkit-box-orient: vertical;
+
}
+
+
.container a {
+
text-decoration: none;
+
color: inherit;
+
}
+
+
.container a:hover {
+
text-decoration: none;
+
}
+
+
.commentContent .handle {
+
color: #6b7280;
+
}
+
.repliesContainer {
+
border-left: 2px solid #525252;
+
padding-left: 0.5rem;
+
}
+
+
.actionsContainer {
+
margin-top: 0.5rem;
+
display: flex;
+
width: 100%;
+
max-width: 150px;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
opacity: 0.6;
+
}
+
+
+
.actionsRow {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
}
+
`;
+
+
return (
+
<div className="commentContainer">
+
<style>{styles}</style>
+
<div className="commentContent">
+
<a
+
className="authorLink"
+
href={`https://bsky.app/profile/${author.did}`}
+
target="_blank"
+
rel="noreferrer noopener"
+
>
+
{author.avatar ? (
+
<img
+
src={comment.post.author.avatar}
+
alt="avatar"
+
className={avatarClassName}
+
/>
+
) : (
+
<div className={avatarClassName} />
+
)}
+
<p className="authorName">
+
{author.displayName ?? author.handle}{' '}
+
<span className="handle">@{author.handle}</span>
+
</p>
+
</a>
+
<a
+
href={`https://bsky.app/profile/${author.did}/post/${comment.post.uri
+
.split('/')
+
.pop()}`}
+
target="_blank"
+
rel="noreferrer noopener"
+
>
+
<p>{(comment.post.record as AppBskyFeedPost.Record).text}</p>
+
<Actions post={comment.post} />
+
</a>
+
</div>
+
{comment.replies && comment.replies.length > 0 && (
+
<div className="repliesContainer">
+
{comment.replies.sort(sortByLikes).map((reply) => {
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
+
return (
+
<Comment key={reply.post.uri} comment={reply} filters={filters} />
+
);
+
})}
+
</div>
+
)}
+
</div>
+
);
+
};
+
+
const Actions = ({ post }: { post: AppBskyFeedDefs.PostView }) => (
+
<div className="actionsContainer">
+
<div className="actionsRow">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="none"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="currentColor"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<p className="text-xs">{post.replyCount ?? 0}</p>
+
</div>
+
<div className="actionsRow">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="none"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="currentColor"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<p className="text-xs">{post.repostCount ?? 0}</p>
+
</div>
+
<div className="actionsRow">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="none"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="currentColor"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<p className="text-xs">{post.likeCount ?? 0}</p>
+
</div>
+
</div>
+
);
+
+
const sortByLikes = (a: unknown, b: unknown) => {
+
if (
+
!AppBskyFeedDefs.isThreadViewPost(a) ||
+
!AppBskyFeedDefs.isThreadViewPost(b) ||
+
!('post' in a) ||
+
!('post' in b)
+
) {
+
return 0;
+
}
+
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
+
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
+
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
+
};
+67
components/bsky-comments/CommentFilters.tsx
···
+
import { AppBskyFeedPost, type AppBskyFeedDefs } from 'npm:@atproto/api';
+
+
const MinLikeCountFilter = (
+
min: number
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
+
return (comment.post.likeCount ?? 0) < min;
+
};
+
};
+
+
const MinCharacterCountFilter = (
+
min: number
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
+
return false;
+
}
+
const record = comment.post.record as AppBskyFeedPost.Record;
+
return record.text.length < min;
+
};
+
};
+
+
const TextContainsFilter = (
+
text: string
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
+
return false;
+
}
+
const record = comment.post.record as AppBskyFeedPost.Record;
+
return record.text.toLowerCase().includes(text.toLowerCase());
+
};
+
};
+
+
const ExactMatchFilter = (
+
text: string
+
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
+
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
+
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
+
return false;
+
}
+
const record = comment.post.record as AppBskyFeedPost.Record;
+
return record.text.toLowerCase() === text.toLowerCase();
+
};
+
};
+
+
/*
+
* This function allows you to filter out comments based on likes,
+
* characters, text, pins, or exact matches.
+
*/
+
export const Filters: {
+
MinLikeCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
MinCharacterCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
TextContainsFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
ExactMatchFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
} = {
+
MinLikeCountFilter,
+
MinCharacterCountFilter,
+
TextContainsFilter,
+
ExactMatchFilter,
+
NoLikes: MinLikeCountFilter(0),
+
NoPins: ExactMatchFilter('📌'),
+
};
+
+
export default Filters;
+232
components/bsky-comments/PostSummary.tsx
···
+
import { AppBskyFeedDefs } from 'npm:@atproto/api';
+
+
type PostSummaryProps = {
+
postUrl: string;
+
post: AppBskyFeedDefs.PostView;
+
};
+
+
export const PostSummary = ({ postUrl, post }: PostSummaryProps) => {
+
const styles = `
+
.container {
+
max-width: 740px;
+
margin: 0 auto;
+
}
+
+
.statsBar {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.statsBar:hover {
+
text-decoration: underline;
+
}
+
+
.statItem {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
white-space: nowrap;
+
}
+
+
.container a.link {
+
text-decoration: underline;
+
}
+
+
.container a.link:hover {
+
text-decoration: underline;
+
}
+
+
.icon {
+
width: 1.25rem;
+
height: 1.25rem;
+
}
+
+
.errorText, .loadingText {
+
text-align: center;
+
}
+
+
.commentsTitle {
+
margin-top: 1.5rem;
+
font-size: 1.25rem;
+
font-weight: bold;
+
}
+
+
.replyText {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
}
+
+
.divider {
+
margin-top: 0.5rem;
+
}
+
+
.commentsList {
+
margin-top: 0.5rem;
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.container .showMoreButton {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
text-decoration: underline;
+
}
+
+
.container .showMoreButton:hover {
+
text-decoration: underline;
+
}
+
+
.commentContainer {
+
margin: 1rem 0;
+
font-size: 0.875rem;
+
}
+
+
.commentContent {
+
display: flex;
+
max-width: 36rem;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.authorLink {
+
display: flex;
+
flex-direction: row;
+
justify-content: flex-start;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.authorLink:hover {
+
text-decoration: underline;
+
}
+
+
.avatar {
+
height: 1rem;
+
width: 1rem;
+
flex-shrink: 0;
+
border-radius: 9999px;
+
background-color: #d1d5db;
+
}
+
+
.authorName {
+
overflow: hidden;
+
text-overflow: ellipsis;
+
white-space: nowrap;
+
display: -webkit-box;
+
-webkit-line-clamp: 1;
+
-webkit-box-orient: vertical;
+
}
+
+
.container a {
+
text-decoration: none;
+
color: inherit;
+
}
+
+
.container a:hover {
+
text-decoration: none;
+
}
+
+
.commentContent .handle {
+
color: #6b7280;
+
}
+
.repliesContainer {
+
border-left: 2px solid #525252;
+
padding-left: 0.5rem;
+
}
+
+
.actionsContainer {
+
margin-top: 0.5rem;
+
display: flex;
+
width: 100%;
+
max-width: 150px;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
opacity: 0.6;
+
}
+
+
+
.actionsRow {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
}
+
`;
+
+
return (
+
<>
+
<style>{styles}</style>
+
<a href={postUrl} target="_blank" rel="noreferrer noopener">
+
<p className="statsBar">
+
<span className="statItem">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="pink"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="pink"
+
flood-color="pink"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<span>{post.likeCount ?? 0} likes</span>
+
</span>
+
<span className="statItem">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="none"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="green"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<span>{post.repostCount ?? 0} reposts</span>
+
</span>
+
<span className="statItem">
+
<svg
+
className="icon"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="#7FBADC"
+
viewBox="0 0 24 24"
+
strokeWidth="1.5"
+
stroke="#7FBADC"
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
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"
+
/>
+
</svg>
+
<span>{post.replyCount ?? 0} replies</span>
+
</span>
+
</p>
+
</a>
+
<h2 className="commentsTitle">Comments</h2>
+
<p className="replyText">
+
Join the conversation by{' '}
+
<a
+
className="link"
+
href={postUrl}
+
target="_blank"
+
rel="noreferrer noopener"
+
>
+
replying on Bluesky
+
</a>
+
.
+
</p>
+
</>
+
);
+
};
+11
components/bsky-comments/types.tsx
···
+
export interface CommentEmptyDetails {
+
code: string;
+
message: string;
+
}
+
+
export interface CommentOptions {
+
uri?: string;
+
author?: string;
+
commentFilters?: Array<(arg: any) => boolean>;
+
onEmpty?: (details: CommentEmptyDetails) => void;
+
}
+1 -2
components/post-info.tsx
···
)}
{createdAt && (
<>
-
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "}
-
&middot;{" "}
+
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>
</>
)}
{children}
+2
fresh.gen.ts
···
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $rss from "./routes/rss.ts";
+
import * as $CommentSection from "./islands/CommentSection.tsx";
import * as $post_list from "./islands/post-list.tsx";
import type { Manifest } from "$fresh/server.ts";
···
"./routes/rss.ts": $rss,
},
islands: {
+
"./islands/CommentSection.tsx": $CommentSection,
"./islands/post-list.tsx": $post_list,
},
baseUrl: import.meta.url,
+405
islands/CommentSection.tsx
···
+
import { useState, useEffect } from 'preact/hooks';
+
import { AppBskyFeedDefs, type AppBskyFeedGetPostThread } from 'npm:@atproto/api';
+
import { CommentOptions } from '../components/bsky-comments/types.tsx';
+
import { PostSummary } from '../components/bsky-comments/PostSummary.tsx';
+
import { Comment } from '../components/bsky-comments/Comment.tsx';
+
+
const getAtUri = (uri: string): string => {
+
if (!uri.startsWith('at://') && uri.includes('bsky.app/profile/')) {
+
const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/);
+
if (match) {
+
const [, did, postId] = match;
+
return `at://${did}/app.bsky.feed.post/${postId}`;
+
}
+
}
+
return uri;
+
};
+
+
/**
+
* This component displays a comment section for a post.
+
* It fetches the comments for a post and displays them in a threaded format.
+
*/
+
export const CommentSection = ({
+
uri: propUri,
+
author,
+
onEmpty,
+
commentFilters,
+
}: CommentOptions): any => {
+
const [uri, setUri] = useState<string | null>(null);
+
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(null);
+
const [error, setError] = useState<string | null>(null);
+
const [visibleCount, setVisibleCount] = useState(5);
+
+
const styles = `
+
.container {
+
max-width: 740px;
+
margin: 0 auto;
+
}
+
+
.statsBar {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.statsBar:hover {
+
text-decoration: underline;
+
}
+
+
.statItem {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
white-space: nowrap;
+
}
+
+
.container a.link {
+
text-decoration: underline;
+
}
+
+
.container a.link:hover {
+
text-decoration: underline;
+
}
+
+
.icon {
+
width: 1.25rem;
+
height: 1.25rem;
+
}
+
+
.errorText, .loadingText {
+
text-align: center;
+
}
+
+
.commentsTitle {
+
margin-top: 1.5rem;
+
font-size: 1.25rem;
+
font-weight: bold;
+
}
+
+
.replyText {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
}
+
+
.divider {
+
margin-top: 0.5rem;
+
}
+
+
.commentsList {
+
margin-top: 0.5rem;
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.container .showMoreButton {
+
margin-top: 0.5rem;
+
font-size: 0.875rem;
+
text-decoration: underline;
+
cursor: pointer;
+
}
+
+
.container .showMoreButton:hover {
+
text-decoration: underline;
+
}
+
+
.commentContainer {
+
margin: 1rem 0;
+
font-size: 0.875rem;
+
}
+
+
.commentContent {
+
display: flex;
+
max-width: 36rem;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.authorLink {
+
display: flex;
+
flex-direction: row;
+
justify-content: flex-start;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.authorLink:hover {
+
text-decoration: underline;
+
}
+
+
.avatar {
+
height: 1rem;
+
width: 1rem;
+
flex-shrink: 0;
+
border-radius: 9999px;
+
background-color: #d1d5db;
+
}
+
+
.authorName {
+
overflow: hidden;
+
text-overflow: ellipsis;
+
white-space: nowrap;
+
display: -webkit-box;
+
-webkit-line-clamp: 1;
+
-webkit-box-orient: vertical;
+
}
+
+
.container a {
+
text-decoration: none;
+
color: inherit;
+
}
+
+
.container a:hover {
+
text-decoration: none;
+
}
+
+
.commentContent .handle {
+
color: #6b7280;
+
}
+
.repliesContainer {
+
border-left: 2px solid #525252;
+
padding-left: 0.5rem;
+
}
+
+
.actionsContainer {
+
margin-top: 0.5rem;
+
display: flex;
+
width: 100%;
+
max-width: 150px;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
opacity: 0.6;
+
}
+
+
+
.actionsRow {
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
}
+
+
+
.font-sans { font-family: var(--font-sans); }
+
.font-serif { font-family: var(--font-serif); }
+
.font-mono { font-family: var(--font-mono); }
+
+
h1 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 2.25rem;
+
}
+
+
h2 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 1.75rem;
+
}
+
+
h3 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 1.5rem;
+
}
+
+
h4 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 1.25rem;
+
}
+
+
h5 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 1rem;
+
}
+
+
h6 {
+
font-family: var(--font-serif);
+
text-transform: uppercase;
+
font-size: 0.875rem;
+
}
+
`;
+
+
useEffect(() => {
+
let isSubscribed = true;
+
+
const initializeUri = async () => {
+
if (propUri) {
+
setUri(propUri);
+
return;
+
}
+
+
if (author) {
+
try {
+
const currentUrl = window.location.href;
+
const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent(
+
currentUrl
+
)}&author=${author}&sort=top`;
+
+
const response = await fetch(apiUrl);
+
const data = await response.json();
+
+
if (isSubscribed) {
+
if (data.posts && data.posts.length > 0) {
+
const post = data.posts[0];
+
setUri(post.uri);
+
} else {
+
setError('No matching post found');
+
if (onEmpty) {
+
onEmpty({ code: 'not_found', message: 'No matching post found' });
+
}
+
}
+
}
+
} catch (err) {
+
if (isSubscribed) {
+
setError('Error fetching post');
+
if (onEmpty) {
+
onEmpty({ code: 'fetching_error', message: 'Error fetching post' });
+
}
+
}
+
}
+
}
+
};
+
+
initializeUri();
+
+
return () => {
+
isSubscribed = false;
+
};
+
}, [propUri, author, onEmpty]);
+
+
useEffect(() => {
+
let isSubscribed = true;
+
+
const fetchThreadData = async () => {
+
if (!uri) return;
+
+
try {
+
const thread = await getPostThread(uri);
+
if (isSubscribed) {
+
setThread(thread);
+
}
+
} catch (err) {
+
if (isSubscribed) {
+
setError('Error loading comments');
+
if (onEmpty) {
+
onEmpty({
+
code: 'comment_loading_error',
+
message: 'Error loading comments',
+
});
+
}
+
}
+
}
+
};
+
+
fetchThreadData();
+
+
return () => {
+
isSubscribed = false;
+
};
+
}, [uri, onEmpty]);
+
+
const showMore = () => {
+
setVisibleCount((prevCount) => prevCount + 5);
+
};
+
+
if (!uri) return null;
+
+
if (error) {
+
return <div className="container"><style>{styles}</style><p className="errorText">{error}</p></div>;
+
}
+
+
if (!thread) {
+
return <div className="container"><style>{styles}</style><p className="loadingText">Loading comments...</p></div>;
+
}
+
+
let postUrl: string = uri;
+
if (uri.startsWith('at://')) {
+
const [, , did, _, rkey] = uri.split('/');
+
postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
+
}
+
+
if (!thread.replies || thread.replies.length === 0) {
+
return (
+
<div className="container">
+
<style>{styles}</style>
+
<PostSummary postUrl={postUrl} post={thread.post} />
+
</div>
+
);
+
}
+
+
// Safe sort - ensure we're working with valid objects
+
const sortedReplies = [...thread.replies].filter(reply =>
+
AppBskyFeedDefs.isThreadViewPost(reply)
+
).sort(sortByLikes);
+
+
return (
+
<div className="container">
+
<style>{styles}</style>
+
<PostSummary postUrl={postUrl} post={thread.post} />
+
<hr className="divider" />
+
<div className="commentsList">
+
{sortedReplies.slice(0, visibleCount).map((reply) => {
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
+
return (
+
<Comment
+
key={reply.post.uri}
+
comment={reply}
+
filters={commentFilters}
+
/>
+
);
+
})}
+
{visibleCount < sortedReplies.length && (
+
<button onClick={showMore} className="showMoreButton">
+
Show more comments
+
</button>
+
)}
+
</div>
+
</div>
+
);
+
};
+
+
const getPostThread = async (uri: string): Promise<AppBskyFeedDefs.ThreadViewPost> => {
+
const atUri = getAtUri(uri);
+
const params = new URLSearchParams({ uri: atUri });
+
+
const res = await fetch(
+
'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?' +
+
params.toString(),
+
{
+
method: 'GET',
+
headers: {
+
Accept: 'application/json',
+
},
+
cache: 'no-store',
+
}
+
);
+
+
if (!res.ok) {
+
console.error(await res.text());
+
throw new Error('Failed to fetch post thread');
+
}
+
+
const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema;
+
+
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+
throw new Error('Could not find thread');
+
}
+
+
return data.thread;
+
};
+
+
const sortByLikes = (a: unknown, b: unknown) => {
+
if (
+
!AppBskyFeedDefs.isThreadViewPost(a) ||
+
!AppBskyFeedDefs.isThreadViewPost(b) ||
+
!('post' in a) ||
+
!('post' in b)
+
) {
+
return 0;
+
}
+
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
+
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
+
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
+
};
+4
routes/post/[slug].tsx
···
import { Title } from "../../components/typography.tsx";
import { getPost } from "../../lib/api.ts";
import { Head } from "$fresh/runtime.ts";
+
import { CommentSection } from "../../islands/CommentSection.tsx";
interface Post {
uri: string;
···
/>
</div>
</article>
+
<CommentSection
+
author="knotbin.xyz"
+
/>
</main>
<Footer />
</div>