+44
app/api/bsky/thread/route.ts
+44
app/api/bsky/thread/route.ts
···
1
+
import { Agent, lexToJson } from "@atproto/api";
2
+
import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
3
+
import { NextRequest } from "next/server";
4
+
5
+
export const runtime = "nodejs";
6
+
7
+
export async function GET(req: NextRequest) {
8
+
try {
9
+
const searchParams = req.nextUrl.searchParams;
10
+
const uri = searchParams.get("uri");
11
+
const depth = searchParams.get("depth");
12
+
const parentHeight = searchParams.get("parentHeight");
13
+
14
+
if (!uri) {
15
+
return Response.json(
16
+
{ error: "uri parameter is required" },
17
+
{ status: 400 },
18
+
);
19
+
}
20
+
21
+
// Fetch thread from Bluesky
22
+
let agent = new Agent({
23
+
service: "https://public.api.bsky.app",
24
+
});
25
+
26
+
const response = await agent.getPostThread({
27
+
uri,
28
+
depth: depth ? parseInt(depth, 10) : 6,
29
+
parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80,
30
+
});
31
+
32
+
const thread = lexToJson(response.data.thread);
33
+
34
+
return Response.json(thread, {
35
+
headers: {
36
+
// Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating
37
+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600",
38
+
},
39
+
});
40
+
} catch (error) {
41
+
console.error("Error fetching Bluesky thread:", error);
42
+
return Response.json({ error: "Failed to fetch thread" }, { status: 500 });
43
+
}
44
+
}
+39
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+39
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
4
4
import { useIsMobile } from "src/hooks/isMobile";
5
5
import { setInteractionState } from "./Interactions";
6
6
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
7
-
import { AtUri } from "@atproto/api";
7
+
import { AtUri, AppBskyFeedPost } from "@atproto/api";
8
8
import { PostPageContext } from "../PostPageContext";
9
9
import {
10
10
PubLeafletBlocksText,
···
22
22
import { openPage } from "../PostPages";
23
23
import useSWR, { mutate } from "swr";
24
24
import { DotLoader } from "components/utils/DotLoader";
25
+
import { CommentTiny } from "components/Icons/CommentTiny";
26
+
import { ThreadLink } from "../ThreadPage";
25
27
26
28
// Helper to get SWR key for quotes
27
29
export function getQuotesSWRKey(uris: string[]) {
···
129
131
130
132
<div className="h-5 w-1 ml-5 border-l border-border-light" />
131
133
<BskyPost
134
+
uri={pv.uri}
132
135
rkey={new AtUri(pv.uri).rkey}
133
136
content={pv.record.text as string}
134
137
user={pv.author.displayName || pv.author.handle}
135
138
profile={pv.author}
136
139
handle={pv.author.handle}
140
+
replyCount={pv.replyCount}
137
141
/>
138
142
</div>
139
143
);
···
150
154
return (
151
155
<BskyPost
152
156
key={`mention-${index}`}
157
+
uri={pv.uri}
153
158
rkey={new AtUri(pv.uri).rkey}
154
159
content={pv.record.text as string}
155
160
user={pv.author.displayName || pv.author.handle}
156
161
profile={pv.author}
157
162
handle={pv.author.handle}
163
+
replyCount={pv.replyCount}
158
164
/>
159
165
);
160
166
})}
···
201
207
className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer"
202
208
onClick={(e) => {
203
209
if (props.position.pageId)
204
-
flushSync(() => openPage(undefined, props.position.pageId!));
210
+
flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! }));
205
211
let scrollMargin = isMobile
206
212
? 16
207
213
: e.currentTarget.getBoundingClientRect().top;
···
239
245
};
240
246
241
247
export const BskyPost = (props: {
248
+
uri: string;
242
249
rkey: string;
243
250
content: string;
244
251
user: string;
245
252
handle: string;
246
253
profile: ProfileViewBasic;
254
+
replyCount?: number;
247
255
}) => {
256
+
const handleOpenThread = () => {
257
+
openPage(undefined, { type: "thread", uri: props.uri });
258
+
};
259
+
248
260
return (
249
-
<a
250
-
target="_blank"
251
-
href={`https://bsky.app/profile/${props.handle}/post/${props.rkey}`}
252
-
className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal"
261
+
<div
262
+
onClick={handleOpenThread}
263
+
className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded"
253
264
>
254
265
{props.profile.avatar && (
255
266
<img
256
-
className="rounded-full w-6 h-6"
267
+
className="rounded-full w-6 h-6 shrink-0"
257
268
src={props.profile.avatar}
258
269
alt={props.profile.displayName}
259
270
/>
260
271
)}
261
-
<div className="flex flex-col">
262
-
<div className="flex items-center gap-2">
272
+
<div className="flex flex-col min-w-0">
273
+
<div className="flex items-center gap-2 flex-wrap">
263
274
<div className="font-bold">{props.user}</div>
264
-
<div className="text-tertiary">@{props.handle}</div>
275
+
<a
276
+
className="text-tertiary hover:underline"
277
+
href={`https://bsky.app/profile/${props.handle}`}
278
+
target="_blank"
279
+
onClick={(e) => e.stopPropagation()}
280
+
>
281
+
@{props.handle}
282
+
</a>
265
283
</div>
266
284
<div className="text-primary">{props.content}</div>
285
+
{props.replyCount != null && props.replyCount > 0 && (
286
+
<ThreadLink
287
+
threadUri={props.uri}
288
+
onClick={(e) => e.stopPropagation()}
289
+
className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast"
290
+
>
291
+
<CommentTiny />
292
+
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
293
+
</ThreadLink>
294
+
)}
267
295
</div>
268
-
</a>
296
+
</div>
269
297
);
270
298
};
271
299
+1
-1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+1
-1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
173
173
let uri = b.block.postRef.uri;
174
174
let post = bskyPostData.find((p) => p.uri === uri);
175
175
if (!post) return <div>no prefetched post rip</div>;
176
-
return <PubBlueskyPostBlock post={post} className={className} />;
176
+
return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />;
177
177
}
178
178
case PubLeafletBlocksIframe.isMain(b.block): {
179
179
return (
+83
-18
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+83
-18
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
19
19
import { Fragment, useEffect } from "react";
20
20
import { flushSync } from "react-dom";
21
21
import { scrollIntoView } from "src/utils/scrollIntoView";
22
-
import { useParams } from "next/navigation";
22
+
import { useParams, useSearchParams } from "next/navigation";
23
23
import { decodeQuotePosition } from "./quotePosition";
24
24
import { PollData } from "./fetchPollData";
25
25
import { LinearDocumentPage } from "./LinearDocumentPage";
26
26
import { CanvasPage } from "./CanvasPage";
27
+
import { ThreadPage as ThreadPageComponent } from "./ThreadPage";
28
+
29
+
// Page types
30
+
export type DocPage = { type: "doc"; id: string };
31
+
export type ThreadPage = { type: "thread"; uri: string };
32
+
export type OpenPage = DocPage | ThreadPage;
33
+
34
+
// Get a stable key for a page
35
+
const getPageKey = (page: OpenPage): string => {
36
+
if (page.type === "doc") return page.id;
37
+
return `thread:${page.uri}`;
38
+
};
27
39
28
40
const usePostPageUIState = create(() => ({
29
-
pages: [] as string[],
41
+
pages: [] as OpenPage[],
30
42
initialized: false,
31
43
}));
32
44
33
-
export const useOpenPages = () => {
45
+
export const useOpenPages = (): OpenPage[] => {
34
46
const { quote } = useParams();
35
47
const state = usePostPageUIState((s) => s);
48
+
const searchParams = useSearchParams();
49
+
const pageParam = searchParams.get("page");
36
50
37
-
if (!state.initialized && quote) {
38
-
const decodedQuote = decodeQuotePosition(quote as string);
39
-
if (decodedQuote?.pageId) {
40
-
return [decodedQuote.pageId];
51
+
if (!state.initialized) {
52
+
// Check for page search param first (for comment links)
53
+
if (pageParam) {
54
+
return [{ type: "doc", id: pageParam }];
55
+
}
56
+
// Then check for quote param
57
+
if (quote) {
58
+
const decodedQuote = decodeQuotePosition(quote as string);
59
+
if (decodedQuote?.pageId) {
60
+
return [{ type: "doc", id: decodedQuote.pageId }];
61
+
}
41
62
}
42
63
}
43
64
···
46
67
47
68
export const useInitializeOpenPages = () => {
48
69
const { quote } = useParams();
70
+
const searchParams = useSearchParams();
71
+
const pageParam = searchParams.get("page");
49
72
50
73
useEffect(() => {
51
74
const state = usePostPageUIState.getState();
52
75
if (!state.initialized) {
76
+
// Check for page search param first (for comment links)
77
+
if (pageParam) {
78
+
usePostPageUIState.setState({
79
+
pages: [{ type: "doc", id: pageParam }],
80
+
initialized: true,
81
+
});
82
+
return;
83
+
}
84
+
85
+
// Then check for quote param
53
86
if (quote) {
54
87
const decodedQuote = decodeQuotePosition(quote as string);
55
88
if (decodedQuote?.pageId) {
56
89
usePostPageUIState.setState({
57
-
pages: [decodedQuote.pageId],
90
+
pages: [{ type: "doc", id: decodedQuote.pageId }],
58
91
initialized: true,
59
92
});
60
93
return;
···
67
100
};
68
101
69
102
export const openPage = (
70
-
parent: string | undefined,
71
-
page: string,
103
+
parent: OpenPage | undefined,
104
+
page: OpenPage,
72
105
options?: { scrollIntoView?: boolean },
73
106
) => {
107
+
const pageKey = getPageKey(page);
108
+
const parentKey = parent ? getPageKey(parent) : undefined;
109
+
74
110
flushSync(() => {
75
111
usePostPageUIState.setState((state) => {
76
-
let parentPosition = state.pages.findIndex((s) => s == parent);
112
+
let parentPosition = state.pages.findIndex(
113
+
(s) => getPageKey(s) === parentKey,
114
+
);
77
115
return {
78
116
pages:
79
117
parentPosition === -1
···
85
123
});
86
124
87
125
if (options?.scrollIntoView !== false) {
88
-
scrollIntoView(`post-page-${page}`);
126
+
scrollIntoView(`post-page-${pageKey}`);
89
127
}
90
128
};
91
129
92
-
export const closePage = (page: string) =>
130
+
export const closePage = (page: OpenPage) => {
131
+
const pageKey = getPageKey(page);
93
132
usePostPageUIState.setState((state) => {
94
-
let parentPosition = state.pages.findIndex((s) => s == page);
133
+
let parentPosition = state.pages.findIndex(
134
+
(s) => getPageKey(s) === pageKey,
135
+
);
95
136
return {
96
137
pages: state.pages.slice(0, parentPosition),
97
138
initialized: true,
98
139
};
99
140
});
141
+
};
100
142
101
143
// Shared props type for both page components
102
144
export type SharedPageProps = {
···
228
270
/>
229
271
)}
230
272
231
-
{openPageIds.map((pageId) => {
273
+
{openPageIds.map((openPage) => {
274
+
const pageKey = getPageKey(openPage);
275
+
276
+
// Handle thread pages
277
+
if (openPage.type === "thread") {
278
+
return (
279
+
<Fragment key={pageKey}>
280
+
<SandwichSpacer />
281
+
<ThreadPageComponent
282
+
threadUri={openPage.uri}
283
+
pageId={pageKey}
284
+
hasPageBackground={hasPageBackground}
285
+
pageOptions={
286
+
<PageOptions
287
+
onClick={() => closePage(openPage)}
288
+
hasPageBackground={hasPageBackground}
289
+
/>
290
+
}
291
+
/>
292
+
</Fragment>
293
+
);
294
+
}
295
+
296
+
// Handle document pages
232
297
let page = record.pages.find(
233
298
(p) =>
234
299
(
235
300
p as
236
301
| PubLeafletPagesLinearDocument.Main
237
302
| PubLeafletPagesCanvas.Main
238
-
).id === pageId,
303
+
).id === openPage.id,
239
304
) as
240
305
| PubLeafletPagesLinearDocument.Main
241
306
| PubLeafletPagesCanvas.Main
···
244
309
if (!page) return null;
245
310
246
311
return (
247
-
<Fragment key={pageId}>
312
+
<Fragment key={pageKey}>
248
313
<SandwichSpacer />
249
314
<PageRenderer
250
315
page={page}
···
253
318
pageId={page.id}
254
319
pageOptions={
255
320
<PageOptions
256
-
onClick={() => closePage(page.id!)}
321
+
onClick={() => closePage(openPage)}
257
322
hasPageBackground={hasPageBackground}
258
323
/>
259
324
}
+33
-18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
+33
-18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
1
1
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
-
import { useEntitySetContext } from "components/EntitySetProvider";
3
-
import { useEffect, useState } from "react";
4
-
import { useEntity } from "src/replicache";
5
-
import { useUIState } from "src/useUIState";
6
-
import { elementId } from "src/utils/elementId";
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
9
3
import { Separator } from "components/Layout";
10
4
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
11
5
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
···
16
10
PostNotAvailable,
17
11
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
18
12
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
13
+
import { openPage } from "./PostPages";
14
+
import { ThreadLink } from "./ThreadPage";
19
15
20
16
export const PubBlueskyPostBlock = (props: {
21
17
post: PostView;
22
18
className: string;
19
+
pageId?: string;
23
20
}) => {
24
21
let post = props.post;
22
+
23
+
const handleOpenThread = () => {
24
+
openPage(
25
+
props.pageId ? { type: "doc", id: props.pageId } : undefined,
26
+
{ type: "thread", uri: post.uri },
27
+
);
28
+
};
29
+
25
30
switch (true) {
26
31
case AppBskyFeedDefs.isBlockedPost(post) ||
27
32
AppBskyFeedDefs.isBlockedAuthor(post) ||
···
34
39
35
40
case AppBskyFeedDefs.validatePostView(post).success:
36
41
let record = post.record as AppBskyFeedDefs.PostView["record"];
37
-
let facets = record.facets;
38
42
39
43
// silliness to get the text and timestamp from the record with proper types
40
-
let text: string | null = null;
41
44
let timestamp: string | undefined = undefined;
42
45
if (AppBskyFeedPost.isRecord(record)) {
43
-
text = (record as AppBskyFeedPost.Record).text;
44
46
timestamp = (record as AppBskyFeedPost.Record).createdAt;
45
47
}
46
48
···
48
50
let postId = post.uri.split("/")[4];
49
51
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
50
52
53
+
const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined;
54
+
51
55
return (
52
56
<div
57
+
onClick={handleOpenThread}
53
58
className={`
54
59
${props.className}
55
60
block-border
56
61
mb-2
57
62
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
63
+
cursor-pointer hover:border-accent-contrast
58
64
`}
59
65
>
60
66
{post.author && record && (
···
75
81
className="text-xs text-tertiary hover:underline"
76
82
target="_blank"
77
83
href={`https://bsky.app/profile/${post.author?.handle}`}
84
+
onClick={(e) => e.stopPropagation()}
78
85
>
79
86
@{post.author?.handle}
80
87
</a>
···
90
97
</pre>
91
98
</div>
92
99
{post.embed && (
93
-
<BlueskyEmbed embed={post.embed} postUrl={url} />
100
+
<div onClick={(e) => e.stopPropagation()}>
101
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
102
+
</div>
94
103
)}
95
104
</div>
96
105
</>
···
98
107
<div className="w-full flex gap-2 items-center justify-between">
99
108
<ClientDate date={timestamp} />
100
109
<div className="flex gap-2 items-center">
101
-
{post.replyCount && post.replyCount > 0 && (
110
+
{post.replyCount != null && post.replyCount > 0 && (
102
111
<>
103
-
<a
104
-
className="flex items-center gap-1 hover:no-underline"
105
-
target="_blank"
106
-
href={url}
112
+
<ThreadLink
113
+
threadUri={post.uri}
114
+
parent={parent}
115
+
className="flex items-center gap-1 hover:text-accent-contrast"
116
+
onClick={(e) => e.stopPropagation()}
107
117
>
108
118
{post.replyCount}
109
119
<CommentTiny />
110
-
</a>
120
+
</ThreadLink>
111
121
<Separator classname="h-4" />
112
122
</>
113
123
)}
114
124
115
-
<a className="" target="_blank" href={url}>
125
+
<a
126
+
className=""
127
+
target="_blank"
128
+
href={url}
129
+
onClick={(e) => e.stopPropagation()}
130
+
>
116
131
<BlueskyTiny />
117
132
</a>
118
133
</div>
+17
-8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
+17
-8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
40
40
}) {
41
41
//switch to use actually state
42
42
let openPages = useOpenPages();
43
-
let isOpen = openPages.includes(props.pageId);
43
+
let isOpen = openPages.some(
44
+
(p) => p.type === "doc" && p.id === props.pageId,
45
+
);
44
46
return (
45
47
<div
46
48
className={`w-full cursor-pointer
···
57
59
e.preventDefault();
58
60
e.stopPropagation();
59
61
60
-
openPage(props.parentPageId, props.pageId);
62
+
openPage(
63
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
64
+
{ type: "doc", id: props.pageId },
65
+
);
61
66
}}
62
67
>
63
68
{props.isCanvas ? (
···
213
218
onClick={(e) => {
214
219
e.preventDefault();
215
220
e.stopPropagation();
216
-
openPage(props.parentPageId, props.pageId, {
217
-
scrollIntoView: false,
218
-
});
221
+
openPage(
222
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
223
+
{ type: "doc", id: props.pageId },
224
+
{ scrollIntoView: false },
225
+
);
219
226
if (!drawerOpen || drawer !== "quotes")
220
227
openInteractionDrawer("quotes", document_uri, props.pageId);
221
228
else setInteractionState(document_uri, { drawerOpen: false });
···
231
238
onClick={(e) => {
232
239
e.preventDefault();
233
240
e.stopPropagation();
234
-
openPage(props.parentPageId, props.pageId, {
235
-
scrollIntoView: false,
236
-
});
241
+
openPage(
242
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
243
+
{ type: "doc", id: props.pageId },
244
+
{ scrollIntoView: false },
245
+
);
237
246
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
238
247
openInteractionDrawer("comments", document_uri, props.pageId);
239
248
else setInteractionState(document_uri, { drawerOpen: false });
+439
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+439
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
1
+
"use client";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
+
import useSWR, { preload } from "swr";
4
+
import { PageWrapper } from "components/Pages/Page";
5
+
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
+
import { DotLoader } from "components/utils/DotLoader";
7
+
import {
8
+
BlueskyEmbed,
9
+
PostNotAvailable,
10
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
11
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
12
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
13
+
import { CommentTiny } from "components/Icons/CommentTiny";
14
+
import { Separator } from "components/Layout";
15
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
17
+
import { openPage, OpenPage } from "./PostPages";
18
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
19
+
20
+
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
21
+
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
22
+
type BlockedPost = AppBskyFeedDefs.BlockedPost;
23
+
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
24
+
25
+
// SWR key for thread data
26
+
export const getThreadKey = (uri: string) => `thread:${uri}`;
27
+
28
+
// Fetch thread from API route
29
+
export async function fetchThread(uri: string): Promise<ThreadType> {
30
+
const params = new URLSearchParams({ uri });
31
+
const response = await fetch(`/api/bsky/thread?${params.toString()}`);
32
+
33
+
if (!response.ok) {
34
+
throw new Error("Failed to fetch thread");
35
+
}
36
+
37
+
return response.json();
38
+
}
39
+
40
+
// Prefetch thread data
41
+
export const prefetchThread = (uri: string) => {
42
+
preload(getThreadKey(uri), () => fetchThread(uri));
43
+
};
44
+
45
+
// Link component for opening thread pages with prefetching
46
+
export function ThreadLink(props: {
47
+
threadUri: string;
48
+
parent?: OpenPage;
49
+
children: React.ReactNode;
50
+
className?: string;
51
+
onClick?: (e: React.MouseEvent) => void;
52
+
}) {
53
+
const { threadUri, parent, children, className, onClick } = props;
54
+
55
+
const handleClick = (e: React.MouseEvent) => {
56
+
onClick?.(e);
57
+
if (e.defaultPrevented) return;
58
+
openPage(parent, { type: "thread", uri: threadUri });
59
+
};
60
+
61
+
const handlePrefetch = () => {
62
+
prefetchThread(threadUri);
63
+
};
64
+
65
+
return (
66
+
<button
67
+
className={className}
68
+
onClick={handleClick}
69
+
onMouseEnter={handlePrefetch}
70
+
onPointerDown={handlePrefetch}
71
+
>
72
+
{children}
73
+
</button>
74
+
);
75
+
}
76
+
77
+
export function ThreadPage(props: {
78
+
threadUri: string;
79
+
pageId: string;
80
+
pageOptions?: React.ReactNode;
81
+
hasPageBackground: boolean;
82
+
}) {
83
+
const { threadUri, pageId, pageOptions } = props;
84
+
const drawer = useDrawerOpen(threadUri);
85
+
86
+
const {
87
+
data: thread,
88
+
isLoading,
89
+
error,
90
+
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
91
+
fetchThread(threadUri),
92
+
);
93
+
let cardBorderHidden = useCardBorderHidden(null);
94
+
95
+
return (
96
+
<PageWrapper
97
+
cardBorderHidden={!!cardBorderHidden}
98
+
pageType="doc"
99
+
fullPageScroll={false}
100
+
id={`post-page-${pageId}`}
101
+
drawerOpen={!!drawer}
102
+
pageOptions={pageOptions}
103
+
>
104
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
105
+
{isLoading ? (
106
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
107
+
<span>loading thread</span>
108
+
<DotLoader />
109
+
</div>
110
+
) : error ? (
111
+
<div className="text-tertiary italic text-sm text-center py-8">
112
+
Failed to load thread
113
+
</div>
114
+
) : thread ? (
115
+
<ThreadContent thread={thread} threadUri={threadUri} />
116
+
) : null}
117
+
</div>
118
+
</PageWrapper>
119
+
);
120
+
}
121
+
122
+
function ThreadContent(props: { thread: ThreadType; threadUri: string }) {
123
+
const { thread, threadUri } = props;
124
+
125
+
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
126
+
return <PostNotAvailable />;
127
+
}
128
+
129
+
if (AppBskyFeedDefs.isBlockedPost(thread)) {
130
+
return (
131
+
<div className="text-tertiary italic text-sm text-center py-8">
132
+
This post is blocked
133
+
</div>
134
+
);
135
+
}
136
+
137
+
if (!AppBskyFeedDefs.isThreadViewPost(thread)) {
138
+
return <PostNotAvailable />;
139
+
}
140
+
141
+
// Collect all parent posts in order (oldest first)
142
+
const parents: ThreadViewPost[] = [];
143
+
let currentParent = thread.parent;
144
+
while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) {
145
+
parents.unshift(currentParent);
146
+
currentParent = currentParent.parent;
147
+
}
148
+
149
+
return (
150
+
<div className="flex flex-col gap-0">
151
+
{/* Parent posts */}
152
+
{parents.map((parent, index) => (
153
+
<div key={parent.post.uri} className="flex flex-col">
154
+
<ThreadPost
155
+
post={parent}
156
+
isMainPost={false}
157
+
showReplyLine={index < parents.length - 1 || true}
158
+
threadUri={threadUri}
159
+
/>
160
+
</div>
161
+
))}
162
+
163
+
{/* Main post */}
164
+
<ThreadPost
165
+
post={thread}
166
+
isMainPost={true}
167
+
showReplyLine={false}
168
+
threadUri={threadUri}
169
+
/>
170
+
171
+
{/* Replies */}
172
+
{thread.replies && thread.replies.length > 0 && (
173
+
<div className="flex flex-col mt-2 pt-2 border-t border-border-light">
174
+
<div className="text-tertiary text-xs font-bold mb-2 px-2">
175
+
Replies
176
+
</div>
177
+
<Replies
178
+
replies={thread.replies as any[]}
179
+
threadUri={threadUri}
180
+
depth={0}
181
+
/>
182
+
</div>
183
+
)}
184
+
</div>
185
+
);
186
+
}
187
+
188
+
function ThreadPost(props: {
189
+
post: ThreadViewPost;
190
+
isMainPost: boolean;
191
+
showReplyLine: boolean;
192
+
threadUri: string;
193
+
}) {
194
+
const { post, isMainPost, showReplyLine, threadUri } = props;
195
+
const postView = post.post;
196
+
const record = postView.record as AppBskyFeedPost.Record;
197
+
198
+
const postId = postView.uri.split("/")[4];
199
+
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
200
+
201
+
return (
202
+
<div className="flex gap-2 relative">
203
+
{/* Reply line connector */}
204
+
{showReplyLine && (
205
+
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
206
+
)}
207
+
208
+
<div className="flex flex-col items-center shrink-0">
209
+
{postView.author.avatar ? (
210
+
<img
211
+
src={postView.author.avatar}
212
+
alt={`${postView.author.displayName}'s avatar`}
213
+
className="w-10 h-10 rounded-full border border-border-light"
214
+
/>
215
+
) : (
216
+
<div className="w-10 h-10 rounded-full border border-border-light bg-border" />
217
+
)}
218
+
</div>
219
+
220
+
<div
221
+
className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`}
222
+
>
223
+
<div className="flex items-center gap-2 leading-tight">
224
+
<div className="font-bold text-secondary">
225
+
{postView.author.displayName}
226
+
</div>
227
+
<a
228
+
className="text-xs text-tertiary hover:underline"
229
+
target="_blank"
230
+
href={`https://bsky.app/profile/${postView.author.handle}`}
231
+
>
232
+
@{postView.author.handle}
233
+
</a>
234
+
</div>
235
+
236
+
<div className="flex flex-col gap-2 mt-1">
237
+
<div className="text-sm text-secondary">
238
+
<BlueskyRichText record={record} />
239
+
</div>
240
+
{postView.embed && (
241
+
<BlueskyEmbed embed={postView.embed} postUrl={url} />
242
+
)}
243
+
</div>
244
+
245
+
<div className="flex gap-2 items-center justify-between mt-2">
246
+
<ClientDate date={record.createdAt} />
247
+
<div className="flex gap-2 items-center">
248
+
{postView.replyCount != null && postView.replyCount > 0 && (
249
+
<>
250
+
{isMainPost ? (
251
+
<div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs">
252
+
{postView.replyCount}
253
+
<CommentTiny />
254
+
</div>
255
+
) : (
256
+
<ThreadLink
257
+
threadUri={postView.uri}
258
+
parent={{ type: "thread", uri: threadUri }}
259
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
260
+
>
261
+
{postView.replyCount}
262
+
<CommentTiny />
263
+
</ThreadLink>
264
+
)}
265
+
<Separator classname="h-4" />
266
+
</>
267
+
)}
268
+
<a className="text-tertiary" target="_blank" href={url}>
269
+
<BlueskyTiny />
270
+
</a>
271
+
</div>
272
+
</div>
273
+
</div>
274
+
</div>
275
+
);
276
+
}
277
+
278
+
function Replies(props: {
279
+
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
280
+
threadUri: string;
281
+
depth: number;
282
+
}) {
283
+
const { replies, threadUri, depth } = props;
284
+
285
+
return (
286
+
<div className="flex flex-col gap-0">
287
+
{replies.map((reply, index) => {
288
+
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
289
+
return (
290
+
<div
291
+
key={`not-found-${index}`}
292
+
className="text-tertiary italic text-xs py-2 px-2"
293
+
>
294
+
Post not found
295
+
</div>
296
+
);
297
+
}
298
+
299
+
if (AppBskyFeedDefs.isBlockedPost(reply)) {
300
+
return (
301
+
<div
302
+
key={`blocked-${index}`}
303
+
className="text-tertiary italic text-xs py-2 px-2"
304
+
>
305
+
Post blocked
306
+
</div>
307
+
);
308
+
}
309
+
310
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) {
311
+
return null;
312
+
}
313
+
314
+
const hasReplies = reply.replies && reply.replies.length > 0;
315
+
316
+
return (
317
+
<div key={reply.post.uri} className="flex flex-col">
318
+
<ReplyPost
319
+
post={reply}
320
+
showReplyLine={hasReplies || index < replies.length - 1}
321
+
isLast={index === replies.length - 1 && !hasReplies}
322
+
threadUri={threadUri}
323
+
/>
324
+
{hasReplies && depth < 3 && (
325
+
<div className="ml-5 pl-5 border-l border-border-light">
326
+
<Replies
327
+
replies={reply.replies as any[]}
328
+
threadUri={threadUri}
329
+
depth={depth + 1}
330
+
/>
331
+
</div>
332
+
)}
333
+
{hasReplies && depth >= 3 && (
334
+
<ThreadLink
335
+
threadUri={reply.post.uri}
336
+
parent={{ type: "thread", uri: threadUri }}
337
+
className="ml-12 text-xs text-accent-contrast hover:underline py-1"
338
+
>
339
+
View more replies
340
+
</ThreadLink>
341
+
)}
342
+
</div>
343
+
);
344
+
})}
345
+
</div>
346
+
);
347
+
}
348
+
349
+
function ReplyPost(props: {
350
+
post: ThreadViewPost;
351
+
showReplyLine: boolean;
352
+
isLast: boolean;
353
+
threadUri: string;
354
+
}) {
355
+
const { post, showReplyLine, isLast, threadUri } = props;
356
+
const postView = post.post;
357
+
const record = postView.record as AppBskyFeedPost.Record;
358
+
359
+
const postId = postView.uri.split("/")[4];
360
+
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
361
+
362
+
const parent = { type: "thread" as const, uri: threadUri };
363
+
364
+
return (
365
+
<div
366
+
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
367
+
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
368
+
>
369
+
<div className="flex flex-col items-center shrink-0">
370
+
{postView.author.avatar ? (
371
+
<img
372
+
src={postView.author.avatar}
373
+
alt={`${postView.author.displayName}'s avatar`}
374
+
className="w-8 h-8 rounded-full border border-border-light"
375
+
/>
376
+
) : (
377
+
<div className="w-8 h-8 rounded-full border border-border-light bg-border" />
378
+
)}
379
+
</div>
380
+
381
+
<div className="flex flex-col grow min-w-0">
382
+
<div className="flex items-center gap-2 leading-tight text-sm">
383
+
<div className="font-bold text-secondary">
384
+
{postView.author.displayName}
385
+
</div>
386
+
<a
387
+
className="text-xs text-tertiary hover:underline"
388
+
target="_blank"
389
+
href={`https://bsky.app/profile/${postView.author.handle}`}
390
+
onClick={(e) => e.stopPropagation()}
391
+
>
392
+
@{postView.author.handle}
393
+
</a>
394
+
</div>
395
+
396
+
<div className="text-sm text-secondary mt-0.5 line-clamp-3">
397
+
<BlueskyRichText record={record} />
398
+
</div>
399
+
400
+
<div className="flex gap-2 items-center mt-1">
401
+
<ClientDate date={record.createdAt} />
402
+
{postView.replyCount != null && postView.replyCount > 0 && (
403
+
<>
404
+
<Separator classname="h-3" />
405
+
<ThreadLink
406
+
threadUri={postView.uri}
407
+
parent={parent}
408
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
409
+
onClick={(e) => e.stopPropagation()}
410
+
>
411
+
{postView.replyCount}
412
+
<CommentTiny />
413
+
</ThreadLink>
414
+
</>
415
+
)}
416
+
</div>
417
+
</div>
418
+
</div>
419
+
);
420
+
}
421
+
422
+
const ClientDate = (props: { date?: string }) => {
423
+
const pageLoaded = useHasPageLoaded();
424
+
const formattedDate = useLocalizedDate(
425
+
props.date || new Date().toISOString(),
426
+
{
427
+
month: "short",
428
+
day: "numeric",
429
+
year: "numeric",
430
+
hour: "numeric",
431
+
minute: "numeric",
432
+
hour12: true,
433
+
},
434
+
);
435
+
436
+
if (!pageLoaded) return null;
437
+
438
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
439
+
};
+52
-26
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
+52
-26
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
23
23
return (
24
24
<div className="flex flex-wrap rounded-md w-full overflow-hidden">
25
25
{imageEmbed.images.map(
26
-
(image: { fullsize: string; alt?: string }, i: number) => (
27
-
<img
28
-
key={i}
29
-
src={image.fullsize}
30
-
alt={image.alt || "Post image"}
31
-
className={`
32
-
overflow-hidden w-full object-cover
33
-
${imageEmbed.images.length === 1 && "h-auto max-h-[800px]"}
34
-
${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"}
35
-
${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"}
26
+
(
27
+
image: {
28
+
fullsize: string;
29
+
alt?: string;
30
+
aspectRatio?: { width: number; height: number };
31
+
},
32
+
i: number,
33
+
) => {
34
+
const isSingle = imageEmbed.images.length === 1;
35
+
const aspectRatio = image.aspectRatio
36
+
? image.aspectRatio.width / image.aspectRatio.height
37
+
: undefined;
38
+
39
+
return (
40
+
<img
41
+
key={i}
42
+
src={image.fullsize}
43
+
alt={image.alt || "Post image"}
44
+
style={
45
+
isSingle && aspectRatio
46
+
? { aspectRatio: String(aspectRatio) }
47
+
: undefined
48
+
}
49
+
className={`
50
+
overflow-hidden w-full object-cover
51
+
${isSingle && "max-h-[800px]"}
52
+
${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"}
53
+
${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"}
36
54
${
37
55
imageEmbed.images.length === 4
38
56
? "basis-1/2 aspect-3/2"
39
-
: `basis-1/${imageEmbed.images.length} `
57
+
: `basis-1/${imageEmbed.images.length}`
40
58
}
41
-
`}
42
-
/>
43
-
),
59
+
`}
60
+
/>
61
+
);
62
+
},
44
63
)}
45
64
</div>
46
65
);
···
49
68
let isGif = externalEmbed.external.uri.includes(".gif");
50
69
if (isGif) {
51
70
return (
52
-
<div className="flex flex-col border border-border-light rounded-md overflow-hidden">
71
+
<div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video">
53
72
<img
54
73
src={externalEmbed.external.uri}
55
74
alt={externalEmbed.external.title}
56
-
className="object-cover"
75
+
className="w-full h-full object-cover"
57
76
/>
58
77
</div>
59
78
);
···
66
85
>
67
86
{externalEmbed.external.thumb === undefined ? null : (
68
87
<>
69
-
<img
70
-
src={externalEmbed.external.thumb}
71
-
alt={externalEmbed.external.title}
72
-
className="object-cover"
73
-
/>
74
-
75
-
<hr className="border-border-light " />
88
+
<div className="w-full aspect-[1.91/1] overflow-hidden">
89
+
<img
90
+
src={externalEmbed.external.thumb}
91
+
alt={externalEmbed.external.title}
92
+
className="w-full h-full object-cover"
93
+
/>
94
+
</div>
95
+
<hr className="border-border-light" />
76
96
</>
77
97
)}
78
98
<div className="p-2 flex flex-col gap-1">
···
91
111
);
92
112
case AppBskyEmbedVideo.isView(props.embed):
93
113
let videoEmbed = props.embed;
114
+
const videoAspectRatio = videoEmbed.aspectRatio
115
+
? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height
116
+
: 16 / 9;
94
117
return (
95
-
<div className="rounded-md overflow-hidden relative">
118
+
<div
119
+
className="rounded-md overflow-hidden relative w-full"
120
+
style={{ aspectRatio: String(videoAspectRatio) }}
121
+
>
96
122
<img
97
123
src={videoEmbed.thumbnail}
98
124
alt={
99
125
"Thumbnail from embedded video. Go to Bluesky to see the full post."
100
126
}
101
-
className={`overflow-hidden w-full object-cover`}
127
+
className="absolute inset-0 w-full h-full object-cover"
102
128
/>
103
-
<div className="overlay absolute top-0 right-0 left-0 bottom-0 bg-primary opacity-65" />
129
+
<div className="overlay absolute inset-0 bg-primary opacity-65" />
104
130
<div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md">
105
131
<SeePostOnBluesky postUrl={props.postUrl} />
106
132
</div>
+1
-1
components/Blocks/BlueskyPostBlock/index.tsx
+1
-1
components/Blocks/BlueskyPostBlock/index.tsx
···
130
130
<div className="w-full flex gap-2 items-center justify-between">
131
131
{timestamp && <PostDate timestamp={timestamp} />}
132
132
<div className="flex gap-2 items-center">
133
-
{post.post.replyCount && post.post.replyCount > 0 && (
133
+
{post.post.replyCount != null && post.post.replyCount > 0 && (
134
134
<>
135
135
<a
136
136
className="flex items-center gap-1 hover:no-underline"