a tool for shared writing and social publishing

linearize single author bsky threads

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