tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
297
fork
atom
a tool for shared writing and social publishing
297
fork
atom
overview
issues
31
pulls
pipelines
linearize single author bsky threads
awarm.space
3 days ago
9f934d11
55655f22
+380
-50
1 changed file
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
ThreadPage.tsx
+380
-50
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
reviewed
···
1
1
"use client";
2
2
-
import { useEffect, useRef } from "react";
3
3
-
import { AppBskyFeedDefs } from "@atproto/api";
2
2
+
import { useEffect, useMemo, useRef } from "react";
3
3
+
import {
4
4
+
AppBskyFeedDefs,
5
5
+
AppBskyFeedPost,
6
6
+
AppBskyRichtextFacet,
7
7
+
AppBskyEmbedExternal,
8
8
+
} from "@atproto/api";
9
9
+
import { AtUri } from "@atproto/syntax";
4
10
import useSWR from "swr";
5
11
import { PageWrapper } from "components/Pages/Page";
6
12
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
···
18
24
fetchThread,
19
25
prefetchThread,
20
26
} from "./PostLinks";
27
27
+
import { useDocument } from "contexts/DocumentContext";
28
28
+
import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
29
29
+
import { QuoteContent } from "./Interactions/Quotes";
30
30
+
import {
31
31
+
decodeQuotePosition,
32
32
+
type QuotePosition,
33
33
+
} from "./quotePosition";
21
34
22
35
// Re-export for backwards compatibility
23
36
export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate };
···
27
40
type BlockedPost = AppBskyFeedDefs.BlockedPost;
28
41
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
29
42
43
43
+
// Walk a reply chain collecting consecutive same-author posts
44
44
+
// where each post is the sole reply. Returns the flat chain.
45
45
+
function flattenSameAuthorChain(
46
46
+
post: ThreadViewPost,
47
47
+
rootAuthorDid: string,
48
48
+
): ThreadViewPost[] {
49
49
+
if (post.post.author.did !== rootAuthorDid) return [post];
50
50
+
51
51
+
const chain: ThreadViewPost[] = [post];
52
52
+
let current = post;
53
53
+
54
54
+
while (current.replies && current.replies.length > 0) {
55
55
+
const replies = current.replies as any[];
56
56
+
const sameAuthorReplies = replies.filter(
57
57
+
(r) =>
58
58
+
AppBskyFeedDefs.isThreadViewPost(r) &&
59
59
+
(r as ThreadViewPost).post.author.did === rootAuthorDid,
60
60
+
) as ThreadViewPost[];
61
61
+
62
62
+
// Only flatten if there's exactly one reply and it's by the same author
63
63
+
if (sameAuthorReplies.length !== 1 || replies.length !== 1) break;
64
64
+
65
65
+
chain.push(sameAuthorReplies[0]);
66
66
+
current = sameAuthorReplies[0];
67
67
+
}
68
68
+
69
69
+
return chain;
70
70
+
}
71
71
+
72
72
+
// Check if a URL matches any of the document's known URLs,
73
73
+
// and extract the quote position if present
74
74
+
function matchDocumentUrl(
75
75
+
uri: string,
76
76
+
documentUrls: string[],
77
77
+
): { url: string; quotePosition: QuotePosition | null } | null {
78
78
+
try {
79
79
+
const url = new URL(uri);
80
80
+
const parts = url.pathname.split("/l-quote/");
81
81
+
const pathWithoutQuote = parts[0];
82
82
+
const quoteParam = parts[1];
83
83
+
const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace(
84
84
+
/\/$/,
85
85
+
"",
86
86
+
);
87
87
+
88
88
+
for (const docUrl of documentUrls) {
89
89
+
const normalized = docUrl.replace(/\/$/, "");
90
90
+
if (fullUrlWithoutQuote === normalized) {
91
91
+
return {
92
92
+
url: uri,
93
93
+
quotePosition: quoteParam
94
94
+
? decodeQuotePosition(quoteParam)
95
95
+
: null,
96
96
+
};
97
97
+
}
98
98
+
}
99
99
+
} catch {
100
100
+
return null;
101
101
+
}
102
102
+
return null;
103
103
+
}
104
104
+
105
105
+
// Scan a post's facets and embed for links to the current document
106
106
+
function findDocumentQuoteLink(
107
107
+
post: AppBskyFeedDefs.PostView,
108
108
+
documentUrls: string[],
109
109
+
): {
110
110
+
url: string;
111
111
+
quotePosition: QuotePosition | null;
112
112
+
isEmbed: boolean;
113
113
+
} | null {
114
114
+
if (documentUrls.length === 0) return null;
115
115
+
116
116
+
const record = post.record as AppBskyFeedPost.Record;
117
117
+
118
118
+
// Check facets for link URIs
119
119
+
if (record.facets) {
120
120
+
for (const facet of record.facets) {
121
121
+
for (const feature of facet.features) {
122
122
+
if (AppBskyRichtextFacet.isLink(feature)) {
123
123
+
const match = matchDocumentUrl(feature.uri, documentUrls);
124
124
+
if (match) return { ...match, isEmbed: false };
125
125
+
}
126
126
+
}
127
127
+
}
128
128
+
}
129
129
+
130
130
+
// Check external embed URI
131
131
+
if (post.embed && AppBskyEmbedExternal.isView(post.embed)) {
132
132
+
const match = matchDocumentUrl(
133
133
+
post.embed.external.uri,
134
134
+
documentUrls,
135
135
+
);
136
136
+
if (match) return { ...match, isEmbed: true };
137
137
+
}
138
138
+
139
139
+
return null;
140
140
+
}
141
141
+
30
142
export function ThreadPage(props: {
31
143
parentUri: string;
32
144
pageId: string;
···
75
187
const { post, parentUri } = props;
76
188
const mainPostRef = useRef<HTMLDivElement>(null);
77
189
190
190
+
// Compute document URLs for leaflet link detection
191
191
+
const {
192
192
+
uri: docUri,
193
193
+
normalizedDocument,
194
194
+
normalizedPublication,
195
195
+
} = useDocument();
196
196
+
const docAtUri = useMemo(() => new AtUri(docUri), [docUri]);
197
197
+
const docDid = docAtUri.host;
198
198
+
199
199
+
const documentUrls = useMemo(() => {
200
200
+
const urls: string[] = [];
201
201
+
const canonicalUrl = getDocumentURL(
202
202
+
normalizedDocument,
203
203
+
docUri,
204
204
+
normalizedPublication,
205
205
+
);
206
206
+
if (canonicalUrl.startsWith("http")) {
207
207
+
urls.push(canonicalUrl);
208
208
+
} else {
209
209
+
urls.push(`https://leaflet.pub${canonicalUrl}`);
210
210
+
}
211
211
+
urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`);
212
212
+
if (
213
213
+
normalizedDocument.site &&
214
214
+
normalizedDocument.site.startsWith("http")
215
215
+
) {
216
216
+
const path = normalizedDocument.path || "/" + docAtUri.rkey;
217
217
+
urls.push(normalizedDocument.site + path);
218
218
+
}
219
219
+
return urls;
220
220
+
}, [docUri, docAtUri, normalizedDocument, normalizedPublication]);
221
221
+
78
222
// Scroll the main post into view when the thread loads
79
223
useEffect(() => {
80
224
if (mainPostRef.current) {
···
101
245
return <PostNotAvailable />;
102
246
}
103
247
248
248
+
const rootAuthorDid = post.post.author.did;
249
249
+
104
250
// Collect all parent posts in order (oldest first)
105
251
const parents: ThreadViewPost[] = [];
106
252
let currentParent = post.parent;
···
137
283
parentPostUri={post.post.uri}
138
284
depth={0}
139
285
parentAuthorDid={post.post.author.did}
286
286
+
rootAuthorDid={rootAuthorDid}
287
287
+
documentUrls={documentUrls}
288
288
+
docDid={docDid}
140
289
/>
141
290
</div>
142
291
)}
···
188
337
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
189
338
depth: number;
190
339
parentAuthorDid?: string;
340
340
+
rootAuthorDid: string;
191
341
pageUri: string;
192
342
parentPostUri: string;
343
343
+
documentUrls: string[];
344
344
+
docDid: string;
193
345
}) {
194
194
-
const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props;
346
346
+
const {
347
347
+
replies,
348
348
+
depth,
349
349
+
parentAuthorDid,
350
350
+
rootAuthorDid,
351
351
+
pageUri,
352
352
+
parentPostUri,
353
353
+
documentUrls,
354
354
+
docDid,
355
355
+
} = props;
195
356
const collapsedThreads = useThreadState((s) => s.collapsedThreads);
196
357
const toggleCollapsed = useThreadState((s) => s.toggleCollapsed);
197
358
198
359
// Sort replies so that replies from the parent author come first
199
199
-
const sortedReplies = parentAuthorDid
200
200
-
? [...replies].sort((a, b) => {
201
201
-
const aIsAuthor =
202
202
-
AppBskyFeedDefs.isThreadViewPost(a) &&
203
203
-
a.post.author.did === parentAuthorDid;
204
204
-
const bIsAuthor =
205
205
-
AppBskyFeedDefs.isThreadViewPost(b) &&
206
206
-
b.post.author.did === parentAuthorDid;
207
207
-
if (aIsAuthor && !bIsAuthor) return -1;
208
208
-
if (!aIsAuthor && bIsAuthor) return 1;
209
209
-
return 0;
210
210
-
})
211
211
-
: replies;
360
360
+
const sortedReplies = useMemo(
361
361
+
() =>
362
362
+
parentAuthorDid
363
363
+
? [...replies].sort((a, b) => {
364
364
+
const aIsAuthor =
365
365
+
AppBskyFeedDefs.isThreadViewPost(a) &&
366
366
+
a.post.author.did === parentAuthorDid;
367
367
+
const bIsAuthor =
368
368
+
AppBskyFeedDefs.isThreadViewPost(b) &&
369
369
+
b.post.author.did === parentAuthorDid;
370
370
+
if (aIsAuthor && !bIsAuthor) return -1;
371
371
+
if (!aIsAuthor && bIsAuthor) return 1;
372
372
+
return 0;
373
373
+
})
374
374
+
: replies,
375
375
+
[replies, parentAuthorDid],
376
376
+
);
212
377
213
378
return (
214
379
<div className="replies flex flex-col gap-0 w-full">
···
249
414
isLast={index === replies.length - 1 && !hasReplies}
250
415
pageUri={pageUri}
251
416
parentPostUri={parentPostUri}
252
252
-
toggleCollapsed={(uri) => toggleCollapsed(uri)}
417
417
+
toggleCollapsed={toggleCollapsed}
253
418
isCollapsed={isCollapsed}
254
419
depth={props.depth}
420
420
+
rootAuthorDid={rootAuthorDid}
421
421
+
documentUrls={documentUrls}
422
422
+
docDid={docDid}
255
423
/>
256
424
);
257
425
})}
258
258
-
{pageUri && depth > 0 && replies.length > 3 && (
259
259
-
<ThreadLink
260
260
-
postUri={pageUri}
261
261
-
parent={{ type: "thread", uri: pageUri }}
262
262
-
className="flex justify-start text-sm text-accent-contrast h-fit hover:underline"
263
263
-
>
264
264
-
<div className="mx-[19px] w-0.5 h-[24px] bg-border-light" />
265
265
-
View {replies.length - 3} more{" "}
266
266
-
{replies.length === 4 ? "reply" : "replies"}
267
267
-
</ThreadLink>
268
268
-
)}
269
426
</div>
270
427
);
271
428
}
···
278
435
toggleCollapsed: (uri: string) => void;
279
436
isCollapsed: boolean;
280
437
depth: number;
438
438
+
rootAuthorDid: string;
439
439
+
documentUrls: string[];
440
440
+
docDid: string;
281
441
}) => {
282
282
-
const { post, pageUri, parentPostUri } = props;
283
283
-
const postView = post.post;
442
442
+
const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props;
284
443
285
285
-
const hasReplies = props.post.replies && props.post.replies.length > 0;
444
444
+
// Flatten same-author chains
445
445
+
const chain = flattenSameAuthorChain(post, rootAuthorDid);
446
446
+
const lastInChain = chain[chain.length - 1];
447
447
+
const hasReplies = lastInChain.replies && lastInChain.replies.length > 0;
448
448
+
const isTruncated =
449
449
+
!hasReplies &&
450
450
+
lastInChain.post.replyCount != null &&
451
451
+
lastInChain.post.replyCount > 0;
286
452
287
453
return (
288
454
<div className="flex h-fit relative">
···
296
462
onClick={(e) => {
297
463
e.preventDefault();
298
464
e.stopPropagation();
299
299
-
300
465
props.toggleCollapsed(parentPostUri);
301
466
}}
302
467
/>
···
305
470
<div
306
471
className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`}
307
472
>
308
308
-
<BskyPostContent
309
309
-
post={postView}
310
310
-
parent={{ type: "thread", uri: pageUri }}
311
311
-
showEmbed={false}
312
312
-
showBlueskyLink={false}
313
313
-
quoteEnabled
314
314
-
replyEnabled
315
315
-
replyOnClick={(e) => {
316
316
-
e.preventDefault();
317
317
-
props.toggleCollapsed(post.post.uri);
318
318
-
}}
319
319
-
className="text-sm"
320
320
-
/>
321
321
-
{hasReplies && props.depth < 3 && (
322
322
-
<div className="ml-[28px] flex grow ">
473
473
+
{/* Render chain: intermediate posts compact, last post full */}
474
474
+
{chain.length > 1 ? (
475
475
+
<>
476
476
+
{chain.slice(0, -1).map((chainPost) => (
477
477
+
<div
478
478
+
key={chainPost.post.uri}
479
479
+
className="flex gap-2 relative w-full pl-[6px] pb-2"
480
480
+
>
481
481
+
<div className="absolute top-0 bottom-0 left-[6px] w-5">
482
482
+
<div className="bg-border-light w-[2px] h-full mx-auto" />
483
483
+
</div>
484
484
+
<ReplyPostContent
485
485
+
post={chainPost.post}
486
486
+
pageUri={pageUri}
487
487
+
documentUrls={documentUrls}
488
488
+
docDid={docDid}
489
489
+
compact
490
490
+
/>
491
491
+
</div>
492
492
+
))}
493
493
+
<ReplyPostContent
494
494
+
post={lastInChain.post}
495
495
+
pageUri={pageUri}
496
496
+
documentUrls={documentUrls}
497
497
+
docDid={docDid}
498
498
+
compact
499
499
+
toggleCollapsed={() =>
500
500
+
props.toggleCollapsed(lastInChain.post.uri)
501
501
+
}
502
502
+
/>
503
503
+
</>
504
504
+
) : (
505
505
+
<ReplyPostContent
506
506
+
post={post.post}
507
507
+
pageUri={pageUri}
508
508
+
documentUrls={documentUrls}
509
509
+
docDid={docDid}
510
510
+
toggleCollapsed={() => props.toggleCollapsed(post.post.uri)}
511
511
+
/>
512
512
+
)}
513
513
+
514
514
+
{/* Render child replies */}
515
515
+
{hasReplies && props.depth < 10 && (
516
516
+
<div className="ml-[28px] flex grow">
323
517
{!props.isCollapsed && (
324
518
<Replies
325
519
pageUri={pageUri}
326
326
-
parentPostUri={post.post.uri}
327
327
-
replies={props.post.replies as any[]}
520
520
+
parentPostUri={lastInChain.post.uri}
521
521
+
replies={lastInChain.replies as any[]}
328
522
depth={props.depth + 1}
329
329
-
parentAuthorDid={props.post.post.author.did}
523
523
+
parentAuthorDid={lastInChain.post.author.did}
524
524
+
rootAuthorDid={rootAuthorDid}
525
525
+
documentUrls={documentUrls}
526
526
+
docDid={docDid}
330
527
/>
331
528
)}
332
529
</div>
333
530
)}
531
531
+
532
532
+
{/* Auto-load truncated replies */}
533
533
+
{isTruncated && props.depth < 10 && !props.isCollapsed && (
534
534
+
<div className="ml-[28px] flex grow">
535
535
+
<SubThread
536
536
+
postUri={lastInChain.post.uri}
537
537
+
pageUri={pageUri}
538
538
+
depth={props.depth}
539
539
+
rootAuthorDid={rootAuthorDid}
540
540
+
documentUrls={documentUrls}
541
541
+
docDid={docDid}
542
542
+
/>
543
543
+
</div>
544
544
+
)}
545
545
+
546
546
+
{/* Safety fallback at extreme depth */}
547
547
+
{(hasReplies || isTruncated) && props.depth >= 10 && (
548
548
+
<div className="ml-[28px]">
549
549
+
<ThreadLink
550
550
+
postUri={lastInChain.post.uri}
551
551
+
parent={{ type: "thread", uri: pageUri }}
552
552
+
className="text-sm text-accent-contrast hover:underline"
553
553
+
>
554
554
+
Continue thread
555
555
+
</ThreadLink>
556
556
+
</div>
557
557
+
)}
334
558
</div>
335
559
</div>
336
560
);
337
561
};
562
562
+
563
563
+
// Renders a single post's content with optional inline quote detection
564
564
+
function ReplyPostContent(props: {
565
565
+
post: AppBskyFeedDefs.PostView;
566
566
+
pageUri: string;
567
567
+
documentUrls: string[];
568
568
+
docDid: string;
569
569
+
compact?: boolean;
570
570
+
toggleCollapsed?: () => void;
571
571
+
}) {
572
572
+
const { post, pageUri, documentUrls, docDid: did, compact } = props;
573
573
+
574
574
+
// Detect leaflet links in this post
575
575
+
const docLink = findDocumentQuoteLink(post, documentUrls);
576
576
+
const page = { type: "thread" as const, uri: pageUri };
577
577
+
578
578
+
const quoteBlock = docLink?.quotePosition && (
579
579
+
<div className="mb-1 ml-[32px]">
580
580
+
<QuoteContent position={docLink.quotePosition} index={0} did={did} />
581
581
+
</div>
582
582
+
);
583
583
+
584
584
+
if (compact) {
585
585
+
return (
586
586
+
<div className="flex flex-col w-full">
587
587
+
{quoteBlock}
588
588
+
<CompactBskyPostContent
589
589
+
post={post}
590
590
+
parent={page}
591
591
+
quoteEnabled
592
592
+
replyEnabled={!!props.toggleCollapsed}
593
593
+
replyOnClick={
594
594
+
props.toggleCollapsed
595
595
+
? (e) => {
596
596
+
e.preventDefault();
597
597
+
props.toggleCollapsed!();
598
598
+
}
599
599
+
: undefined
600
600
+
}
601
601
+
/>
602
602
+
</div>
603
603
+
);
604
604
+
}
605
605
+
606
606
+
return (
607
607
+
<div className="flex flex-col w-full">
608
608
+
{quoteBlock}
609
609
+
<BskyPostContent
610
610
+
post={post}
611
611
+
parent={page}
612
612
+
showEmbed={!docLink?.isEmbed}
613
613
+
showBlueskyLink={false}
614
614
+
quoteEnabled
615
615
+
replyEnabled
616
616
+
replyOnClick={
617
617
+
props.toggleCollapsed
618
618
+
? (e) => {
619
619
+
e.preventDefault();
620
620
+
props.toggleCollapsed!();
621
621
+
}
622
622
+
: undefined
623
623
+
}
624
624
+
className="text-sm"
625
625
+
/>
626
626
+
</div>
627
627
+
);
628
628
+
}
629
629
+
630
630
+
// Auto-loads a sub-thread when replies were truncated by the API depth limit
631
631
+
function SubThread(props: {
632
632
+
postUri: string;
633
633
+
pageUri: string;
634
634
+
depth: number;
635
635
+
rootAuthorDid: string;
636
636
+
documentUrls: string[];
637
637
+
docDid: string;
638
638
+
}) {
639
639
+
const { data: thread, isLoading } = useSWR(
640
640
+
getThreadKey(props.postUri),
641
641
+
() => fetchThread(props.postUri),
642
642
+
);
643
643
+
644
644
+
if (isLoading) {
645
645
+
return (
646
646
+
<div className="flex items-center gap-1 text-tertiary italic text-xs py-2">
647
647
+
<DotLoader />
648
648
+
</div>
649
649
+
);
650
650
+
}
651
651
+
652
652
+
if (!thread || !AppBskyFeedDefs.isThreadViewPost(thread)) return null;
653
653
+
if (!thread.replies || thread.replies.length === 0) return null;
654
654
+
655
655
+
return (
656
656
+
<Replies
657
657
+
replies={thread.replies as any[]}
658
658
+
pageUri={props.pageUri}
659
659
+
parentPostUri={props.postUri}
660
660
+
depth={props.depth + 1}
661
661
+
parentAuthorDid={thread.post.author.did}
662
662
+
rootAuthorDid={props.rootAuthorDid}
663
663
+
documentUrls={props.documentUrls}
664
664
+
docDid={props.docDid}
665
665
+
/>
666
666
+
);
667
667
+
}