diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx index 83c79789..95ba6444 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx @@ -4,8 +4,7 @@ import useSWR from "swr"; import { PageWrapper } from "components/Pages/Page"; import { useDrawerOpen } from "./Interactions/InteractionDrawer"; import { DotLoader } from "components/utils/DotLoader"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { openPage } from "./postPageState"; + import { BskyPostContent } from "./BskyPostContent"; import { QuotesLink, @@ -73,32 +72,26 @@ function QuotesContent(props: { posts: PostView[]; postUri: string }) { return (
- {posts.map((post, index) => ( - <> - - {posts.length !== index + 1 && ( -
- )} - - ))} + {posts.map((post, index) => { + const parent = { type: "quotes" as const, uri: postUri }; + return ( + <> + {" "} + {posts.length !== index + 1 && ( +
+ )} + + ); + })}
); } - -function QuotePost(props: { post: PostView; quotesUri: string }) { - const { post, quotesUri } = props; - const parent = { type: "quotes" as const, uri: quotesUri }; - - return ( - - ); -} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index 132bbe83..41f2ff0a 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -14,6 +14,8 @@ import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { Avatar } from "components/Avatar"; import { timeAgo } from "src/utils/timeAgo"; import { ProfilePopover } from "components/ProfilePopover"; +import { QuotePosition } from "./quotePosition"; +import { QuoteContent } from "./Interactions/Quotes"; type PostView = AppBskyFeedDefs.PostView; @@ -29,6 +31,11 @@ export function BskyPostContent(props: { replyEnabled?: boolean; replyOnClick?: (e: React.MouseEvent) => void; clientHost?: string; + hasQuote?: { + position: QuotePosition; + index: number; + did: string; + }; }) { const { post, @@ -41,6 +48,7 @@ export function BskyPostContent(props: { replyEnabled, replyOnClick, clientHost = "bsky.app", + hasQuote, } = props; const record = post.record as AppBskyFeedPost.Record; @@ -55,6 +63,18 @@ export function BskyPostContent(props: { openPage(parent, { type: "thread", uri: post.uri }); }} /> + {/*{props.parent?.type === "thread" && props.parent.uri && ( +
+
+
+
+
+ Replying to Eileen + + This is a one-liner of content + +
+ )}*/}
-
-
- -
-
- -
- {showEmbed && post.embed && ( -
e.stopPropagation()} - > - -
- )} +
+ + +
+ {props.hasQuote && ( + + )} +
+
+ {showEmbed && post.embed && ( +
e.stopPropagation()} + > + +
+ )}
{props.showBlueskyLink || (props.post.quoteCount && props.post.quoteCount > 0) || (props.post.replyCount && props.post.replyCount > 0) ? (
+
{replyContent && (props.replyEnabled ? ( -
{props.record.attachment && PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( -
+
{ - const { uri: document_uri } = useDocument(); + const { + uri: document_uri, + normalizedDocument, + normalizedPublication, + } = useDocument(); + + // URLs that point to this document, used to detect when a post's embed is + // just a link card back to this leaflet (redundant with the quote we render) + const documentUrls = getDocumentUrls( + normalizedDocument, + document_uri, + normalizedPublication, + ); // Fetch Bluesky post data for all URIs const uris = props.quotesAndMentions.map((q) => q.uri); @@ -85,8 +103,8 @@ export const MentionsDrawerContent = (props: { ); // Separate quotes with links (quoted content) from direct mentions - const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); - const directMentions = props.quotesAndMentions.filter((q) => !q.link); + // const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); + // const directMentions = props.quotesAndMentions.filter((q) => !q.link); // Create a map of URIs to post views for easy lookup const postViewMap = new Map(); @@ -100,123 +118,77 @@ export const MentionsDrawerContent = (props: { const scoreB = engagementScore(postViewMap.get(b.uri)); return scoreB - scoreA; }; - quotesWithLinks.sort(byEngagement); - directMentions.sort(byEngagement); + let sortedBskyMentions = props.quotesAndMentions.sort(byEngagement); return ( <> {props.quotesAndMentions.length === 0 ? ( -
+
no quotes yet!
highlight any part of this post to quote it
-
+ ) : isLoading ? (
loading
) : ( -
- {quotesWithLinks.length > 0 && ( -
-

Quotes on Bluesky

- {/* Quotes with links (quoted content) */} - {quotesWithLinks.map((q, index) => { - return ( - <> - - {quotesWithLinks.length !== index + 1 && ( -
- )} - - ); - })} -
- )} - {/* Direct post mentions (without quoted content) */} - {directMentions.length > 0 && ( -
-

Mentions on Bluesky

- {directMentions.map((q, index) => { - const post = postViewMap.get(q.uri); - if (!post) return null; - - const parent = { type: "thread" as const, uri: q.uri }; - return ( - <> - - {directMentions.length !== index + 1 && ( -
- )} - - ); - })} -
- )} -
+ sortedBskyMentions.length > 0 && ( +
+ {sortedBskyMentions.map((q, index) => { + const post = postViewMap.get(q.uri); + if (!post) return null; + const parent = { type: "thread" as const, uri: q.uri }; + let quotePosition: QuotePosition | null = null; + if (q.link) { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (quoteParam) { + quotePosition = decodeQuotePosition(quoteParam) ?? null; + } + } + + // Hide the embed when it's just a link card pointing back to this + // document; show it for any other embed (image, link, quoted post) + const showEmbed = !( + AppBskyEmbedExternal.isView(post.embed) && + matchDocumentUrl(post.embed.external.uri, documentUrls) + ); + + return ( + <> + +
+ + ); + })} +
+ ) )} ); }; -const Quote = (props: { - q: { - uri: string; - link?: string; - }; - index: number; - did: string; - postViewMap: Map; -}) => { - const post = props.postViewMap.get(props.q.uri); - if (!post || !props.q.link) return null; - const parent = { type: "thread" as const, uri: props.q.uri }; - const url = new URL(props.q.link); - const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; - const quotePosition = decodeQuotePosition(quoteParam); - if (!quotePosition) return null; - - return ( -
- - -
- -
- ); -}; - export const QuoteContent = (props: { position: QuotePosition; index: number; @@ -276,10 +248,10 @@ export const QuoteContent = (props: { }); }} > -
-
- -
+
+
+ +
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx index 4005c0ee..e8a46456 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -25,10 +25,11 @@ import { prefetchThread, } from "./PostLinks"; import { useDocument } from "contexts/DocumentContext"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; import { QuoteContent } from "./Interactions/Quotes"; import { decodeQuotePosition, + getDocumentUrls, + matchDocumentUrl, type QuotePosition, } from "./quotePosition"; @@ -69,39 +70,6 @@ function flattenSameAuthorChain( return chain; } -// Check if a URL matches any of the document's known URLs, -// and extract the quote position if present -function matchDocumentUrl( - uri: string, - documentUrls: string[], -): { url: string; quotePosition: QuotePosition | null } | null { - try { - const url = new URL(uri); - const parts = url.pathname.split("/l-quote/"); - const pathWithoutQuote = parts[0]; - const quoteParam = parts[1]; - const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace( - /\/$/, - "", - ); - - for (const docUrl of documentUrls) { - const normalized = docUrl.replace(/\/$/, ""); - if (fullUrlWithoutQuote === normalized) { - return { - url: uri, - quotePosition: quoteParam - ? decodeQuotePosition(quoteParam) - : null, - }; - } - } - } catch { - return null; - } - return null; -} - // Scan a post's facets and embed for links to the current document function findDocumentQuoteLink( post: AppBskyFeedDefs.PostView, @@ -129,10 +97,7 @@ function findDocumentQuoteLink( // Check external embed URI if (post.embed && AppBskyEmbedExternal.isView(post.embed)) { - const match = matchDocumentUrl( - post.embed.external.uri, - documentUrls, - ); + const match = matchDocumentUrl(post.embed.external.uri, documentUrls); if (match) return { ...match, isEmbed: true }; } @@ -196,28 +161,10 @@ function ThreadContent(props: { post: ThreadType; parentUri: string }) { const docAtUri = useMemo(() => new AtUri(docUri), [docUri]); const docDid = docAtUri.host; - const documentUrls = useMemo(() => { - const urls: string[] = []; - const canonicalUrl = getDocumentURL( - normalizedDocument, - docUri, - normalizedPublication, - ); - if (canonicalUrl.startsWith("http")) { - urls.push(canonicalUrl); - } else { - urls.push(`https://leaflet.pub${canonicalUrl}`); - } - urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`); - if ( - normalizedDocument.site && - normalizedDocument.site.startsWith("http") - ) { - const path = normalizedDocument.path || "/" + docAtUri.rkey; - urls.push(normalizedDocument.site + path); - } - return urls; - }, [docUri, docAtUri, normalizedDocument, normalizedPublication]); + const documentUrls = useMemo( + () => getDocumentUrls(normalizedDocument, docUri, normalizedPublication), + [docUri, normalizedDocument, normalizedPublication], + ); // Scroll the main post into view when the thread loads useEffect(() => { @@ -439,7 +386,8 @@ const ReplyPost = (props: { documentUrls: string[]; docDid: string; }) => { - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props; + const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = + props; // Flatten same-author chains const chain = flattenSameAuthorChain(post, rootAuthorDid); @@ -583,7 +531,7 @@ function ReplyPostContent(props: { if (compact) { return ( -
+
{quoteBlock} +
{quoteBlock}
); @@ -636,9 +584,8 @@ function SubThread(props: { documentUrls: string[]; docDid: string; }) { - const { data: thread, isLoading } = useSWR( - getThreadKey(props.postUri), - () => fetchThread(props.postUri), + const { data: thread, isLoading } = useSWR(getThreadKey(props.postUri), () => + fetchThread(props.postUri), ); if (isLoading) { diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/quotePosition.ts b/app/(app)/lish/[did]/[publication]/[rkey]/quotePosition.ts index 20182c8d..ef5542f3 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/quotePosition.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/quotePosition.ts @@ -1,3 +1,10 @@ +import { AtUri } from "@atproto/syntax"; +import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import type { + NormalizedDocument, + NormalizedPublication, +} from "src/utils/normalizeRecords"; + export interface QuotePosition { pageId?: string; start: { @@ -69,3 +76,62 @@ export function decodeQuotePosition(encoded: string): QuotePosition | null { return null; } } + +/** + * Builds the list of canonical/known URLs that point to a given document. + * Mirrors the URLs a Bluesky post might link to when referencing the doc. + */ +export function getDocumentUrls( + doc: NormalizedDocument, + docUri: string, + publication?: NormalizedPublication | null, +): string[] { + const urls: string[] = []; + const docAtUri = new AtUri(docUri); + + const canonicalUrl = getDocumentURL(doc, docUri, publication); + if (canonicalUrl.startsWith("http")) { + urls.push(canonicalUrl); + } else { + urls.push(`https://leaflet.pub${canonicalUrl}`); + } + urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`); + if (doc.site && doc.site.startsWith("http")) { + const path = doc.path || "/" + docAtUri.rkey; + urls.push(doc.site + path); + } + return urls; +} + +/** + * Check if a URL matches any of the document's known URLs, + * and extract the quote position if present. + */ +export function matchDocumentUrl( + uri: string, + documentUrls: string[], +): { url: string; quotePosition: QuotePosition | null } | null { + try { + const url = new URL(uri); + const parts = url.pathname.split("/l-quote/"); + const pathWithoutQuote = parts[0]; + const quoteParam = parts[1]; + const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace( + /\/$/, + "", + ); + + for (const docUrl of documentUrls) { + const normalized = docUrl.replace(/\/$/, ""); + if (fullUrlWithoutQuote === normalized) { + return { + url: uri, + quotePosition: quoteParam ? decodeQuotePosition(quoteParam) : null, + }; + } + } + } catch { + return null; + } + return null; +}