tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
288
fork
atom
a tool for shared writing and social publishing
288
fork
atom
overview
issues
26
pulls
pipelines
show quotes on bsky posts
awarm.space
2 months ago
ae3813a4
728a8393
+525
-227
7 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
BlueskyQuotesPage.tsx
BskyPostContent.tsx
Interactions
Quotes.tsx
PostLinks.tsx
PostPages.tsx
PublishBskyPostBlock.tsx
ThreadPage.tsx
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
···
1
1
+
"use client";
2
2
+
import { AppBskyFeedDefs } from "@atproto/api";
3
3
+
import useSWR from "swr";
4
4
+
import { PageWrapper } from "components/Pages/Page";
5
5
+
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
6
+
import { DotLoader } from "components/utils/DotLoader";
7
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
8
+
import { openPage } from "./PostPages";
9
9
+
import { BskyPostContent } from "./BskyPostContent";
10
10
+
import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks";
11
11
+
12
12
+
// Re-export for backwards compatibility
13
13
+
export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes };
14
14
+
15
15
+
type PostView = AppBskyFeedDefs.PostView;
16
16
+
17
17
+
export function BlueskyQuotesPage(props: {
18
18
+
postUri: string;
19
19
+
pageId: string;
20
20
+
pageOptions?: React.ReactNode;
21
21
+
hasPageBackground: boolean;
22
22
+
}) {
23
23
+
const { postUri, pageId, pageOptions } = props;
24
24
+
const drawer = useDrawerOpen(postUri);
25
25
+
26
26
+
const {
27
27
+
data: quotesData,
28
28
+
isLoading,
29
29
+
error,
30
30
+
} = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri));
31
31
+
32
32
+
return (
33
33
+
<PageWrapper
34
34
+
pageType="doc"
35
35
+
fullPageScroll={false}
36
36
+
id={`post-page-${pageId}`}
37
37
+
drawerOpen={!!drawer}
38
38
+
pageOptions={pageOptions}
39
39
+
>
40
40
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
41
41
+
<div className="text-secondary font-bold mb-3 flex items-center gap-2">
42
42
+
<QuoteTiny />
43
43
+
Bluesky Quotes
44
44
+
</div>
45
45
+
{isLoading ? (
46
46
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
47
47
+
<span>loading quotes</span>
48
48
+
<DotLoader />
49
49
+
</div>
50
50
+
) : error ? (
51
51
+
<div className="text-tertiary italic text-sm text-center py-8">
52
52
+
Failed to load quotes
53
53
+
</div>
54
54
+
) : quotesData && quotesData.posts.length > 0 ? (
55
55
+
<QuotesContent posts={quotesData.posts} postUri={postUri} />
56
56
+
) : (
57
57
+
<div className="text-tertiary italic text-sm text-center py-8">
58
58
+
No quotes yet
59
59
+
</div>
60
60
+
)}
61
61
+
</div>
62
62
+
</PageWrapper>
63
63
+
);
64
64
+
}
65
65
+
66
66
+
function QuotesContent(props: { posts: PostView[]; postUri: string }) {
67
67
+
const { posts, postUri } = props;
68
68
+
69
69
+
return (
70
70
+
<div className="flex flex-col gap-0">
71
71
+
{posts.map((post) => (
72
72
+
<QuotePost
73
73
+
key={post.uri}
74
74
+
post={post}
75
75
+
quotesUri={postUri}
76
76
+
/>
77
77
+
))}
78
78
+
</div>
79
79
+
);
80
80
+
}
81
81
+
82
82
+
function QuotePost(props: {
83
83
+
post: PostView;
84
84
+
quotesUri: string;
85
85
+
}) {
86
86
+
const { post, quotesUri } = props;
87
87
+
const parent = { type: "quotes" as const, uri: quotesUri };
88
88
+
89
89
+
return (
90
90
+
<div
91
91
+
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
92
92
+
onClick={() => openPage(parent, { type: "thread", uri: post.uri })}
93
93
+
>
94
94
+
<BskyPostContent
95
95
+
post={post}
96
96
+
parent={parent}
97
97
+
linksEnabled={true}
98
98
+
showEmbed={true}
99
99
+
showBlueskyLink={true}
100
100
+
onLinkClick={(e) => e.stopPropagation()}
101
101
+
onEmbedClick={(e) => e.stopPropagation()}
102
102
+
/>
103
103
+
</div>
104
104
+
);
105
105
+
}
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
···
1
1
+
"use client";
2
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
3
+
import {
4
4
+
BlueskyEmbed,
5
5
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
6
6
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
7
7
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
8
8
+
import { CommentTiny } from "components/Icons/CommentTiny";
9
9
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
10
10
+
import { Separator } from "components/Layout";
11
11
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
12
12
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
13
13
+
import { OpenPage } from "./PostPages";
14
14
+
import { ThreadLink, QuotesLink } from "./PostLinks";
15
15
+
16
16
+
type PostView = AppBskyFeedDefs.PostView;
17
17
+
18
18
+
export function BskyPostContent(props: {
19
19
+
post: PostView;
20
20
+
parent?: OpenPage;
21
21
+
linksEnabled?: boolean;
22
22
+
avatarSize?: "sm" | "md";
23
23
+
showEmbed?: boolean;
24
24
+
showBlueskyLink?: boolean;
25
25
+
onEmbedClick?: (e: React.MouseEvent) => void;
26
26
+
onLinkClick?: (e: React.MouseEvent) => void;
27
27
+
}) {
28
28
+
const {
29
29
+
post,
30
30
+
parent,
31
31
+
linksEnabled = true,
32
32
+
avatarSize = "md",
33
33
+
showEmbed = true,
34
34
+
showBlueskyLink = true,
35
35
+
onEmbedClick,
36
36
+
onLinkClick,
37
37
+
} = props;
38
38
+
39
39
+
const record = post.record as AppBskyFeedPost.Record;
40
40
+
const postId = post.uri.split("/")[4];
41
41
+
const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
42
42
+
43
43
+
const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10";
44
44
+
45
45
+
return (
46
46
+
<>
47
47
+
<div className="flex flex-col items-center shrink-0">
48
48
+
{post.author.avatar ? (
49
49
+
<img
50
50
+
src={post.author.avatar}
51
51
+
alt={`${post.author.displayName}'s avatar`}
52
52
+
className={`${avatarClass} rounded-full border border-border-light`}
53
53
+
/>
54
54
+
) : (
55
55
+
<div className={`${avatarClass} rounded-full border border-border-light bg-border`} />
56
56
+
)}
57
57
+
</div>
58
58
+
59
59
+
<div className="flex flex-col grow min-w-0">
60
60
+
<div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}>
61
61
+
<div className="font-bold text-secondary">
62
62
+
{post.author.displayName}
63
63
+
</div>
64
64
+
<a
65
65
+
className="text-xs text-tertiary hover:underline"
66
66
+
target="_blank"
67
67
+
href={`https://bsky.app/profile/${post.author.handle}`}
68
68
+
onClick={onLinkClick}
69
69
+
>
70
70
+
@{post.author.handle}
71
71
+
</a>
72
72
+
</div>
73
73
+
74
74
+
<div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}>
75
75
+
<div className="text-sm text-secondary">
76
76
+
<BlueskyRichText record={record} />
77
77
+
</div>
78
78
+
{showEmbed && post.embed && (
79
79
+
<div onClick={onEmbedClick}>
80
80
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
81
81
+
</div>
82
82
+
)}
83
83
+
</div>
84
84
+
85
85
+
<div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}>
86
86
+
<ClientDate date={record.createdAt} />
87
87
+
<PostCounts
88
88
+
post={post}
89
89
+
parent={parent}
90
90
+
linksEnabled={linksEnabled}
91
91
+
showBlueskyLink={showBlueskyLink}
92
92
+
url={url}
93
93
+
onLinkClick={onLinkClick}
94
94
+
/>
95
95
+
</div>
96
96
+
</div>
97
97
+
</>
98
98
+
);
99
99
+
}
100
100
+
101
101
+
function PostCounts(props: {
102
102
+
post: PostView;
103
103
+
parent?: OpenPage;
104
104
+
linksEnabled: boolean;
105
105
+
showBlueskyLink: boolean;
106
106
+
url: string;
107
107
+
onLinkClick?: (e: React.MouseEvent) => void;
108
108
+
}) {
109
109
+
const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props;
110
110
+
111
111
+
return (
112
112
+
<div className="flex gap-2 items-center">
113
113
+
{post.replyCount != null && post.replyCount > 0 && (
114
114
+
<>
115
115
+
<Separator classname="h-3" />
116
116
+
{linksEnabled ? (
117
117
+
<ThreadLink
118
118
+
threadUri={post.uri}
119
119
+
parent={parent}
120
120
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
121
121
+
onClick={onLinkClick}
122
122
+
>
123
123
+
{post.replyCount}
124
124
+
<CommentTiny />
125
125
+
</ThreadLink>
126
126
+
) : (
127
127
+
<div className="flex items-center gap-1 text-tertiary text-xs">
128
128
+
{post.replyCount}
129
129
+
<CommentTiny />
130
130
+
</div>
131
131
+
)}
132
132
+
</>
133
133
+
)}
134
134
+
{post.quoteCount != null && post.quoteCount > 0 && (
135
135
+
<>
136
136
+
<Separator classname="h-3" />
137
137
+
<QuotesLink
138
138
+
postUri={post.uri}
139
139
+
parent={parent}
140
140
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
141
141
+
onClick={onLinkClick}
142
142
+
>
143
143
+
{post.quoteCount}
144
144
+
<QuoteTiny />
145
145
+
</QuotesLink>
146
146
+
</>
147
147
+
)}
148
148
+
{showBlueskyLink && (
149
149
+
<>
150
150
+
<Separator classname="h-3" />
151
151
+
<a
152
152
+
className="text-tertiary"
153
153
+
target="_blank"
154
154
+
href={url}
155
155
+
onClick={onLinkClick}
156
156
+
>
157
157
+
<BlueskyTiny />
158
158
+
</a>
159
159
+
</>
160
160
+
)}
161
161
+
</div>
162
162
+
);
163
163
+
}
164
164
+
165
165
+
export const ClientDate = (props: { date?: string }) => {
166
166
+
const pageLoaded = useHasPageLoaded();
167
167
+
const formattedDate = useLocalizedDate(
168
168
+
props.date || new Date().toISOString(),
169
169
+
{
170
170
+
month: "short",
171
171
+
day: "numeric",
172
172
+
year: "numeric",
173
173
+
hour: "numeric",
174
174
+
minute: "numeric",
175
175
+
hour12: true,
176
176
+
},
177
177
+
);
178
178
+
179
179
+
if (!pageLoaded) return null;
180
180
+
181
181
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
182
182
+
};
+27
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
23
23
import useSWR, { mutate } from "swr";
24
24
import { DotLoader } from "components/utils/DotLoader";
25
25
import { CommentTiny } from "components/Icons/CommentTiny";
26
26
-
import { ThreadLink } from "../ThreadPage";
26
26
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
27
+
import { ThreadLink, QuotesLink } from "../PostLinks";
27
28
28
29
// Helper to get SWR key for quotes
29
30
export function getQuotesSWRKey(uris: string[]) {
···
138
139
profile={pv.author}
139
140
handle={pv.author.handle}
140
141
replyCount={pv.replyCount}
142
142
+
quoteCount={pv.quoteCount}
141
143
/>
142
144
</div>
143
145
);
···
161
163
profile={pv.author}
162
164
handle={pv.author.handle}
163
165
replyCount={pv.replyCount}
166
166
+
quoteCount={pv.quoteCount}
164
167
/>
165
168
);
166
169
})}
···
252
255
handle: string;
253
256
profile: ProfileViewBasic;
254
257
replyCount?: number;
258
258
+
quoteCount?: number;
255
259
}) => {
256
260
const handleOpenThread = () => {
257
261
openPage(undefined, { type: "thread", uri: props.uri });
···
282
286
</a>
283
287
</div>
284
288
<div className="text-primary">{props.content}</div>
285
285
-
{props.replyCount != null && props.replyCount > 0 && (
286
286
-
<ThreadLink
287
287
-
threadUri={props.uri}
288
288
-
onClick={(e) => e.stopPropagation()}
289
289
-
className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast"
290
290
-
>
291
291
-
<CommentTiny />
292
292
-
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
293
293
-
</ThreadLink>
294
294
-
)}
289
289
+
<div className="flex gap-2 items-center mt-1">
290
290
+
{props.replyCount != null && props.replyCount > 0 && (
291
291
+
<ThreadLink
292
292
+
threadUri={props.uri}
293
293
+
onClick={(e) => e.stopPropagation()}
294
294
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
295
295
+
>
296
296
+
<CommentTiny />
297
297
+
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
298
298
+
</ThreadLink>
299
299
+
)}
300
300
+
{props.quoteCount != null && props.quoteCount > 0 && (
301
301
+
<QuotesLink
302
302
+
postUri={props.uri}
303
303
+
onClick={(e) => e.stopPropagation()}
304
304
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
305
305
+
>
306
306
+
<QuoteTiny />
307
307
+
{props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"}
308
308
+
</QuotesLink>
309
309
+
)}
310
310
+
</div>
295
311
</div>
296
312
</div>
297
313
);
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
···
1
1
+
"use client";
2
2
+
import { AppBskyFeedDefs } from "@atproto/api";
3
3
+
import { preload } from "swr";
4
4
+
import { openPage, OpenPage } from "./PostPages";
5
5
+
6
6
+
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
7
7
+
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
8
8
+
type BlockedPost = AppBskyFeedDefs.BlockedPost;
9
9
+
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
10
10
+
11
11
+
type PostView = AppBskyFeedDefs.PostView;
12
12
+
13
13
+
export interface QuotesResponse {
14
14
+
uri: string;
15
15
+
cid?: string;
16
16
+
cursor?: string;
17
17
+
posts: PostView[];
18
18
+
}
19
19
+
20
20
+
// Thread fetching
21
21
+
export const getThreadKey = (uri: string) => `thread:${uri}`;
22
22
+
23
23
+
export async function fetchThread(uri: string): Promise<ThreadType> {
24
24
+
const params = new URLSearchParams({ uri });
25
25
+
const response = await fetch(`/api/bsky/thread?${params.toString()}`);
26
26
+
27
27
+
if (!response.ok) {
28
28
+
throw new Error("Failed to fetch thread");
29
29
+
}
30
30
+
31
31
+
return response.json();
32
32
+
}
33
33
+
34
34
+
export const prefetchThread = (uri: string) => {
35
35
+
preload(getThreadKey(uri), () => fetchThread(uri));
36
36
+
};
37
37
+
38
38
+
// Quotes fetching
39
39
+
export const getQuotesKey = (uri: string) => `quotes:${uri}`;
40
40
+
41
41
+
export async function fetchQuotes(uri: string): Promise<QuotesResponse> {
42
42
+
const params = new URLSearchParams({ uri });
43
43
+
const response = await fetch(`/api/bsky/quotes?${params.toString()}`);
44
44
+
45
45
+
if (!response.ok) {
46
46
+
throw new Error("Failed to fetch quotes");
47
47
+
}
48
48
+
49
49
+
return response.json();
50
50
+
}
51
51
+
52
52
+
export const prefetchQuotes = (uri: string) => {
53
53
+
preload(getQuotesKey(uri), () => fetchQuotes(uri));
54
54
+
};
55
55
+
56
56
+
// Link component for opening thread pages with prefetching
57
57
+
export function ThreadLink(props: {
58
58
+
threadUri: string;
59
59
+
parent?: OpenPage;
60
60
+
children: React.ReactNode;
61
61
+
className?: string;
62
62
+
onClick?: (e: React.MouseEvent) => void;
63
63
+
}) {
64
64
+
const { threadUri, parent, children, className, onClick } = props;
65
65
+
66
66
+
const handleClick = (e: React.MouseEvent) => {
67
67
+
onClick?.(e);
68
68
+
if (e.defaultPrevented) return;
69
69
+
openPage(parent, { type: "thread", uri: threadUri });
70
70
+
};
71
71
+
72
72
+
const handlePrefetch = () => {
73
73
+
prefetchThread(threadUri);
74
74
+
};
75
75
+
76
76
+
return (
77
77
+
<button
78
78
+
className={className}
79
79
+
onClick={handleClick}
80
80
+
onMouseEnter={handlePrefetch}
81
81
+
onPointerDown={handlePrefetch}
82
82
+
>
83
83
+
{children}
84
84
+
</button>
85
85
+
);
86
86
+
}
87
87
+
88
88
+
// Link component for opening quotes pages with prefetching
89
89
+
export function QuotesLink(props: {
90
90
+
postUri: string;
91
91
+
parent?: OpenPage;
92
92
+
children: React.ReactNode;
93
93
+
className?: string;
94
94
+
onClick?: (e: React.MouseEvent) => void;
95
95
+
}) {
96
96
+
const { postUri, parent, children, className, onClick } = props;
97
97
+
98
98
+
const handleClick = (e: React.MouseEvent) => {
99
99
+
onClick?.(e);
100
100
+
if (e.defaultPrevented) return;
101
101
+
openPage(parent, { type: "quotes", uri: postUri });
102
102
+
};
103
103
+
104
104
+
const handlePrefetch = () => {
105
105
+
prefetchQuotes(postUri);
106
106
+
};
107
107
+
108
108
+
return (
109
109
+
<button
110
110
+
className={className}
111
111
+
onClick={handleClick}
112
112
+
onMouseEnter={handlePrefetch}
113
113
+
onPointerDown={handlePrefetch}
114
114
+
>
115
115
+
{children}
116
116
+
</button>
117
117
+
);
118
118
+
}
+24
-1
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
25
25
import { LinearDocumentPage } from "./LinearDocumentPage";
26
26
import { CanvasPage } from "./CanvasPage";
27
27
import { ThreadPage as ThreadPageComponent } from "./ThreadPage";
28
28
+
import { BlueskyQuotesPage } from "./BlueskyQuotesPage";
28
29
29
30
// Page types
30
31
export type DocPage = { type: "doc"; id: string };
31
32
export type ThreadPage = { type: "thread"; uri: string };
32
32
-
export type OpenPage = DocPage | ThreadPage;
33
33
+
export type QuotesPage = { type: "quotes"; uri: string };
34
34
+
export type OpenPage = DocPage | ThreadPage | QuotesPage;
33
35
34
36
// Get a stable key for a page
35
37
const getPageKey = (page: OpenPage): string => {
36
38
if (page.type === "doc") return page.id;
39
39
+
if (page.type === "quotes") return `quotes:${page.uri}`;
37
40
return `thread:${page.uri}`;
38
41
};
39
42
···
279
282
<SandwichSpacer />
280
283
<ThreadPageComponent
281
284
threadUri={openPage.uri}
285
285
+
pageId={pageKey}
286
286
+
hasPageBackground={hasPageBackground}
287
287
+
pageOptions={
288
288
+
<PageOptions
289
289
+
onClick={() => closePage(openPage)}
290
290
+
hasPageBackground={hasPageBackground}
291
291
+
/>
292
292
+
}
293
293
+
/>
294
294
+
</Fragment>
295
295
+
);
296
296
+
}
297
297
+
298
298
+
// Handle quotes pages
299
299
+
if (openPage.type === "quotes") {
300
300
+
return (
301
301
+
<Fragment key={pageKey}>
302
302
+
<SandwichSpacer />
303
303
+
<BlueskyQuotesPage
304
304
+
postUri={openPage.uri}
282
305
pageId={pageKey}
283
306
hasPageBackground={hasPageBackground}
284
307
pageOptions={
+16
-1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
4
4
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
5
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
6
import { CommentTiny } from "components/Icons/CommentTiny";
7
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
8
+
import { ThreadLink, QuotesLink } from "./PostLinks";
7
9
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
8
10
import {
9
11
BlueskyEmbed,
···
11
13
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
12
14
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
13
15
import { openPage } from "./PostPages";
14
14
-
import { ThreadLink } from "./ThreadPage";
15
16
16
17
export const PubBlueskyPostBlock = (props: {
17
18
post: PostView;
···
118
119
{post.replyCount}
119
120
<CommentTiny />
120
121
</ThreadLink>
122
122
+
<Separator classname="h-4" />
123
123
+
</>
124
124
+
)}
125
125
+
{post.quoteCount != null && post.quoteCount > 0 && (
126
126
+
<>
127
127
+
<QuotesLink
128
128
+
postUri={post.uri}
129
129
+
parent={parent}
130
130
+
className="flex items-center gap-1 hover:text-accent-contrast"
131
131
+
onClick={(e) => e.stopPropagation()}
132
132
+
>
133
133
+
{post.quoteCount}
134
134
+
<QuoteTiny />
135
135
+
</QuotesLink>
121
136
<Separator classname="h-4" />
122
137
</>
123
138
)}
+53
-214
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
1
1
"use client";
2
2
import { useEffect, useRef } from "react";
3
3
-
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
4
4
-
import useSWR, { preload } from "swr";
3
3
+
import { AppBskyFeedDefs } from "@atproto/api";
4
4
+
import useSWR from "swr";
5
5
import { PageWrapper } from "components/Pages/Page";
6
6
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
7
7
import { DotLoader } from "components/utils/DotLoader";
8
8
-
import {
9
9
-
BlueskyEmbed,
10
10
-
PostNotAvailable,
11
11
-
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
12
12
-
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
13
13
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
14
14
-
import { CommentTiny } from "components/Icons/CommentTiny";
15
15
-
import { Separator } from "components/Layout";
16
16
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
17
17
-
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
18
18
-
import { openPage, OpenPage } from "./PostPages";
19
19
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
8
8
+
import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
9
9
+
import { openPage } from "./PostPages";
20
10
import { useThreadState } from "src/useThreadState";
21
21
-
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
11
11
+
import { BskyPostContent, ClientDate } from "./BskyPostContent";
12
12
+
import {
13
13
+
ThreadLink,
14
14
+
getThreadKey,
15
15
+
fetchThread,
16
16
+
prefetchThread,
17
17
+
} from "./PostLinks";
18
18
+
19
19
+
// Re-export for backwards compatibility
20
20
+
export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate };
22
21
23
22
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
24
23
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
25
24
type BlockedPost = AppBskyFeedDefs.BlockedPost;
26
25
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
27
26
28
28
-
// SWR key for thread data
29
29
-
export const getThreadKey = (uri: string) => `thread:${uri}`;
30
30
-
31
31
-
// Fetch thread from API route
32
32
-
export async function fetchThread(uri: string): Promise<ThreadType> {
33
33
-
const params = new URLSearchParams({ uri });
34
34
-
const response = await fetch(`/api/bsky/thread?${params.toString()}`);
35
35
-
36
36
-
if (!response.ok) {
37
37
-
throw new Error("Failed to fetch thread");
38
38
-
}
39
39
-
40
40
-
return response.json();
41
41
-
}
42
42
-
43
43
-
// Prefetch thread data
44
44
-
export const prefetchThread = (uri: string) => {
45
45
-
preload(getThreadKey(uri), () => fetchThread(uri));
46
46
-
};
47
47
-
48
48
-
// Link component for opening thread pages with prefetching
49
49
-
export function ThreadLink(props: {
50
50
-
threadUri: string;
51
51
-
parent?: OpenPage;
52
52
-
children: React.ReactNode;
53
53
-
className?: string;
54
54
-
onClick?: (e: React.MouseEvent) => void;
55
55
-
}) {
56
56
-
const { threadUri, parent, children, className, onClick } = props;
57
57
-
58
58
-
const handleClick = (e: React.MouseEvent) => {
59
59
-
onClick?.(e);
60
60
-
if (e.defaultPrevented) return;
61
61
-
openPage(parent, { type: "thread", uri: threadUri });
62
62
-
};
63
63
-
64
64
-
const handlePrefetch = () => {
65
65
-
prefetchThread(threadUri);
66
66
-
};
67
67
-
68
68
-
return (
69
69
-
<button
70
70
-
className={className}
71
71
-
onClick={handleClick}
72
72
-
onMouseEnter={handlePrefetch}
73
73
-
onPointerDown={handlePrefetch}
74
74
-
>
75
75
-
{children}
76
76
-
</button>
77
77
-
);
78
78
-
}
79
79
-
80
27
export function ThreadPage(props: {
81
28
threadUri: string;
82
29
pageId: string;
···
93
40
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
94
41
fetchThread(threadUri),
95
42
);
96
96
-
let cardBorderHidden = useCardBorderHidden(null);
97
43
98
44
return (
99
45
<PageWrapper
···
193
139
replies={thread.replies as any[]}
194
140
threadUri={threadUri}
195
141
depth={0}
142
142
+
parentAuthorDid={thread.post.author.did}
196
143
/>
197
144
</div>
198
145
)}
···
208
155
}) {
209
156
const { post, isMainPost, showReplyLine, threadUri } = props;
210
157
const postView = post.post;
211
211
-
const record = postView.record as AppBskyFeedPost.Record;
212
212
-
213
213
-
const postId = postView.uri.split("/")[4];
214
214
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
158
158
+
const parent = { type: "thread" as const, uri: threadUri };
215
159
216
160
return (
217
161
<div className="flex gap-2 relative">
···
220
164
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
221
165
)}
222
166
223
223
-
<div className="flex flex-col items-center shrink-0">
224
224
-
{postView.author.avatar ? (
225
225
-
<img
226
226
-
src={postView.author.avatar}
227
227
-
alt={`${postView.author.displayName}'s avatar`}
228
228
-
className="w-10 h-10 rounded-full border border-border-light"
229
229
-
/>
230
230
-
) : (
231
231
-
<div className="w-10 h-10 rounded-full border border-border-light bg-border" />
232
232
-
)}
233
233
-
</div>
234
234
-
235
235
-
<div
236
236
-
className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`}
237
237
-
>
238
238
-
<div className="flex items-center gap-2 leading-tight">
239
239
-
<div className="font-bold text-secondary">
240
240
-
{postView.author.displayName}
241
241
-
</div>
242
242
-
<a
243
243
-
className="text-xs text-tertiary hover:underline"
244
244
-
target="_blank"
245
245
-
href={`https://bsky.app/profile/${postView.author.handle}`}
246
246
-
>
247
247
-
@{postView.author.handle}
248
248
-
</a>
249
249
-
</div>
250
250
-
251
251
-
<div className="flex flex-col gap-2 mt-1">
252
252
-
<div className="text-sm text-secondary">
253
253
-
<BlueskyRichText record={record} />
254
254
-
</div>
255
255
-
{postView.embed && (
256
256
-
<BlueskyEmbed embed={postView.embed} postUrl={url} />
257
257
-
)}
258
258
-
</div>
259
259
-
260
260
-
<div className="flex gap-2 items-center justify-between mt-2">
261
261
-
<ClientDate date={record.createdAt} />
262
262
-
<div className="flex gap-2 items-center">
263
263
-
{postView.replyCount != null && postView.replyCount > 0 && (
264
264
-
<>
265
265
-
{isMainPost ? (
266
266
-
<div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs">
267
267
-
{postView.replyCount}
268
268
-
<CommentTiny />
269
269
-
</div>
270
270
-
) : (
271
271
-
<ThreadLink
272
272
-
threadUri={postView.uri}
273
273
-
parent={{ type: "thread", uri: threadUri }}
274
274
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
275
275
-
>
276
276
-
{postView.replyCount}
277
277
-
<CommentTiny />
278
278
-
</ThreadLink>
279
279
-
)}
280
280
-
<Separator classname="h-4" />
281
281
-
</>
282
282
-
)}
283
283
-
<a className="text-tertiary" target="_blank" href={url}>
284
284
-
<BlueskyTiny />
285
285
-
</a>
286
286
-
</div>
287
287
-
</div>
288
288
-
</div>
167
167
+
<BskyPostContent
168
168
+
post={postView}
169
169
+
parent={parent}
170
170
+
linksEnabled={!isMainPost}
171
171
+
showBlueskyLink={true}
172
172
+
showEmbed={true}
173
173
+
/>
289
174
</div>
290
175
);
291
176
}
···
294
179
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
295
180
threadUri: string;
296
181
depth: number;
182
182
+
parentAuthorDid?: string;
297
183
}) {
298
298
-
const { replies, threadUri, depth } = props;
184
184
+
const { replies, threadUri, depth, parentAuthorDid } = props;
299
185
const collapsedThreads = useThreadState((s) => s.collapsedThreads);
300
186
const toggleCollapsed = useThreadState((s) => s.toggleCollapsed);
301
187
188
188
+
// Sort replies so that replies from the parent author come first
189
189
+
const sortedReplies = parentAuthorDid
190
190
+
? [...replies].sort((a, b) => {
191
191
+
const aIsAuthor =
192
192
+
AppBskyFeedDefs.isThreadViewPost(a) &&
193
193
+
a.post.author.did === parentAuthorDid;
194
194
+
const bIsAuthor =
195
195
+
AppBskyFeedDefs.isThreadViewPost(b) &&
196
196
+
b.post.author.did === parentAuthorDid;
197
197
+
if (aIsAuthor && !bIsAuthor) return -1;
198
198
+
if (!aIsAuthor && bIsAuthor) return 1;
199
199
+
return 0;
200
200
+
})
201
201
+
: replies;
202
202
+
302
203
return (
303
204
<div className="flex flex-col gap-0">
304
304
-
{replies.map((reply, index) => {
205
205
+
{sortedReplies.map((reply, index) => {
305
206
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
306
207
return (
307
208
<div
···
371
272
replies={reply.replies as any[]}
372
273
threadUri={threadUri}
373
274
depth={depth + 1}
275
275
+
parentAuthorDid={reply.post.author.did}
374
276
/>
375
277
</div>
376
278
)}
···
398
300
isLast: boolean;
399
301
threadUri: string;
400
302
}) {
401
401
-
const { post, showReplyLine, isLast, threadUri } = props;
303
303
+
const { post, threadUri } = props;
402
304
const postView = post.post;
403
403
-
const record = postView.record as AppBskyFeedPost.Record;
404
404
-
405
405
-
const postId = postView.uri.split("/")[4];
406
406
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
407
407
-
408
305
const parent = { type: "thread" as const, uri: threadUri };
409
306
410
307
return (
···
412
309
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
413
310
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
414
311
>
415
415
-
<div className="flex flex-col items-center shrink-0">
416
416
-
{postView.author.avatar ? (
417
417
-
<img
418
418
-
src={postView.author.avatar}
419
419
-
alt={`${postView.author.displayName}'s avatar`}
420
420
-
className="w-8 h-8 rounded-full border border-border-light"
421
421
-
/>
422
422
-
) : (
423
423
-
<div className="w-8 h-8 rounded-full border border-border-light bg-border" />
424
424
-
)}
425
425
-
</div>
426
426
-
427
427
-
<div className="flex flex-col grow min-w-0">
428
428
-
<div className="flex items-center gap-2 leading-tight text-sm">
429
429
-
<div className="font-bold text-secondary">
430
430
-
{postView.author.displayName}
431
431
-
</div>
432
432
-
<a
433
433
-
className="text-xs text-tertiary hover:underline"
434
434
-
target="_blank"
435
435
-
href={`https://bsky.app/profile/${postView.author.handle}`}
436
436
-
onClick={(e) => e.stopPropagation()}
437
437
-
>
438
438
-
@{postView.author.handle}
439
439
-
</a>
440
440
-
</div>
441
441
-
442
442
-
<div className="text-sm text-secondary mt-0.5">
443
443
-
<BlueskyRichText record={record} />
444
444
-
</div>
445
445
-
446
446
-
<div className="flex gap-2 items-center mt-1">
447
447
-
<ClientDate date={record.createdAt} />
448
448
-
{postView.replyCount != null && postView.replyCount > 0 && (
449
449
-
<>
450
450
-
<Separator classname="h-3" />
451
451
-
<ThreadLink
452
452
-
threadUri={postView.uri}
453
453
-
parent={parent}
454
454
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
455
455
-
onClick={(e) => e.stopPropagation()}
456
456
-
>
457
457
-
{postView.replyCount}
458
458
-
<CommentTiny />
459
459
-
</ThreadLink>
460
460
-
</>
461
461
-
)}
462
462
-
</div>
463
463
-
</div>
312
312
+
<BskyPostContent
313
313
+
post={postView}
314
314
+
parent={parent}
315
315
+
linksEnabled={true}
316
316
+
avatarSize="sm"
317
317
+
showEmbed={false}
318
318
+
showBlueskyLink={false}
319
319
+
onLinkClick={(e) => e.stopPropagation()}
320
320
+
onEmbedClick={(e) => e.stopPropagation()}
321
321
+
/>
464
322
</div>
465
323
);
466
324
}
467
467
-
468
468
-
const ClientDate = (props: { date?: string }) => {
469
469
-
const pageLoaded = useHasPageLoaded();
470
470
-
const formattedDate = useLocalizedDate(
471
471
-
props.date || new Date().toISOString(),
472
472
-
{
473
473
-
month: "short",
474
474
-
day: "numeric",
475
475
-
year: "numeric",
476
476
-
hour: "numeric",
477
477
-
minute: "numeric",
478
478
-
hour12: true,
479
479
-
},
480
480
-
);
481
481
-
482
482
-
if (!pageLoaded) return null;
483
483
-
484
484
-
return <div className="text-xs text-tertiary">{formattedDate}</div>;
485
485
-
};