a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

adjust styling to make the comments and bsky posts more uniform

celine (May 29, 2026, 4:47 PM EDT) 1203ffdb 9bd86ab3

+238 -235
+21 -28
app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 4 4 import { PageWrapper } from "components/Pages/Page"; 5 5 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 6 import { DotLoader } from "components/utils/DotLoader"; 7 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 - import { openPage } from "./postPageState"; 7 + 9 8 import { BskyPostContent } from "./BskyPostContent"; 10 9 import { 11 10 QuotesLink, ··· 73 72 74 73 return ( 75 74 <div className="flex flex-col gap-0"> 76 - {posts.map((post, index) => ( 77 - <> 78 - <QuotePost key={post.uri} post={post} quotesUri={postUri} /> 79 - {posts.length !== index + 1 && ( 80 - <hr className="border-border-light my-4" /> 81 - )} 82 - </> 83 - ))} 75 + {posts.map((post, index) => { 76 + const parent = { type: "quotes" as const, uri: postUri }; 77 + return ( 78 + <> 79 + <BskyPostContent 80 + post={post} 81 + parent={parent} 82 + showEmbed={true} 83 + compactEmbed 84 + showBlueskyLink={true} 85 + quoteEnabled 86 + replyEnabled 87 + className="relative rounded text-sm" 88 + />{" "} 89 + {posts.length !== index + 1 && ( 90 + <hr className="border-border-light my-4" /> 91 + )} 92 + </> 93 + ); 94 + })} 84 95 </div> 85 96 ); 86 97 } 87 - 88 - function QuotePost(props: { post: PostView; quotesUri: string }) { 89 - const { post, quotesUri } = props; 90 - const parent = { type: "quotes" as const, uri: quotesUri }; 91 - 92 - return ( 93 - <BskyPostContent 94 - post={post} 95 - parent={parent} 96 - showEmbed={true} 97 - compactEmbed 98 - showBlueskyLink={true} 99 - quoteEnabled 100 - replyEnabled 101 - className="relative rounded text-sm" 102 - /> 103 - ); 104 - }
+55 -29
app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 14 14 import { Avatar } from "components/Avatar"; 15 15 import { timeAgo } from "src/utils/timeAgo"; 16 16 import { ProfilePopover } from "components/ProfilePopover"; 17 + import { QuotePosition } from "./quotePosition"; 18 + import { QuoteContent } from "./Interactions/Quotes"; 17 19 18 20 type PostView = AppBskyFeedDefs.PostView; 19 21 ··· 29 31 replyEnabled?: boolean; 30 32 replyOnClick?: (e: React.MouseEvent) => void; 31 33 clientHost?: string; 34 + hasQuote?: { 35 + position: QuotePosition; 36 + index: number; 37 + did: string; 38 + }; 32 39 }) { 33 40 const { 34 41 post, ··· 41 48 replyEnabled, 42 49 replyOnClick, 43 50 clientHost = "bsky.app", 51 + hasQuote, 44 52 } = props; 45 53 46 54 const record = post.record as AppBskyFeedPost.Record; ··· 55 63 openPage(parent, { type: "thread", uri: post.uri }); 56 64 }} 57 65 /> 66 + {/*{props.parent?.type === "thread" && props.parent.uri && ( 67 + <div className="text-xs flex gap-2 px-1 text-tertiary"> 68 + <div className="flex flex-col shrink-0"> 69 + <div className="h-4 w-4 mx-0.5 bg-test rounded-full shrink-0" /> 70 + <div className="w-0.5 h-3 bg-border mx-auto" /> 71 + </div> 72 + <strong> Replying to Eileen </strong> 73 + <span className="font-normal text-tertiary"> 74 + This is a one-liner of content 75 + </span> 76 + </div> 77 + )}*/} 58 78 59 79 <div 60 80 className={`flex gap-2 text-left w-full pointer-events-none ${props.className}`} ··· 70 90 size={avatarSize ? avatarSize : "medium"} 71 91 /> 72 92 </div> 73 - <div className={`flex flex-col min-w-0 w-full mb-2`}> 74 - <div 75 - className={`bskyPostTextContent flex flex-col grow text-left w-full ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 76 - > 77 - <PostInfo 78 - displayName={post.author.displayName} 79 - handle={post.author.handle} 80 - createdAt={record.createdAt} 81 - /> 82 93 83 - <div className={`postContent flex flex-col gap-2 mt-0.5`}> 84 - <div className="text-secondary"> 85 - <BlueskyRichText record={record} /> 86 - </div> 87 - {showEmbed && post.embed && ( 88 - <div 89 - className="pointer-events-auto relative" 90 - onClick={(e) => e.stopPropagation()} 91 - > 92 - <BlueskyEmbed 93 - parent={parent} 94 - embed={post.embed} 95 - compact={compactEmbed} 96 - postUrl={url} 97 - className="text-sm" 98 - /> 99 - </div> 100 - )} 94 + <div 95 + className={`bskyPostContent flex flex-col grow text-left w-full min-w-0 ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 96 + > 97 + <PostInfo 98 + displayName={post.author.displayName} 99 + handle={post.author.handle} 100 + createdAt={record.createdAt} 101 + /> 102 + 103 + <div className={`bskyPostBody flex flex-col min-w-0 w-full pb-1`}> 104 + {props.hasQuote && ( 105 + <QuoteContent 106 + index={props.hasQuote?.index} 107 + did={props.hasQuote?.did} 108 + position={props.hasQuote?.position} 109 + /> 110 + )} 111 + <div className="bskyPostTextContent text-secondary mt-0.5"> 112 + <BlueskyRichText record={record} /> 101 113 </div> 114 + {showEmbed && post.embed && ( 115 + <div 116 + className="bskyPostEmbedWrapper pointer-events-auto relative mt-2" 117 + onClick={(e) => e.stopPropagation()} 118 + > 119 + <BlueskyEmbed 120 + parent={parent} 121 + embed={post.embed} 122 + compact={compactEmbed} 123 + postUrl={url} 124 + className="text-sm" 125 + /> 126 + </div> 127 + )} 102 128 </div> 103 129 {props.showBlueskyLink || 104 130 (props.post.quoteCount && props.post.quoteCount > 0) || 105 131 (props.post.replyCount && props.post.replyCount > 0) ? ( 106 132 <div 107 - className={`postCountsAndLink flex gap-2 items-center justify-between mt-2 pointer-events-auto`} 133 + className={`postCountsAndLink flex gap-2 items-center justify-between pointer-events-auto`} 108 134 > 109 135 <PostCounts 110 136 post={post} ··· 276 302 ); 277 303 278 304 return ( 279 - <div className="postCounts flex gap-2 items-center w-full text-tertiary"> 305 + <div className="postCounts flex gap-2 items-center w-full text-tertiary mb-1"> 280 306 {replyContent && 281 307 (props.replyEnabled ? ( 282 308 <ThreadLink
+1 -2
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 145 145 createdAt={props.record.createdAt} 146 146 compact 147 147 /> 148 - <div className="spacer w-full h-0.5" /> 149 148 150 149 {props.record.attachment && 151 150 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 152 - <div className="mt-1 mb-2"> 151 + <div className="my-2 "> 153 152 <QuoteContent 154 153 index={-1} 155 154 position={props.record.attachment.quote}
+81 -109
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 3 3 import { useIsMobile } from "src/hooks/isMobile"; 4 4 import { setInteractionState } from "./Interactions"; 5 5 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 6 - import { AtUri, AppBskyFeedPost } from "@atproto/api"; 6 + import { AtUri, AppBskyFeedPost, AppBskyEmbedExternal } from "@atproto/api"; 7 7 import { 8 8 PubLeafletBlocksText, 9 9 PubLeafletBlocksUnorderedList, ··· 13 13 } from "lexicons/api"; 14 14 import { useDocument } from "contexts/DocumentContext"; 15 15 import { useLeafletContent } from "contexts/LeafletContentContext"; 16 - import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 16 + import { 17 + decodeQuotePosition, 18 + getDocumentUrls, 19 + matchDocumentUrl, 20 + QuotePosition, 21 + } from "../quotePosition"; 17 22 import { useActiveHighlightState } from "../useHighlight"; 18 23 import { PostContent } from "../PostContent"; 19 24 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; ··· 25 30 import { QuoteTiny } from "components/Icons/QuoteTiny"; 26 31 import { ThreadLink, QuotesLink } from "../PostLinks"; 27 32 import { BskyPostContent } from "../BskyPostContent"; 33 + import { EmptyState } from "components/EmptyState"; 28 34 29 35 function engagementScore(post: PostView | undefined): number { 30 36 if (!post) return 0; ··· 75 81 quotesAndMentions: { uri: string; link?: string }[]; 76 82 did: string; 77 83 }) => { 78 - const { uri: document_uri } = useDocument(); 84 + const { 85 + uri: document_uri, 86 + normalizedDocument, 87 + normalizedPublication, 88 + } = useDocument(); 89 + 90 + // URLs that point to this document, used to detect when a post's embed is 91 + // just a link card back to this leaflet (redundant with the quote we render) 92 + const documentUrls = getDocumentUrls( 93 + normalizedDocument, 94 + document_uri, 95 + normalizedPublication, 96 + ); 79 97 80 98 // Fetch Bluesky post data for all URIs 81 99 const uris = props.quotesAndMentions.map((q) => q.uri); ··· 85 103 ); 86 104 87 105 // Separate quotes with links (quoted content) from direct mentions 88 - const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); 89 - const directMentions = props.quotesAndMentions.filter((q) => !q.link); 106 + // const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); 107 + // const directMentions = props.quotesAndMentions.filter((q) => !q.link); 90 108 91 109 // Create a map of URIs to post views for easy lookup 92 110 const postViewMap = new Map<string, PostView>(); ··· 100 118 const scoreB = engagementScore(postViewMap.get(b.uri)); 101 119 return scoreB - scoreA; 102 120 }; 103 - quotesWithLinks.sort(byEngagement); 104 - directMentions.sort(byEngagement); 121 + let sortedBskyMentions = props.quotesAndMentions.sort(byEngagement); 105 122 106 123 return ( 107 124 <> 108 125 {props.quotesAndMentions.length === 0 ? ( 109 - <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 126 + <EmptyState container="opaque" title="It's quiet… for now."> 110 127 <div className="font-bold">no quotes yet!</div> 111 128 <div>highlight any part of this post to quote it</div> 112 - </div> 129 + </EmptyState> 113 130 ) : isLoading ? ( 114 131 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 115 132 <span>loading</span> 116 133 <DotLoader /> 117 134 </div> 118 135 ) : ( 119 - <div className="flex flex-col gap-8 w-full"> 120 - {quotesWithLinks.length > 0 && ( 121 - <div className="flex flex-col w-full"> 122 - <h4 className="mb-2">Quotes on Bluesky</h4> 123 - {/* Quotes with links (quoted content) */} 124 - {quotesWithLinks.map((q, index) => { 125 - return ( 126 - <> 127 - <Quote 128 - key={q.uri} 129 - q={q} 130 - index={index} 131 - did={props.did} 132 - postViewMap={postViewMap} 133 - /> 134 - {quotesWithLinks.length !== index + 1 && ( 135 - <hr className="border-border-light my-4" /> 136 - )} 137 - </> 138 - ); 139 - })} 140 - </div> 141 - )} 142 - {/* Direct post mentions (without quoted content) */} 143 - {directMentions.length > 0 && ( 144 - <div className="flex flex-col"> 145 - <h4 className="mb-2">Mentions on Bluesky</h4> 146 - {directMentions.map((q, index) => { 147 - const post = postViewMap.get(q.uri); 148 - if (!post) return null; 136 + sortedBskyMentions.length > 0 && ( 137 + <div className="flex flex-col gap-6"> 138 + {sortedBskyMentions.map((q, index) => { 139 + const post = postViewMap.get(q.uri); 140 + if (!post) return null; 141 + const parent = { type: "thread" as const, uri: q.uri }; 142 + let quotePosition: QuotePosition | null = null; 143 + if (q.link) { 144 + const url = new URL(q.link); 145 + const quoteParam = url.pathname.split("/l-quote/")[1]; 146 + if (quoteParam) { 147 + quotePosition = decodeQuotePosition(quoteParam) ?? null; 148 + } 149 + } 149 150 150 - const parent = { type: "thread" as const, uri: q.uri }; 151 - return ( 152 - <> 153 - <BskyPostContent 154 - key={`mention-${index}`} 155 - post={post} 156 - parent={parent} 157 - showBlueskyLink={true} 158 - showEmbed={true} 159 - avatarSize="medium" 160 - quoteEnabled 161 - replyEnabled 162 - className="text-sm" 163 - compactEmbed 164 - /> 165 - {directMentions.length !== index + 1 && ( 166 - <hr className="border-border-light my-4" /> 167 - )} 168 - </> 169 - ); 170 - })} 171 - </div> 172 - )} 173 - </div> 151 + // Hide the embed when it's just a link card pointing back to this 152 + // document; show it for any other embed (image, link, quoted post) 153 + const showEmbed = !( 154 + AppBskyEmbedExternal.isView(post.embed) && 155 + matchDocumentUrl(post.embed.external.uri, documentUrls) 156 + ); 157 + 158 + return ( 159 + <> 160 + <BskyPostContent 161 + key={`mention-${index}`} 162 + post={post} 163 + parent={parent} 164 + showBlueskyLink={true} 165 + showEmbed={showEmbed} 166 + avatarSize="medium" 167 + quoteEnabled 168 + replyEnabled 169 + className="text-sm" 170 + compactEmbed 171 + hasQuote={ 172 + quotePosition 173 + ? { 174 + index: index, 175 + did: props.did, 176 + position: quotePosition, 177 + } 178 + : undefined 179 + } 180 + /> 181 + <hr className="border-border-light last:hidden" /> 182 + </> 183 + ); 184 + })} 185 + </div> 186 + ) 174 187 )} 175 188 </> 176 189 ); 177 190 }; 178 191 179 - const Quote = (props: { 180 - q: { 181 - uri: string; 182 - link?: string; 183 - }; 184 - index: number; 185 - did: string; 186 - postViewMap: Map<string, PostView>; 187 - }) => { 188 - const post = props.postViewMap.get(props.q.uri); 189 - if (!post || !props.q.link) return null; 190 - const parent = { type: "thread" as const, uri: props.q.uri }; 191 - const url = new URL(props.q.link); 192 - const quoteParam = url.pathname.split("/l-quote/")[1]; 193 - if (!quoteParam) return null; 194 - const quotePosition = decodeQuotePosition(quoteParam); 195 - if (!quotePosition) return null; 196 - 197 - return ( 198 - <div key={`quote-${props.index}`} className="flex flex-col w-full"> 199 - <QuoteContent 200 - index={props.index} 201 - did={props.did} 202 - position={quotePosition} 203 - /> 204 - 205 - <div className="h-3 w-1 ml-[11px] border-l border-border-light" /> 206 - <BskyPostContent 207 - post={post} 208 - parent={parent} 209 - showBlueskyLink={true} 210 - showEmbed={false} 211 - avatarSize="medium" 212 - quoteEnabled 213 - replyEnabled 214 - className="text-sm" 215 - /> 216 - </div> 217 - ); 218 - }; 219 - 220 192 export const QuoteContent = (props: { 221 193 position: QuotePosition; 222 194 index: number; ··· 276 248 }); 277 249 }} 278 250 > 279 - <div className="flex gap-[6px] items-stretch light-container italic rounded-md px-2 pt-1 "> 280 - <div className="flex flex-col gap-1 justify-center h-full pt-2 pb-3"> 281 - <QuoteTiny className="shrink-0 text-tertiary" /> 282 - <div className="w-0.5 grow border border-l border-border h-full" /> 251 + <div className="flex gap-3 items-stretch italic rounded-md pt-1 "> 252 + <div className="relative flex"> 253 + <QuoteTiny className="shrink-0 text-tertiary bg-bg-page absolute -left-2 top-3" /> 254 + <div className="w-0.5 grow border border-border mx-auto my-2" /> 283 255 </div> 284 256 <PostContent 285 257 pollData={[]} ··· 289 261 blocks={content} 290 262 did={props.did} 291 263 preview 292 - className="pt-2! px-0! text-tertiary" 264 + className="pt-3! px-0! text-tertiary" 293 265 /> 294 266 </div> 295 267 </div>
+14 -67
app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 25 25 prefetchThread, 26 26 } from "./PostLinks"; 27 27 import { useDocument } from "contexts/DocumentContext"; 28 - import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 29 28 import { QuoteContent } from "./Interactions/Quotes"; 30 29 import { 31 30 decodeQuotePosition, 31 + getDocumentUrls, 32 + matchDocumentUrl, 32 33 type QuotePosition, 33 34 } from "./quotePosition"; 34 35 ··· 69 70 return chain; 70 71 } 71 72 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 73 // Scan a post's facets and embed for links to the current document 106 74 function findDocumentQuoteLink( 107 75 post: AppBskyFeedDefs.PostView, ··· 129 97 130 98 // Check external embed URI 131 99 if (post.embed && AppBskyEmbedExternal.isView(post.embed)) { 132 - const match = matchDocumentUrl( 133 - post.embed.external.uri, 134 - documentUrls, 135 - ); 100 + const match = matchDocumentUrl(post.embed.external.uri, documentUrls); 136 101 if (match) return { ...match, isEmbed: true }; 137 102 } 138 103 ··· 196 161 const docAtUri = useMemo(() => new AtUri(docUri), [docUri]); 197 162 const docDid = docAtUri.host; 198 163 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]); 164 + const documentUrls = useMemo( 165 + () => getDocumentUrls(normalizedDocument, docUri, normalizedPublication), 166 + [docUri, normalizedDocument, normalizedPublication], 167 + ); 221 168 222 169 // Scroll the main post into view when the thread loads 223 170 useEffect(() => { ··· 439 386 documentUrls: string[]; 440 387 docDid: string; 441 388 }) => { 442 - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props; 389 + const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = 390 + props; 443 391 444 392 // Flatten same-author chains 445 393 const chain = flattenSameAuthorChain(post, rootAuthorDid); ··· 583 531 584 532 if (compact) { 585 533 return ( 586 - <div className="flex flex-col w-full"> 534 + <div className="bskyPostReplyCompact flex flex-col w-full"> 587 535 {quoteBlock} 588 536 <CompactBskyPostContent 589 537 post={post} ··· 604 552 } 605 553 606 554 return ( 607 - <div className="flex flex-col w-full"> 555 + <div className="bskyPostReply flex flex-col w-full "> 608 556 {quoteBlock} 609 557 <BskyPostContent 610 558 post={post} ··· 621 569 } 622 570 : undefined 623 571 } 624 - className="text-sm" 572 + className=" text-sm pt-4" 625 573 /> 626 574 </div> 627 575 ); ··· 636 584 documentUrls: string[]; 637 585 docDid: string; 638 586 }) { 639 - const { data: thread, isLoading } = useSWR( 640 - getThreadKey(props.postUri), 641 - () => fetchThread(props.postUri), 587 + const { data: thread, isLoading } = useSWR(getThreadKey(props.postUri), () => 588 + fetchThread(props.postUri), 642 589 ); 643 590 644 591 if (isLoading) {
+66
app/(app)/lish/[did]/[publication]/[rkey]/quotePosition.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 3 + import type { 4 + NormalizedDocument, 5 + NormalizedPublication, 6 + } from "src/utils/normalizeRecords"; 7 + 1 8 export interface QuotePosition { 2 9 pageId?: string; 3 10 start: { ··· 69 76 return null; 70 77 } 71 78 } 79 + 80 + /** 81 + * Builds the list of canonical/known URLs that point to a given document. 82 + * Mirrors the URLs a Bluesky post might link to when referencing the doc. 83 + */ 84 + export function getDocumentUrls( 85 + doc: NormalizedDocument, 86 + docUri: string, 87 + publication?: NormalizedPublication | null, 88 + ): string[] { 89 + const urls: string[] = []; 90 + const docAtUri = new AtUri(docUri); 91 + 92 + const canonicalUrl = getDocumentURL(doc, docUri, publication); 93 + if (canonicalUrl.startsWith("http")) { 94 + urls.push(canonicalUrl); 95 + } else { 96 + urls.push(`https://leaflet.pub${canonicalUrl}`); 97 + } 98 + urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`); 99 + if (doc.site && doc.site.startsWith("http")) { 100 + const path = doc.path || "/" + docAtUri.rkey; 101 + urls.push(doc.site + path); 102 + } 103 + return urls; 104 + } 105 + 106 + /** 107 + * Check if a URL matches any of the document's known URLs, 108 + * and extract the quote position if present. 109 + */ 110 + export function matchDocumentUrl( 111 + uri: string, 112 + documentUrls: string[], 113 + ): { url: string; quotePosition: QuotePosition | null } | null { 114 + try { 115 + const url = new URL(uri); 116 + const parts = url.pathname.split("/l-quote/"); 117 + const pathWithoutQuote = parts[0]; 118 + const quoteParam = parts[1]; 119 + const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace( 120 + /\/$/, 121 + "", 122 + ); 123 + 124 + for (const docUrl of documentUrls) { 125 + const normalized = docUrl.replace(/\/$/, ""); 126 + if (fullUrlWithoutQuote === normalized) { 127 + return { 128 + url: uri, 129 + quotePosition: quoteParam ? decodeQuotePosition(quoteParam) : null, 130 + }; 131 + } 132 + } 133 + } catch { 134 + return null; 135 + } 136 + return null; 137 + }