a tool for shared writing and social publishing
0

Configure Feed

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

Feature/post block interactions (#311)

* just some quick things to make it look better

* adding things to make the comments more legible

* adjust styling to make the comments and bsky posts more uniform

* combine comments and bsky mentions in all places

* show discussion from postLink everywhere where there is a postLink

* tabbing replies and quotes in the bluesky thread viewer, fixing a ton of
styling

* open threads in the drawer rather than as new pages

* standard site posts open in interaction drawer on posts

* bottom sheet to open on mobile rather than a drawer

* little fixes

* Scope subpage comment counts and panel to the page

Comment counts on the post page all came from the document-wide
commentsCount aggregate, so every subpage block and the bottom comments
button showed the whole document's comment total. The subpage block's
comment icon also opened a standalone discussion modal.

- Compute a per-page comment count map (commentsCountByPage) in
getPostPageData from each comment's onPage; expose it via DocumentContext
- Subpage block comment button now opens the subpage and its interaction
panel scoped to comments, instead of the discussion modal
- Bottom comments button, post header, canvas page, and the drawer toggle
now show the count for their own page

* update cover images in medium size standard site post block to be
smaller

* more tweak

* default open comments/quotes if present

---------

Co-authored-by: Jared Pereira <jared@awarm.space>

authored by

celine
Jared Pereira
and committed by
GitHub
(Jun 1, 2026, 1:35 PM EDT) 2166f3a9 13a08ddd

+1941 -1102
app/(app)/(home-pages)/reader/PreviewDrawer.tsx

This is a binary file and will not be displayed.

-91
app/(app)/(home-pages)/reader/ReaderMentionsContent.tsx
··· 1 - "use client"; 2 - import useSWR from "swr"; 3 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 - import { BskyPostContent } from "app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent"; 5 - import { EmptyState } from "components/EmptyState"; 6 - import { DotLoader } from "components/utils/DotLoader"; 7 - 8 - async function fetchBskyPosts(uris: string[]): Promise<PostView[]> { 9 - const params = new URLSearchParams({ 10 - uris: JSON.stringify(uris), 11 - }); 12 - const response = await fetch(`/api/bsky/hydrate?${params.toString()}`); 13 - if (!response.ok) throw new Error("Failed to fetch Bluesky posts"); 14 - return response.json(); 15 - } 16 - 17 - export function ReaderMentionsContent(props: { 18 - quotesAndMentions: { uri: string; link?: string }[]; 19 - }) { 20 - const uris = props.quotesAndMentions.map((q) => q.uri); 21 - const key = 22 - uris.length > 0 23 - ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 24 - : null; 25 - 26 - const { data: bskyPosts, isLoading } = useSWR(key, () => 27 - fetchBskyPosts(uris), 28 - ); 29 - 30 - if (props.quotesAndMentions.length === 0) { 31 - return ( 32 - <EmptyState 33 - title="No mentions yet!" 34 - container="opaque" 35 - className="gap-0.5 p-[6px]!" 36 - /> 37 - ); 38 - } 39 - 40 - if (isLoading) { 41 - return ( 42 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 43 - <span>loading</span> 44 - <DotLoader /> 45 - </div> 46 - ); 47 - } 48 - 49 - const postViewMap = new Map<string, PostView>(); 50 - bskyPosts?.forEach((pv) => postViewMap.set(pv.uri, pv)); 51 - 52 - // Sort by engagement: likes count 1, replies and quotes count 1.5 53 - const sorted = [...props.quotesAndMentions].sort((a, b) => { 54 - const postA = postViewMap.get(a.uri); 55 - const postB = postViewMap.get(b.uri); 56 - const scoreA = 57 - (postA?.likeCount ?? 0) + 58 - (postA?.replyCount ?? 0) * 1.5 + 59 - (postA?.quoteCount ?? 0) * 1.5; 60 - const scoreB = 61 - (postB?.likeCount ?? 0) + 62 - (postB?.replyCount ?? 0) * 1.5 + 63 - (postB?.quoteCount ?? 0) * 1.5; 64 - return scoreB - scoreA; 65 - }); 66 - 67 - return ( 68 - <div className="flex flex-col gap-4 w-full"> 69 - {sorted.map((q, index) => { 70 - const post = postViewMap.get(q.uri); 71 - if (!post) return null; 72 - return ( 73 - <div key={q.uri}> 74 - <BskyPostContent 75 - post={post} 76 - parent={undefined} 77 - showBlueskyLink={true} 78 - showEmbed={true} 79 - avatarSize="medium" 80 - className="text-sm" 81 - compactEmbed 82 - /> 83 - {index < sorted.length - 1 && ( 84 - <hr className="border-border-light mt-4" /> 85 - )} 86 - </div> 87 - ); 88 - })} 89 - </div> 90 - ); 91 - }
+43 -32
app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx
··· 37 37 const hasDate = date !== undefined && date !== null; 38 38 return ( 39 39 <div 40 - className={`justify-between w-full ${textClassName} text-tertiary flex gap-1 flex-wrap items-center`} 40 + className={`justify-between w-full ${textClassName} text-tertiary flex gap-1 flex-wrap items-center`} 41 41 > 42 - <p 43 - className={`${textClassName} text-tertiary flex gap-1 items-center flex-wrap`} 44 - > 42 + <p className={`text-tertiary flex gap-1 items-center flex-wrap`}> 45 43 {hasAuthor && ( 46 44 <span className="whitespace-nowrap"> 47 45 {author} ··· 102 100 const hasCoverImage = !!props.coverImageSrc; 103 101 104 102 return ( 105 - <div className="flex w-full items-stretch "> 106 - <div className="flex w-full grow flex-col justify-between min-w-0 pl-3 pr-3 py-2"> 107 - <PostLink href={props.href}> 108 - {props.title && ( 109 - <h3 className="text-primary leading-snug line-clamp-2 pb-1"> 110 - {props.title} 111 - </h3> 103 + <div className="flex w-full flex-col"> 104 + <div className="flex w-full items-stretch "> 105 + <div className="flex w-full grow flex-col justify-between min-w-0 pl-3 pr-3 py-2"> 106 + <PostLink href={props.href}> 107 + {props.title && ( 108 + <h3 className="text-primary leading-snug line-clamp-2 pb-1"> 109 + {props.title} 110 + </h3> 111 + )} 112 + <p className="text-secondary line-clamp-3 grow mb-2"> 113 + {props.description} 114 + </p> 115 + </PostLink> 116 + <MetaRow 117 + author={props.author} 118 + date={props.date} 119 + interactions={props.interactions} 120 + textClassName="text-sm place-self-end sm:block hidden" 121 + /> 122 + {props.footer && ( 123 + <div className="shrink-0 hidden sm:block">{props.footer}</div> 112 124 )} 113 - <p className="text-secondary line-clamp-3 grow mb-2"> 114 - {props.description} 115 - </p> 116 - </PostLink> 117 - <MetaRow 118 - author={props.author} 119 - date={props.date} 120 - interactions={props.interactions} 121 - textClassName="text-sm place-self-end" 122 - /> 123 - <div className="shrink-0">{props.footer}</div> 125 + </div> 126 + {hasCoverImage && ( 127 + <div 128 + className={`self-start shrink-0 w-24 h-24 rounded-md mt-2 mr-2 border-l sm:border-border-light border-transparent ${props.footer ? "sm:w-[182px] sm:h-[182px] sm:rounded-none sm:m-0" : "sm:h-36 sm:w-36 sm:rounded-none sm:m-0"}`} 129 + > 130 + <img 131 + src={props.coverImageSrc} 132 + alt={props.coverImageAlt || props.title || ""} 133 + className="w-full h-full aspect-square object-cover sm:rounded-[0px] rounded-md" 134 + /> 135 + </div> 136 + )} 124 137 </div> 125 - {hasCoverImage && ( 126 - <div 127 - className={`self-start shrink-0 w-16 border-l border-border-light ${props.footer ? " w-[182px] h-[182px]" : "sm:h-36 sm:w-36"}`} 128 - > 129 - <img 130 - src={props.coverImageSrc} 131 - alt={props.coverImageAlt || props.title || ""} 132 - className="w-full aspect-square object-cover rounded" 133 - /> 134 - </div> 138 + <MetaRow 139 + author={props.author} 140 + date={props.date} 141 + interactions={props.interactions} 142 + textClassName="text-sm place-self-end shrink-0 sm:hidden px-3 " 143 + /> 144 + {props.footer && ( 145 + <div className="shrink-0 sm:hidden px-3 pb-2">{props.footer}</div> 135 146 )} 136 147 </div> 137 148 );
+1
app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx
··· 103 103 documentUri={post.uri} 104 104 tags={tags} 105 105 postUrl={docUrl} 106 + title={doc_record.title} 106 107 showComments={ 107 108 publicationRecord?.preferences?.showComments !== false 108 109 }
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 33 33 avatarSize="large" 34 34 quoteEnabled 35 35 replyEnabled 36 - className="text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 36 + className="publishedBskyPostBlock text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 37 37 clientHost={props.clientHost} 38 38 /> 39 39 );
+27 -53
app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 8 8 import { 9 9 PubLeafletBlocksHeader, 10 10 PubLeafletBlocksText, 11 - PubLeafletComment, 12 11 PubLeafletPagesLinearDocument, 13 12 PubLeafletPagesCanvas, 14 13 PubLeafletPublication, ··· 18 17 import { TextBlock } from "./TextBlock"; 19 18 import { useDocument } from "contexts/DocumentContext"; 20 19 import { openPage, useOpenPages } from "../postPageState"; 21 - import { 22 - openInteractionDrawer, 23 - setInteractionState, 24 - useInteractionState, 25 - } from "../Interactions/Interactions"; 20 + import { openInteractionDrawer } from "../Interactions/Interactions"; 26 21 import { CommentTiny } from "components/Icons/CommentTiny"; 27 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 28 22 import { CanvasBackgroundPattern } from "components/Canvas"; 29 23 30 24 export function PublishedPageLinkBlock(props: { ··· 208 202 } 209 203 210 204 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 211 - const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); 205 + const { 206 + uri: document_uri, 207 + commentsCountByPage, 208 + mentions, 209 + } = useDocument(); 210 + let comments = commentsCountByPage[props.pageId] ?? 0; 212 211 let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; 213 212 214 - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 213 + if (quotes + comments === 0) return null; 215 214 216 215 return ( 217 216 <div 218 217 className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 219 218 > 220 - {quotes > 0 && ( 221 - <button 222 - className={`flex gap-1 items-center`} 223 - onClick={(e) => { 224 - e.preventDefault(); 225 - e.stopPropagation(); 226 - openPage( 227 - props.parentPageId 228 - ? { type: "doc", id: props.parentPageId } 229 - : undefined, 230 - { type: "doc", id: props.pageId }, 231 - { scrollIntoView: false }, 232 - ); 233 - if (!drawerOpen || drawer !== "quotes") 234 - openInteractionDrawer("quotes", document_uri, props.pageId); 235 - else setInteractionState(document_uri, { drawerOpen: false }); 236 - }} 237 - > 238 - <span className="sr-only">Page quotes</span> 239 - <QuoteTiny aria-hidden /> {quotes}{" "} 240 - </button> 241 - )} 242 - {comments > 0 && ( 243 - <button 244 - className={`flex gap-1 items-center`} 245 - onClick={(e) => { 246 - e.preventDefault(); 247 - e.stopPropagation(); 248 - openPage( 249 - props.parentPageId 250 - ? { type: "doc", id: props.parentPageId } 251 - : undefined, 252 - { type: "doc", id: props.pageId }, 253 - { scrollIntoView: false }, 254 - ); 255 - if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 256 - openInteractionDrawer("comments", document_uri, props.pageId); 257 - else setInteractionState(document_uri, { drawerOpen: false }); 258 - }} 259 - > 260 - <span className="sr-only">Page comments</span> 261 - <CommentTiny aria-hidden /> {comments}{" "} 262 - </button> 263 - )} 219 + <button 220 + className={`flex gap-1 items-center`} 221 + onClick={(e) => { 222 + e.preventDefault(); 223 + e.stopPropagation(); 224 + // Open the subpage itself, then open its interaction panel scoped to 225 + // comments — rather than popping a standalone discussion modal. 226 + openPage( 227 + props.parentPageId 228 + ? { type: "doc", id: props.parentPageId } 229 + : undefined, 230 + { type: "doc", id: props.pageId }, 231 + ); 232 + openInteractionDrawer("comments", document_uri, props.pageId); 233 + }} 234 + > 235 + <span className="sr-only">Page discussions</span> 236 + <CommentTiny aria-hidden /> {comments + quotes}{" "} 237 + </button> 264 238 </div> 265 239 ); 266 240 };
-104
app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 1 - "use client"; 2 - import { AppBskyFeedDefs } from "@atproto/api"; 3 - import useSWR from "swr"; 4 - import { PageWrapper } from "components/Pages/Page"; 5 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 - import { DotLoader } from "components/utils/DotLoader"; 7 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 - import { openPage } from "./postPageState"; 9 - import { BskyPostContent } from "./BskyPostContent"; 10 - import { 11 - QuotesLink, 12 - getQuotesKey, 13 - fetchQuotes, 14 - prefetchQuotes, 15 - } from "./PostLinks"; 16 - 17 - // Re-export for backwards compatibility 18 - export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; 19 - 20 - type PostView = AppBskyFeedDefs.PostView; 21 - 22 - export function BlueskyQuotesPage(props: { 23 - postUri: string; 24 - pageId: string; 25 - pageOptions?: React.ReactNode; 26 - hasPageBackground: boolean; 27 - }) { 28 - const { postUri, pageId, pageOptions } = props; 29 - const drawer = useDrawerOpen(postUri); 30 - 31 - const { 32 - data: quotesData, 33 - isLoading, 34 - error, 35 - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => 36 - fetchQuotes(postUri), 37 - ); 38 - 39 - return ( 40 - <PageWrapper 41 - pageType="doc" 42 - fullPageScroll={false} 43 - id={`post-page-${pageId}`} 44 - drawerOpen={false} 45 - pageOptions={pageOptions} 46 - fixedWidth 47 - > 48 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 49 - <h4 className="text-secondary font-bold mb-2">Bluesky Quotes</h4> 50 - {isLoading ? ( 51 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 52 - <span>loading quotes</span> 53 - <DotLoader /> 54 - </div> 55 - ) : error ? ( 56 - <div className="text-tertiary italic text-sm text-center py-8"> 57 - Failed to load quotes 58 - </div> 59 - ) : quotesData && quotesData.posts.length > 0 ? ( 60 - <QuotesContent posts={quotesData.posts} postUri={postUri} /> 61 - ) : ( 62 - <div className="text-tertiary italic text-sm text-center py-8"> 63 - No quotes yet 64 - </div> 65 - )} 66 - </div> 67 - </PageWrapper> 68 - ); 69 - } 70 - 71 - function QuotesContent(props: { posts: PostView[]; postUri: string }) { 72 - const { posts, postUri } = props; 73 - 74 - return ( 75 - <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 - ))} 84 - </div> 85 - ); 86 - } 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 - }
+137 -74
app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 8 8 import { Separator } from "components/Layout"; 9 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 - import { OpenPage, openPage } from "./postPageState"; 11 + import { OpenPage } from "./postPageState"; 12 + import { useOpenThread } from "./Interactions/drawerThreadContext"; 12 13 import { ThreadLink, QuotesLink } from "./PostLinks"; 13 14 import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 14 15 import { Avatar } from "components/Avatar"; 15 16 import { timeAgo } from "src/utils/timeAgo"; 16 17 import { ProfilePopover } from "components/ProfilePopover"; 18 + import { QuotePosition } from "./quotePosition"; 19 + import { QuoteContent } from "./Interactions/Quotes"; 17 20 18 21 type PostView = AppBskyFeedDefs.PostView; 19 22 ··· 25 28 showEmbed?: boolean; 26 29 compactEmbed?: boolean; 27 30 showBlueskyLink?: boolean; 31 + showInteractions?: boolean; 28 32 quoteEnabled?: boolean; 29 33 replyEnabled?: boolean; 30 34 replyOnClick?: (e: React.MouseEvent) => void; 31 35 clientHost?: string; 36 + hasQuote?: { 37 + position: QuotePosition; 38 + index: number; 39 + did: string; 40 + }; 32 41 }) { 33 42 const { 34 43 post, ··· 37 46 showEmbed = true, 38 47 compactEmbed = false, 39 48 showBlueskyLink = true, 49 + showInteractions = true, 40 50 quoteEnabled, 41 51 replyEnabled, 42 52 replyOnClick, 43 53 clientHost = "bsky.app", 54 + hasQuote, 44 55 } = props; 56 + const openThread = useOpenThread(); 45 57 46 58 const record = post.record as AppBskyFeedPost.Record; 47 59 const postId = post.uri.split("/")[4]; 48 60 const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 49 61 62 + // Only allow opening the thread page when there's a discussion to show 63 + const hasThreadContent = 64 + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; 65 + 50 66 return ( 51 67 <div className={`bskyPost relative flex flex-col w-full `}> 52 - <button 53 - className="absolute inset-0" 54 - onClick={() => { 55 - openPage(parent, { type: "thread", uri: post.uri }); 56 - }} 57 - /> 68 + {hasThreadContent && ( 69 + <button 70 + className="absolute inset-0" 71 + onClick={() => { 72 + openThread(parent, { type: "thread", uri: post.uri }); 73 + }} 74 + /> 75 + )} 76 + {/*{props.parent?.type === "thread" && props.parent.uri && ( 77 + <div className="text-xs flex gap-2 px-1 text-tertiary"> 78 + <div className="flex flex-col shrink-0"> 79 + <div className="h-4 w-4 mx-0.5 bg-test rounded-full shrink-0" /> 80 + <div className="w-0.5 h-3 bg-border mx-auto" /> 81 + </div> 82 + <strong> Replying to Eileen </strong> 83 + <span className="font-normal text-tertiary"> 84 + This is a one-liner of content 85 + </span> 86 + </div> 87 + )}*/} 58 88 59 89 <div 60 90 className={`flex gap-2 text-left w-full pointer-events-none ${props.className}`} ··· 62 92 <div className="flex flex-col items-start shrink-0 w-fit pointer-events-auto"> 63 93 <Avatar 64 94 src={post.author.avatar} 65 - displayName={post.author.displayName} 95 + displayName={ 96 + post.author.displayName 97 + ? post.author.displayName 98 + : post.author.handle 99 + } 66 100 size={avatarSize ? avatarSize : "medium"} 67 101 /> 68 102 </div> 69 - <div className={`flex flex-col min-w-0 w-full mb-2`}> 70 - <div 71 - className={`bskyPostTextContent flex flex-col grow text-left w-full ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 72 - > 73 - <PostInfo 74 - displayName={post.author.displayName} 75 - handle={post.author.handle} 76 - createdAt={record.createdAt} 77 - /> 103 + 104 + <div 105 + 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"}`} 106 + > 107 + <PostInfo 108 + displayName={post.author.displayName} 109 + handle={post.author.handle} 110 + createdAt={record.createdAt} 111 + /> 78 112 79 - <div className={`postContent flex flex-col gap-2 mt-0.5`}> 80 - <div className="text-secondary"> 81 - <BlueskyRichText record={record} /> 113 + <div className={`bskyPostBody flex flex-col min-w-0 w-full`}> 114 + {props.hasQuote && ( 115 + <QuoteContent 116 + index={props.hasQuote?.index} 117 + did={props.hasQuote?.did} 118 + position={props.hasQuote?.position} 119 + /> 120 + )} 121 + <div className="bskyPostTextContent text-secondary mt-0.5"> 122 + <BlueskyRichText record={record} /> 123 + </div> 124 + {showEmbed && post.embed && ( 125 + <div 126 + className="bskyPostEmbedWrapper pointer-events-auto relative mt-2" 127 + onClick={(e) => e.stopPropagation()} 128 + > 129 + <BlueskyEmbed 130 + parent={parent} 131 + embed={post.embed} 132 + compact={compactEmbed} 133 + postUrl={url} 134 + className="text-sm" 135 + /> 82 136 </div> 83 - {showEmbed && post.embed && ( 84 - <div 85 - className="pointer-events-auto relative" 86 - onClick={(e) => e.stopPropagation()} 87 - > 88 - <BlueskyEmbed 89 - parent={parent} 90 - embed={post.embed} 91 - compact={compactEmbed} 92 - postUrl={url} 93 - className="text-sm" 94 - /> 95 - </div> 96 - )} 97 - </div> 137 + )} 98 138 </div> 99 139 {props.showBlueskyLink || 100 - (props.post.quoteCount && props.post.quoteCount > 0) || 101 - (props.post.replyCount && props.post.replyCount > 0) ? ( 140 + (showInteractions && 141 + ((props.post.quoteCount && props.post.quoteCount > 0) || 142 + (props.post.replyCount && props.post.replyCount > 0))) ? ( 102 143 <div 103 - className={`postCountsAndLink flex gap-2 items-center justify-between mt-2 pointer-events-auto`} 144 + className={`postCountsAndLink flex gap-2 items-center justify-between pointer-events-auto mt-2`} 104 145 > 105 - <PostCounts 106 - post={post} 107 - parent={parent} 108 - replyEnabled={replyEnabled} 109 - replyOnClick={replyOnClick} 110 - quoteEnabled={quoteEnabled} 111 - showBlueskyLink={showBlueskyLink} 112 - url={url} 113 - /> 146 + {showInteractions ? ( 147 + <PostCounts 148 + post={post} 149 + parent={parent} 150 + replyEnabled={replyEnabled} 151 + replyOnClick={replyOnClick} 152 + quoteEnabled={quoteEnabled} 153 + showBlueskyLink={showBlueskyLink} 154 + url={url} 155 + /> 156 + ) : ( 157 + <div /> 158 + )} 114 159 115 160 <div className="flex gap-3 items-center"> 116 161 {showBlueskyLink && ( ··· 142 187 replyOnClick?: (e: React.MouseEvent) => void; 143 188 clientHost?: string; 144 189 }) { 145 - const { post, parent, quoteEnabled, replyEnabled, replyOnClick, clientHost = "bsky.app" } = props; 190 + const { 191 + post, 192 + parent, 193 + quoteEnabled, 194 + replyEnabled, 195 + replyOnClick, 196 + clientHost = "bsky.app", 197 + } = props; 198 + const openThread = useOpenThread(); 146 199 147 200 const record = post.record as AppBskyFeedPost.Record; 148 201 const postId = post.uri.split("/")[4]; 149 202 const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 150 203 204 + // Only allow opening the thread page when there's a discussion to show 205 + const hasThreadContent = 206 + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; 207 + 151 208 return ( 152 209 <div className="bskyPost relative flex flex-col w-full"> 153 - <button 154 - className="absolute inset-0 " 155 - onClick={() => { 156 - openPage(parent, { type: "thread", uri: post.uri }); 157 - }} 158 - /> 210 + {hasThreadContent && ( 211 + <button 212 + className="absolute inset-0 " 213 + onClick={() => { 214 + openThread(parent, { type: "thread", uri: post.uri }); 215 + }} 216 + /> 217 + )} 159 218 <div className={`flex gap-2 text-left w-full ${props.className}`}> 160 219 <Avatar 161 220 src={post.author.avatar} ··· 164 223 /> 165 224 <div className={`flex flex-col min-w-0 w-full`}> 166 225 <button 167 - className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 168 - onClick={() => { 169 - openPage(parent, { type: "thread", uri: post.uri }); 170 - }} 226 + className={`bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary ${hasThreadContent ? "" : "cursor-default"}`} 227 + onClick={ 228 + hasThreadContent 229 + ? () => { 230 + openThread(parent, { type: "thread", uri: post.uri }); 231 + } 232 + : undefined 233 + } 171 234 > 172 235 <PostInfo 173 236 displayName={post.author.displayName} ··· 202 265 ); 203 266 } 204 267 205 - function PostInfo(props: { 206 - displayName?: string; 268 + export function PostInfo(props: { 269 + displayName?: string | null; 207 270 handle: string; 208 271 createdAt: string; 209 272 compact?: boolean; ··· 212 275 213 276 return ( 214 277 <div className="postInfo flex items-center gap-2 leading-tight w-full"> 215 - <div className="flex gap-2 items-center min-w-0"> 216 - <div className={`font-bold text-secondary truncate`}> 217 - {displayName} 218 - </div> 219 - <div className="truncate items-end flex pointer-events-auto"> 220 - <ProfilePopover 221 - trigger={ 278 + <ProfilePopover 279 + trigger={ 280 + <div className="flex gap-2 items-baseline min-w-0 w-full"> 281 + <div className={`font-bold text-secondary truncate min-w-0`}> 282 + {displayName ? displayName : handle} 283 + </div> 284 + {displayName && ( 222 285 <div 223 - className={`${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline w-full truncate `} 286 + className={`truncate min-w-0 shrink pointer-events-auto ${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline`} 224 287 > 225 288 @{handle} 226 289 </div> 227 - } 228 - didOrHandle={handle} 229 - /> 230 - </div> 231 - </div> 290 + )} 291 + </div> 292 + } 293 + didOrHandle={handle} 294 + /> 232 295 <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 233 296 <div 234 297 className={`${compact ? "text-xs" : "text-sm"} text-tertiary shrink-0`} ··· 265 328 ); 266 329 267 330 return ( 268 - <div className="postCounts flex gap-2 items-center w-full text-tertiary"> 331 + <div className="postCounts flex gap-2 items-center w-full text-tertiary mb-1"> 269 332 {replyContent && 270 333 (props.replyEnabled ? ( 271 334 <ThreadLink
+15 -12
app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 15 15 import { Popover } from "components/Popover"; 16 16 import { InfoSmall } from "components/Icons/InfoSmall"; 17 17 import { PostHeader } from "./PostHeader/PostHeader"; 18 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 18 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 19 + import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; 19 20 import { PollData } from "./fetchPollData"; 20 21 import { SharedPageProps } from "./PostPages"; 21 22 import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; ··· 67 68 data={document} 68 69 profile={profile} 69 70 preferences={preferences} 70 - commentsCount={document.commentsCount} 71 + commentsCount={document.commentsCountByPage[pageId ?? ""] ?? 0} 71 72 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 72 73 recommendsCount={document.recommendsCount} 73 74 /> 74 - <CanvasContent 75 - blocks={blocks} 76 - did={did} 77 - prerenderedCodeBlocks={prerenderedCodeBlocks} 78 - bskyPostData={bskyPostData} 79 - standardSitePostData={standardSitePostData} 80 - pollData={pollData} 81 - pageId={pageId} 82 - pages={pages} 83 - /> 75 + <DrawerThreadPageProvider document_uri={document_uri} pageId={pageId}> 76 + <CanvasContent 77 + blocks={blocks} 78 + did={did} 79 + prerenderedCodeBlocks={prerenderedCodeBlocks} 80 + bskyPostData={bskyPostData} 81 + standardSitePostData={standardSitePostData} 82 + pollData={pollData} 83 + pageId={pageId} 84 + pages={pages} 85 + /> 86 + </DrawerThreadPageProvider> 84 87 </PageWrapper> 85 88 ); 86 89 }
+7 -4
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 219 219 uri: result.uri, 220 220 profile: { 221 221 did: new AtUri(result.uri).host, 222 - handle: null, 222 + handle: record?.handle ?? null, 223 223 displayName: 224 + record?.displayName ?? 224 225 (result.profile as { displayName?: string } | null) 225 - ?.displayName ?? null, 226 - avatar: null, 227 - description: null, 226 + ?.displayName ?? 227 + null, 228 + avatar: record?.avatar ?? null, 229 + description: record?.description ?? null, 228 230 }, 229 231 }, 230 232 ], ··· 418 420 <div className="w-full relative group"> 419 421 <pre 420 422 ref={mountRef} 423 + style={{ fontFamily: "inherit" }} 421 424 onFocus={() => { 422 425 // Close mention dropdown when editor gains focus (reset stale state) 423 426 handleMentionOpenChange(false);
+168 -150
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 6 6 import { PubLeafletComment } from "lexicons/api"; 7 7 import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; 8 8 import { useMemo, useState } from "react"; 9 + import { CollapsibleReplies } from "components/CollapsibleReplies"; 9 10 import { CommentTiny } from "components/Icons/CommentTiny"; 10 11 import { Separator } from "components/Layout"; 11 12 import { Popover } from "components/Popover"; ··· 17 18 import { ProfilePopover } from "components/ProfilePopover"; 18 19 import { LoginModal } from "components/LoginButton"; 19 20 import { type Profile } from "src/identity"; 21 + import { PostInfo } from "../../BskyPostContent"; 22 + import { Avatar } from "components/Avatar"; 23 + import { EmptyState } from "components/EmptyState"; 20 24 21 25 export type Comment = { 22 26 record: Json; ··· 27 31 document_uri: string; 28 32 comments: Comment[]; 29 33 noCommentBox?: boolean; 34 + pageId?: string; 30 35 }) { 31 36 let { identity } = useIdentityData(); 32 - let { localComments, pageId } = useInteractionState(props.document_uri); 37 + let { localComments, pageId: statePageId } = useInteractionState( 38 + props.document_uri, 39 + ); 40 + // Callers (e.g. the discussion modal) can pin the page explicitly; otherwise 41 + // fall back to the page tracked in the shared interaction state. 42 + let pageId = props.pageId ?? statePageId; 33 43 let comments = useMemo(() => { 34 44 let filtered = props.comments.filter( 35 45 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 36 46 ); 37 47 return [ 38 - ...localComments.filter( 39 - (c) => (c.record as any)?.onPage === pageId, 40 - ), 48 + ...localComments.filter((c) => (c.record as any)?.onPage === pageId), 41 49 ...filtered, 42 50 ]; 43 51 }, [props.comments, localComments, pageId]); ··· 76 84 <hr className="border-border-light" /> 77 85 </> 78 86 )} 79 - <div className="flex flex-col gap-4 py-2"> 80 - {comments 81 - .sort((a, b) => { 82 - let aRecord = a.record as PubLeafletComment.Record; 83 - let bRecord = b.record as PubLeafletComment.Record; 84 - return ( 85 - new Date(bRecord.createdAt).getTime() - 86 - new Date(aRecord.createdAt).getTime() 87 - ); 88 - }) 89 - .filter( 90 - (comment) => !(comment.record as PubLeafletComment.Record).reply, 91 - ) 92 - .map((comment) => { 93 - let record = comment.record as PubLeafletComment.Record; 94 - return ( 95 - <Comment 96 - pageId={pageId} 97 - profile={comment.profile} 98 - document={props.document_uri} 99 - comment={comment} 100 - record={record} 101 - comments={comments} 102 - key={comment.uri} 103 - /> 104 - ); 105 - })} 87 + <div className="comments flex flex-col gap-4 sm:gap-6 py-2"> 88 + {comments.length === 0 && <EmptyState>No comments yet…</EmptyState>} 89 + {comments.length > 0 && 90 + comments 91 + .sort((a, b) => { 92 + let aRecord = a.record as PubLeafletComment.Record; 93 + let bRecord = b.record as PubLeafletComment.Record; 94 + return ( 95 + new Date(bRecord.createdAt).getTime() - 96 + new Date(aRecord.createdAt).getTime() 97 + ); 98 + }) 99 + .filter( 100 + (comment) => !(comment.record as PubLeafletComment.Record).reply, 101 + ) 102 + .map((comment) => { 103 + let record = comment.record as PubLeafletComment.Record; 104 + return ( 105 + <> 106 + <Comment 107 + pageId={pageId} 108 + profile={comment.profile} 109 + document={props.document_uri} 110 + comment={comment} 111 + record={record} 112 + comments={comments} 113 + key={comment.uri} 114 + /> 115 + <hr className="border-border last:hidden" /> 116 + </> 117 + ); 118 + })} 106 119 </div> 107 120 </div> 108 121 ); ··· 118 131 }) => { 119 132 const did = props.profile?.did; 120 133 121 - let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 122 - 123 134 return ( 124 - <div id={props.comment.uri} className="comment"> 125 - <div className="flex gap-2 items-center"> 126 - {did ? ( 127 - <ProfilePopover 128 - didOrHandle={did} 129 - trigger={ 130 - <div className="text-sm text-secondary font-bold hover:underline"> 131 - {props.profile?.displayName} 132 - </div> 133 - } 134 - /> 135 - ) : null} 135 + <div 136 + id={props.comment.uri} 137 + className="comment flex gap-2 pointer-events-auto" 138 + > 139 + <Avatar 140 + src={props.profile?.avatar || undefined} 141 + displayName={ 142 + props.profile?.displayName 143 + ? props.profile?.displayName 144 + : props.profile?.handle || undefined 145 + } 146 + size={"medium"} 147 + /> 136 148 137 - <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 138 - <div className="text-sm text-tertiary">{timeAgoDate}</div> 139 - </div> 140 - {props.record.attachment && 141 - PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 142 - <div className="mt-1 mb-2"> 143 - <QuoteContent 144 - index={-1} 145 - position={props.record.attachment.quote} 146 - did={new AtUri(props.record.attachment.document).host} 147 - /> 148 - </div> 149 - )} 150 - <pre 151 - key={props.comment.uri} 152 - style={{ wordBreak: "break-word" }} 153 - className="whitespace-pre-wrap text-secondary pb-[4px] " 154 - > 155 - <BaseTextBlock 156 - index={[]} 157 - plaintext={props.record.plaintext} 158 - facets={props.record.facets} 149 + <div className="min-w-0 w-full grow flex flex-col pt-1"> 150 + <PostInfo 151 + displayName={props.profile?.displayName} 152 + handle={props.profile?.handle || ""} 153 + createdAt={props.record.createdAt} 154 + compact 159 155 /> 160 - </pre> 161 - <Replies 162 - pageId={props.pageId} 163 - comment_uri={props.comment.uri} 164 - comments={props.comments} 165 - document={props.document} 166 - /> 156 + 157 + {props.record.attachment && 158 + PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 159 + <div className="my-2 "> 160 + <QuoteContent 161 + index={-1} 162 + position={props.record.attachment.quote} 163 + did={new AtUri(props.record.attachment.document).host} 164 + /> 165 + </div> 166 + )} 167 + <pre 168 + key={props.comment.uri} 169 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 170 + className="whitespace-pre-wrap text-secondary pb-[4px] " 171 + > 172 + <BaseTextBlock 173 + index={[]} 174 + plaintext={props.record.plaintext} 175 + facets={props.record.facets} 176 + /> 177 + </pre> 178 + <Replies 179 + pageId={props.pageId} 180 + comment_uri={props.comment.uri} 181 + comments={props.comments} 182 + document={props.document} 183 + /> 184 + </div> 167 185 </div> 168 186 ); 169 187 }; ··· 194 212 ); 195 213 }); 196 214 197 - let repliesOrReplyBoxOpen = 198 - replyBoxOpen || (repliesOpen && replies.length > 0); 199 215 return ( 200 216 <> 201 217 <div className="flex gap-2 items-center"> 202 - <button 203 - className="flex gap-1 items-center text-sm text-tertiary" 204 - onClick={() => { 205 - setRepliesOpen(!repliesOpen); 206 - setReplyBoxOpen(false); 207 - }} 208 - > 209 - <CommentTiny className="text-border" />{" "} 210 - {replies.length !== 0 && replies.length} 211 - </button> 218 + {(replies.length !== 0 || identity?.atp_did) && ( 219 + <button 220 + className="flex gap-1 items-center text-sm text-tertiary" 221 + onClick={() => { 222 + setRepliesOpen(!repliesOpen); 223 + setReplyBoxOpen(false); 224 + }} 225 + > 226 + <CommentTiny className="text-border" />{" "} 227 + {replies.length !== 0 && replies.length} 228 + </button> 229 + )} 212 230 {identity?.atp_did && ( 213 - <> 214 - <Separator classname="h-[14px]" /> 231 + <button 232 + className="text-accent-contrast text-sm" 233 + onClick={() => { 234 + setRepliesOpen(true); 235 + setReplyBoxOpen(true); 236 + }} 237 + > 238 + Reply 239 + </button> 240 + )} 241 + </div> 242 + {replyBoxOpen && ( 243 + <div className="repliesWrapper flex w-full pt-1"> 244 + <button 245 + className="repliesCollapse mr-[14px] ml-[7px]" 246 + onClick={() => { 247 + setReplyBoxOpen(false); 248 + setRepliesOpen(false); 249 + }} 250 + > 251 + <div className="bg-border-light w-[2px] h-full" /> 252 + </button> 253 + <CommentBox 254 + className="pt-3" 255 + pageId={props.pageId} 256 + doc_uri={props.document} 257 + replyTo={props.comment_uri} 258 + autoFocus={true} 259 + onSubmit={() => { 260 + setReplyBoxOpen(false); 261 + }} 262 + /> 263 + </div> 264 + )} 265 + {replies.length > 0 && ( 266 + <CollapsibleReplies open={repliesOpen}> 267 + <div className="repliesWrapper flex pt-1 relative"> 268 + {/* the thread line itself is non-interactive; a transparent button 269 + is overlaid on top of it (z-10) to catch clicks, so the line 270 + stays clickable even though the comments re-enable pointer 271 + events with pointer-events-auto */} 272 + <div className="-mr-[14px] ml-[7px] pointer-events-none"> 273 + <div className="bg-border-light w-[2px] h-full" /> 274 + </div> 215 275 <button 216 - className="text-accent-contrast text-sm" 276 + className="repliesCollapse absolute top-0 bottom-0 left-0 w-[20px] z-10" 217 277 onClick={() => { 218 - setRepliesOpen(true); 219 - setReplyBoxOpen(true); 278 + setReplyBoxOpen(false); 279 + setRepliesOpen(false); 220 280 }} 221 - > 222 - Reply 223 - </button> 224 - </> 225 - )} 226 - </div> 227 - {repliesOrReplyBoxOpen && ( 228 - <div className="flex flex-col pt-1"> 229 - {replyBoxOpen && ( 230 - <div className="repliesWrapper flex w-full"> 231 - <button 232 - className="repliesCollapse pr-[14px] ml-[7px]" 233 - onClick={() => { 234 - setReplyBoxOpen(false); 235 - setRepliesOpen(false); 236 - }} 237 - > 238 - <div className="bg-border-light w-[2px] h-full" /> 239 - </button> 240 - <CommentBox 241 - className="pt-3" 242 - pageId={props.pageId} 243 - doc_uri={props.document} 244 - replyTo={props.comment_uri} 245 - autoFocus={true} 246 - onSubmit={() => { 247 - setReplyBoxOpen(false); 248 - }} 249 - /> 281 + /> 282 + <div className="repliesContent flex flex-col gap-8 pt-4 w-full"> 283 + {replies.map((reply) => { 284 + return ( 285 + <Comment 286 + pageId={props.pageId} 287 + document={props.document} 288 + key={reply.uri} 289 + comment={reply} 290 + profile={reply.profile} 291 + record={reply.record as PubLeafletComment.Record} 292 + comments={props.comments} 293 + /> 294 + ); 295 + })} 250 296 </div> 251 - )} 252 - {repliesOpen && replies.length > 0 && ( 253 - <div className="repliesWrapper flex"> 254 - <button 255 - className="repliesCollapse pr-[14px] ml-[7px]" 256 - onClick={() => { 257 - setReplyBoxOpen(false); 258 - setRepliesOpen(false); 259 - }} 260 - > 261 - <div className="bg-border-light w-[2px] h-full" /> 262 - </button> 263 - <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 264 - {replies.map((reply) => { 265 - return ( 266 - <Comment 267 - pageId={props.pageId} 268 - document={props.document} 269 - key={reply.uri} 270 - comment={reply} 271 - profile={reply.profile} 272 - record={reply.record as PubLeafletComment.Record} 273 - comments={props.comments} 274 - /> 275 - ); 276 - })} 277 - </div> 278 - </div> 279 - )} 280 - </div> 297 + </div> 298 + </CollapsibleReplies> 281 299 )} 282 300 </> 283 301 );
+228 -50
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { MentionsDrawerContent } from "./Quotes"; 3 3 import { 4 - InteractionState, 5 4 setInteractionState, 6 5 useInteractionState, 6 + pushDrawerThread, 7 + popDrawerThread, 8 + popDrawerThreadToRoot, 7 9 } from "./Interactions"; 8 - import { useSearchParams } from "next/navigation"; 9 10 import { SandwichSpacer } from "components/LeafletLayout"; 10 11 import { decodeQuotePosition } from "../quotePosition"; 11 12 import { CloseTiny } from "components/Icons/CloseTiny"; 13 + import { GoBackTiny } from "components/Icons/GoBackTiny"; 14 + import { DoubleArrowRightTiny } from "components/Icons/DoubleArrowRightTiny"; 15 + import { ToggleGroup } from "components/ToggleGroup"; 16 + import { useDocument } from "contexts/DocumentContext"; 17 + import { useEffect, useMemo, useRef, useState } from "react"; 18 + import { DrawerThread, DrawerThreadContext } from "./drawerThreadContext"; 19 + import { useDrawerOpen } from "./useDrawerOpen"; 20 + import { ThreadView } from "../ThreadPage"; 21 + import { StandardSitePostDrawerView } from "./StandardSitePostDrawerView"; 22 + import { useDocumentDiscussionData } from "./useDocumentDiscussionData"; 23 + import { useIsMobile } from "src/hooks/isMobile"; 24 + import { MobileSheet } from "components/MobileSheet"; 12 25 13 26 export const InteractionDrawer = (props: { 14 27 showPageBackground: boolean | undefined; ··· 18 31 did: string; 19 32 pageId?: string; 20 33 }) => { 34 + // Reset the drawer's scroll to the top whenever we navigate between views, so 35 + // a pushed thread (or a back navigation) doesn't start scrolled partway down. 36 + const scrollRef = useRef<HTMLDivElement>(null); 37 + let { threadStack } = useInteractionState(props.document_uri); 38 + 39 + useEffect(() => { 40 + scrollRef.current?.scrollTo({ top: 0 }); 41 + }, [threadStack.length]); 42 + 43 + let isMobile = useIsMobile(); 44 + 45 + // On mobile the drawer slides up from the bottom as a sheet instead of sitting 46 + // inline in the horizontal page sandwich. The content renders its own header 47 + // and close button, so the sheet supplies no title/chrome of its own. This 48 + // component only mounts while the drawer is open (gated in PostPages), so the 49 + // sheet is always open; closing it clears the drawer state. 50 + if (isMobile) { 51 + return ( 52 + <MobileSheet 53 + open 54 + onOpenChange={(open) => { 55 + if (!open) 56 + setInteractionState(props.document_uri, { drawerOpen: false }); 57 + }} 58 + id="interaction-drawer" 59 + contentRef={scrollRef} 60 + > 61 + <InteractionDrawerContent {...props} /> 62 + </MobileSheet> 63 + ); 64 + } 65 + 66 + return ( 67 + <> 68 + <SandwichSpacer noWidth /> 69 + <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 70 + <div 71 + ref={scrollRef} 72 + id="interaction-drawer" 73 + className={`relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "light-container rounded-l-none! rounded-r-lg! -ml-[1px]" : " opaque-container rounded-lg! sm:ml-4"}`} 74 + > 75 + <InteractionDrawerContent {...props} /> 76 + </div> 77 + </div> 78 + </> 79 + ); 80 + }; 81 + 82 + const InteractionDrawerContent = (props: { 83 + showPageBackground: boolean | undefined; 84 + document_uri: string; 85 + quotesAndMentions: { uri: string; link?: string }[]; 86 + commentsSlot: React.ReactNode; 87 + did: string; 88 + pageId?: string; 89 + }) => { 21 90 let drawer = useDrawerOpen(props.document_uri); 22 91 if (!drawer) return null; 23 92 93 + let { commentsCountByPage } = useDocument(); 94 + let commentsCount = commentsCountByPage[props.pageId ?? ""] ?? 0; 95 + let { threadStack } = useInteractionState(props.document_uri); 96 + const drawerNav = useMemo( 97 + () => ({ 98 + push: (thread: DrawerThread) => 99 + pushDrawerThread(props.document_uri, thread), 100 + }), 101 + [props.document_uri], 102 + ); 103 + 104 + // The innermost thread/quotes view opened within the drawer, if any. When 105 + // present it replaces the comments/mentions tabs. 106 + const activeThread = threadStack[threadStack.length - 1]; 107 + 108 + // A standard-site-post thread shows another post's own discussion. It's always 109 + // at the root of the stack (Bluesky threads opened from its mentions become 110 + // the active thread instead), so its comments/mentions toggle lives in the 111 + // drawer header in place of a Back button. Its data is fetched here too (SWR 112 + // dedupes with the view below) to drive that toggle. 113 + const sspUri = 114 + activeThread?.type === "standardSitePost" ? activeThread.uri : null; 115 + const ssp = useDocumentDiscussionData(sspUri ?? "", !!sspUri); 116 + const [sspTab, setSspTab] = useState<"comments" | "quotes">("comments"); 117 + useEffect(() => { 118 + setSspTab("comments"); 119 + }, [sspUri]); 120 + 121 + const sspCommentsAvailable = ssp.showComments && ssp.comments.length > 0; 122 + const sspMentionsAvailable = 123 + ssp.showMentions && ssp.quotesAndMentions.length > 0; 124 + const sspBothAvailable = sspCommentsAvailable && sspMentionsAvailable; 125 + let sspActiveTab: "comments" | "quotes" = sspTab; 126 + if (sspActiveTab === "comments" && !sspCommentsAvailable) 127 + sspActiveTab = "quotes"; 128 + if (sspActiveTab === "quotes" && !sspMentionsAvailable) 129 + sspActiveTab = "comments"; 130 + 24 131 const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { 25 132 if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page 26 133 const url = new URL(q.link); ··· 30 137 return quotePosition?.pageId === props.pageId; 31 138 }); 32 139 140 + // commentsSlot is null when comments are disabled by permissions; mentions 141 + // are only available when there's something to show on this page. 142 + const commentsAvailable = props.commentsSlot != null; 143 + const mentionsAvailable = filteredQuotesAndMentions.length > 0; 144 + const bothAvailable = commentsAvailable && mentionsAvailable; 145 + 146 + // Resolve the active tab, falling back to whichever option is available. 147 + let activeTab: "comments" | "quotes" = 148 + drawer.drawer === "quotes" ? "quotes" : "comments"; 149 + if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; 150 + if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; 33 151 return ( 34 152 <> 35 - <SandwichSpacer noWidth /> 36 - <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 37 - <div 38 - id="interaction-drawer" 39 - className={`opaque-container relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 40 - > 41 - {drawer.drawer === "quotes" ? ( 42 - <> 43 - <button 44 - className="text-tertiary absolute top-4 right-4" 45 - onClick={() => 46 - setInteractionState(props.document_uri, { drawerOpen: false }) 47 - } 48 - > 49 - <CloseTiny /> 50 - </button> 51 - <MentionsDrawerContent 52 - did={props.did} 53 - quotesAndMentions={filteredQuotesAndMentions} 153 + <div className="w-full flex items-center gap-2 mb-3"> 154 + <div className="flex-1 min-w-0"> 155 + {sspUri ? ( 156 + sspBothAvailable ? ( 157 + <ToggleGroup 158 + fullWidth 159 + value={sspActiveTab} 160 + onChange={(value, e) => { 161 + e?.preventDefault(); 162 + setSspTab(value); 163 + }} 164 + options={[ 165 + { 166 + value: "comments", 167 + label: 168 + ssp.comments.length > 0 169 + ? `Comments (${ssp.comments.length})` 170 + : "Comments", 171 + }, 172 + { 173 + value: "quotes", 174 + label: ( 175 + <div> 176 + Bluesky{" "} 177 + <span className="hidden sm:inline">Mentions</span>{" "} 178 + {ssp.quotesAndMentions.length > 0 && 179 + `(${ssp.quotesAndMentions.length})`} 180 + </div> 181 + ), 182 + }, 183 + ]} 54 184 /> 55 - </> 56 - ) : ( 57 - <> 58 - <div className="w-full flex justify-between"> 59 - <h4> Comments</h4> 185 + ) : ( 186 + <h4> 187 + {sspActiveTab === "quotes" 188 + ? `Bluesky Mentions${ssp.quotesAndMentions.length > 0 ? ` (${ssp.quotesAndMentions.length})` : ""}` 189 + : `Comments${ssp.comments.length > 0 ? ` (${ssp.comments.length})` : ""}`} 190 + </h4> 191 + ) 192 + ) : activeThread ? ( 193 + <div className="flex items-center gap-2"> 194 + {threadStack.length >= 2 && ( 60 195 <button 61 - className="text-tertiary" 62 - onClick={() => 63 - setInteractionState(props.document_uri, { 64 - drawerOpen: false, 65 - }) 66 - } 196 + className="text-tertiary hover:text-secondary shrink-0" 197 + aria-label="Back to the top of the thread" 198 + onClick={() => popDrawerThreadToRoot(props.document_uri)} 67 199 > 68 - <CloseTiny /> 200 + <DoubleArrowRightTiny className="rotate-180" /> 69 201 </button> 70 - </div> 71 - {props.commentsSlot} 72 - </> 202 + )} 203 + <button 204 + className="flex items-center gap-1 text-tertiary hover:text-secondary font-bold text-sm" 205 + onClick={() => popDrawerThread(props.document_uri)} 206 + > 207 + <GoBackTiny /> Back 208 + </button> 209 + </div> 210 + ) : bothAvailable ? ( 211 + <ToggleGroup 212 + fullWidth 213 + value={activeTab} 214 + onChange={(value, e) => { 215 + e?.preventDefault(); 216 + setInteractionState(props.document_uri, { drawer: value }); 217 + }} 218 + options={[ 219 + { 220 + value: "comments", 221 + label: 222 + commentsCount > 0 223 + ? `Comments (${commentsCount})` 224 + : "Comments", 225 + }, 226 + { 227 + value: "quotes", 228 + label: ( 229 + <div> 230 + Bluesky <span className="hidden sm:inline">Mentions</span>{" "} 231 + {filteredQuotesAndMentions.length > 0 && 232 + `(${filteredQuotesAndMentions.length})`} 233 + </div> 234 + ), 235 + }, 236 + ]} 237 + /> 238 + ) : ( 239 + <h4>{activeTab === "quotes" ? "Bluesky Mentions" : "Comments"}</h4> 73 240 )} 74 241 </div> 242 + <button 243 + className="text-tertiary shrink-0" 244 + onClick={() => 245 + setInteractionState(props.document_uri, { drawerOpen: false }) 246 + } 247 + > 248 + <CloseTiny /> 249 + </button> 75 250 </div> 251 + <DrawerThreadContext.Provider value={drawerNav}> 252 + {sspUri ? ( 253 + <StandardSitePostDrawerView uri={sspUri} tab={sspActiveTab} /> 254 + ) : activeThread ? ( 255 + <ThreadView 256 + parentUri={activeThread.uri} 257 + initialTab={activeThread.type === "quotes" ? "quotes" : "replies"} 258 + /> 259 + ) : activeTab === "quotes" ? ( 260 + <MentionsDrawerContent 261 + did={props.did} 262 + quotesAndMentions={filteredQuotesAndMentions} 263 + /> 264 + ) : ( 265 + props.commentsSlot 266 + )} 267 + </DrawerThreadContext.Provider> 76 268 </> 77 269 ); 78 270 }; 79 - 80 - export const useDrawerOpen = (uri: string) => { 81 - let params = useSearchParams(); 82 - let interactionDrawerSearchParam = params.get("interactionDrawer"); 83 - let pageParam = params.get("page"); 84 - let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 85 - if (open === false || (open === undefined && !interactionDrawerSearchParam)) 86 - return null; 87 - drawer = 88 - drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 89 - // Use pageId from state, or fall back to page search param 90 - const resolvedPageId = pageId ?? pageParam ?? undefined; 91 - return { drawer, pageId: resolvedPageId }; 92 - };
+108 -60
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 1 1 "use client"; 2 2 import { CommentTiny } from "components/Icons/CommentTiny"; 3 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 4 3 import { flushSync } from "react-dom"; 5 4 import type { Json } from "supabase/database.types"; 6 5 import { create } from "zustand"; ··· 19 18 import { RecommendButton } from "components/RecommendButton"; 20 19 import { ButtonSecondary } from "components/Buttons"; 21 20 import { Separator } from "components/Layout"; 21 + import type { DrawerThread } from "./drawerThreadContext"; 22 22 23 23 export type InteractionState = { 24 24 drawerOpen: undefined | boolean; ··· 26 26 drawer: undefined | "comments" | "quotes"; 27 27 localComments: Comment[]; 28 28 commentBox: { quote: QuotePosition | null }; 29 + // Thread/quotes views opened within the drawer, innermost last. When 30 + // non-empty the drawer shows the top entry instead of the comments/mentions 31 + // tabs, with a back button to work up the tree. 32 + threadStack: DrawerThread[]; 29 33 }; 30 34 31 35 const defaultInteractionState: InteractionState = { ··· 33 37 drawer: undefined, 34 38 localComments: [], 35 39 commentBox: { quote: null }, 40 + threadStack: [], 36 41 }; 37 42 38 43 export let useInteractionStateStore = create<{ ··· 80 85 const url = new URL(window.location.href); 81 86 const newDocState = newState[document_uri]; 82 87 83 - if (newDocState.drawerOpen && newDocState.drawer) { 88 + // The drawer counts as open if drawerOpen is explicitly true, or if it 89 + // was opened via the URL (drawerOpen still undefined but the param is 90 + // present). This mirrors useDrawerOpen — otherwise updating just the tab 91 + // while the drawer is param-opened would delete the param and close it. 92 + const drawerCurrentlyOpen = 93 + newDocState.drawerOpen === true || 94 + (newDocState.drawerOpen === undefined && 95 + url.searchParams.has("interactionDrawer")); 96 + 97 + if (drawerCurrentlyOpen && newDocState.drawer) { 84 98 url.searchParams.set("interactionDrawer", newDocState.drawer); 85 99 } else { 86 100 url.searchParams.delete("interactionDrawer"); ··· 98 112 pageId?: string, 99 113 ) { 100 114 flushSync(() => { 101 - setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); 115 + setInteractionState(document_uri, { 116 + drawerOpen: true, 117 + drawer, 118 + pageId, 119 + threadStack: [], 120 + }); 102 121 }); 103 122 scrollIntoView("interaction-drawer"); 104 123 } 105 124 125 + // Open the drawer straight onto a thread/quotes view. Used when a Bluesky post 126 + // in the document body is clicked, so its thread opens in the drawer instead of 127 + // a new page (mirroring how the post's own comments/mentions open the drawer). 128 + export function openDrawerThread( 129 + document_uri: string, 130 + thread: DrawerThread, 131 + pageId?: string, 132 + ) { 133 + flushSync(() => { 134 + setInteractionState(document_uri, (s) => ({ 135 + drawerOpen: true, 136 + drawer: s.drawer ?? "comments", 137 + pageId, 138 + threadStack: [thread], 139 + })); 140 + }); 141 + scrollIntoView("interaction-drawer"); 142 + } 143 + 144 + // Open a thread/quotes view inside the drawer, replacing its content. Clicking 145 + // the view you're already on (e.g. the main post of the current thread) is a 146 + // no-op rather than stacking a duplicate. 147 + export function pushDrawerThread(document_uri: string, thread: DrawerThread) { 148 + setInteractionState(document_uri, (s) => { 149 + const top = s.threadStack[s.threadStack.length - 1]; 150 + if (top && top.type === thread.type && top.uri === thread.uri) return {}; 151 + return { threadStack: [...s.threadStack, thread] }; 152 + }); 153 + } 154 + 155 + // Step back up the drawer's thread navigation tree. 156 + export function popDrawerThread(document_uri: string) { 157 + setInteractionState(document_uri, (s) => ({ 158 + threadStack: s.threadStack.slice(0, -1), 159 + })); 160 + } 161 + 162 + // Jump all the way back out of the thread navigation, to the drawer's top 163 + // level (the comments/mentions tabs). 164 + export function popDrawerThreadToRoot(document_uri: string) { 165 + setInteractionState(document_uri, { threadStack: [] }); 166 + } 167 + 106 168 export const Interactions = (props: { 107 169 quotesCount: number; 108 170 commentsCount: number; ··· 131 193 const tags = normalizedDocument.tags; 132 194 const tagCount = tags?.length || 0; 133 195 134 - let interactionsAvailable = 135 - props.showComments || 136 - (props.showMentions && props.quotesCount > 0) || 137 - props.showRecommends; 196 + let commentsAvailable = props.showComments; 197 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 198 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 199 + let defaultDiscussionTab: "comments" | "quotes" = 200 + commentsAvailable && (props.commentsCount > 0 || !mentionsAvailable) 201 + ? "comments" 202 + : "quotes"; 203 + 204 + let interactionsAvailable = discussionsAvailable || props.showRecommends; 138 205 139 206 return ( 140 207 <div ··· 147 214 /> 148 215 )} 149 216 150 - {/*MENTIONS BUTTON*/} 151 - {props.quotesCount === 0 || props.showMentions === false ? null : ( 217 + {/*DISCUSSIONS BUTTON*/} 218 + {!discussionsAvailable ? null : ( 152 219 <button 153 - className="flex w-fit gap-1 items-center" 220 + className="flex gap-1 items-center w-fit" 154 221 onClick={() => { 155 - if (!drawerOpen || drawer !== "quotes") 156 - openInteractionDrawer("quotes", document_uri, props.pageId); 222 + if ( 223 + !drawerOpen || 224 + (drawer !== "comments" && drawer !== "quotes") || 225 + pageId !== props.pageId 226 + ) 227 + openInteractionDrawer( 228 + defaultDiscussionTab, 229 + document_uri, 230 + props.pageId, 231 + ); 157 232 else setInteractionState(document_uri, { drawerOpen: false }); 158 233 }} 159 234 onMouseEnter={handleQuotePrefetch} 160 235 onTouchStart={handleQuotePrefetch} 161 - aria-label="Post quotes" 236 + aria-label="Discussions" 162 237 > 163 - <QuoteTiny aria-hidden /> {props.quotesCount} 164 - </button> 165 - )} 166 - {/*COMMENT BUTTON*/} 167 - {props.showComments === false ? null : ( 168 - <button 169 - className="flex gap-1 items-center w-fit" 170 - onClick={() => { 171 - if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 172 - openInteractionDrawer("comments", document_uri, props.pageId); 173 - else setInteractionState(document_uri, { drawerOpen: false }); 174 - }} 175 - aria-label="Post comments" 176 - > 177 - <CommentTiny aria-hidden /> {props.commentsCount} 238 + <CommentTiny aria-hidden /> {props.commentsCount + props.quotesCount} 178 239 </button> 179 240 )} 180 241 ··· 218 279 const tags = normalizedDocument.tags; 219 280 const tagCount = tags?.length || 0; 220 281 221 - let noInteractions = 222 - !props.showComments && !props.showMentions && !props.showRecommends; 282 + let commentsAvailable = props.showComments; 283 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 284 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 285 + let defaultDiscussionTab: "comments" | "quotes" = 286 + commentsAvailable && (props.commentsCount > 0 || !mentionsAvailable) 287 + ? "comments" 288 + : "quotes"; 289 + 290 + let noInteractions = !discussionsAvailable && !props.showRecommends; 223 291 224 292 return ( 225 293 <div ··· 248 316 expanded 249 317 /> 250 318 )} 251 - {props.quotesCount === 0 || !props.showMentions ? null : ( 252 - <ButtonSecondary 253 - onClick={() => { 254 - if (!drawerOpen || drawer !== "quotes") 255 - openInteractionDrawer( 256 - "quotes", 257 - document_uri, 258 - props.pageId, 259 - ); 260 - else 261 - setInteractionState(document_uri, { drawerOpen: false }); 262 - }} 263 - onMouseEnter={handleQuotePrefetch} 264 - onTouchStart={handleQuotePrefetch} 265 - aria-label="Post quotes" 266 - > 267 - <QuoteTiny aria-hidden /> {props.quotesCount} 268 - <Separator classname="h-4! text-accent-contrast!" /> 269 - Mention{props.quotesCount > 1 ? "s" : ""} 270 - </ButtonSecondary> 271 - )} 272 - {!props.showComments ? null : ( 319 + {!discussionsAvailable ? null : ( 273 320 <ButtonSecondary 274 321 onClick={() => { 275 322 if ( 276 323 !drawerOpen || 277 - drawer !== "comments" || 324 + (drawer !== "comments" && drawer !== "quotes") || 278 325 pageId !== props.pageId 279 326 ) 280 327 openInteractionDrawer( 281 - "comments", 328 + defaultDiscussionTab, 282 329 document_uri, 283 330 props.pageId, 284 331 ); 285 332 else 286 333 setInteractionState(document_uri, { drawerOpen: false }); 287 334 }} 288 - aria-label="Post comments" 335 + onMouseEnter={handleQuotePrefetch} 336 + onTouchStart={handleQuotePrefetch} 337 + aria-label="Discussions" 289 338 > 290 - <CommentTiny aria-hidden />{" "} 291 - {props.commentsCount > 0 && ( 339 + <CommentTiny aria-hidden /> 340 + {props.quotesCount + props.commentsCount !== 0 && ( 292 341 <> 293 - {props.commentsCount} 342 + {props.quotesCount + props.commentsCount}{" "} 294 343 <Separator classname="h-4! text-accent-contrast!" /> 295 344 </> 296 345 )} 297 - Comment{props.commentsCount > 1 ? "s" : ""} 346 + Discussion 298 347 </ButtonSecondary> 299 348 )} 300 349 </div> ··· 365 414 }).length; 366 415 } 367 416 } 368 - 369 417 370 418 const EditButton = (props: { 371 419 publication: { identity_did: string } | null;
+83 -106
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-4 sm: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 + } 150 + 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 + ); 149 157 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> 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="italic border border-border-light rounded-md px-2 pt-1"> 251 + <div className="flex gap-3 items-stretch italic rounded-md pt-1 "> 252 + <div className="flex flex-col w-4"> 253 + <div className="shrink-0 h-1 w-0.5 border border-border mx-auto mt-2" /> 254 + <QuoteTiny className="shrink-0 text-tertiary " /> 255 + <div className="w-0.5 grow border border-border mx-auto mb-2" /> 256 + </div> 280 257 <PostContent 281 258 pollData={[]} 282 259 pages={[]} ··· 285 262 blocks={content} 286 263 did={props.did} 287 264 preview 288 - className="py-0! px-0! text-tertiary" 265 + className="pt-3! px-0! text-tertiary" 289 266 /> 290 267 </div> 291 268 </div>
+64
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx
··· 1 + "use client"; 2 + import { DotLoader } from "components/utils/DotLoader"; 3 + import { StandardSitePostItem } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; 4 + import { DocumentProvider } from "contexts/DocumentContext"; 5 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 6 + import { CommentsDrawerContent } from "./Comments"; 7 + import { MentionsDrawerContent } from "./Quotes"; 8 + import { useDocumentDiscussionData } from "./useDocumentDiscussionData"; 9 + 10 + // Renders a referenced post's discussion inside the interaction drawer: the 11 + // post itself (as a StandardSitePostItem) at the top of the stack, then its 12 + // comments or Bluesky mentions for the active tab. The tab is driven by the 13 + // drawer header's toggle (InteractionDrawer), which shares the same fetch via 14 + // SWR — this view is otherwise self-contained. Pushed onto the drawer's thread 15 + // stack when a standard-site-post block's interactions are clicked from within 16 + // a published post body, mirroring how a Bluesky post opens its thread. 17 + export function StandardSitePostDrawerView(props: { 18 + uri: string; 19 + tab: "comments" | "quotes"; 20 + }) { 21 + const { 22 + isLoading, 23 + data, 24 + did, 25 + pages, 26 + documentContextValue, 27 + comments, 28 + quotesAndMentions, 29 + } = useDocumentDiscussionData(props.uri, true); 30 + 31 + return ( 32 + <div className="standardSitePostDiscussion flex flex-col"> 33 + <div className="standardSitePostBlock block-border overflow-hidden bg-bg-page mb-3"> 34 + <StandardSitePostItem uri={props.uri} hideInteractions /> 35 + </div> 36 + 37 + {!data && isLoading ? ( 38 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 39 + <span>loading</span> 40 + <DotLoader /> 41 + </div> 42 + ) : documentContextValue ? ( 43 + <LeafletContentProvider value={{ pages }}> 44 + <DocumentProvider value={documentContextValue}> 45 + {props.tab === "comments" ? ( 46 + <CommentsDrawerContent 47 + document_uri={props.uri} 48 + comments={comments} 49 + /> 50 + ) : ( 51 + <> 52 + <hr className="border-border-light mt-3 mb-6" /> 53 + <MentionsDrawerContent 54 + did={did} 55 + quotesAndMentions={quotesAndMentions} 56 + /> 57 + </> 58 + )} 59 + </DocumentProvider> 60 + </LeafletContentProvider> 61 + ) : null} 62 + </div> 63 + ); 64 + }
+55
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext, useMemo } from "react"; 3 + import { OpenPage, openPage } from "../postPageState"; 4 + import { openDrawerThread } from "./Interactions"; 5 + 6 + // A thread or quotes view that can be shown inside the interaction drawer. 7 + // `standardSitePost` shows a referenced post's own discussion (the post itself 8 + // plus its comments / Bluesky mentions) rather than a Bluesky thread. 9 + export type DrawerThread = 10 + | { type: "thread"; uri: string } 11 + | { type: "quotes"; uri: string } 12 + | { type: "standardSitePost"; uri: string }; 13 + 14 + type DrawerThreadNav = { 15 + push: (thread: DrawerThread) => void; 16 + }; 17 + 18 + // Set by the InteractionDrawer (to navigate within the drawer) and by the 19 + // document page (to open the drawer onto a thread). When present, thread/quotes 20 + // links replace the drawer's content instead of opening a new page. 21 + export const DrawerThreadContext = createContext<DrawerThreadNav | null>(null); 22 + 23 + // Returns a function that opens a thread or quotes view. When a drawer-aware 24 + // provider is in scope it navigates within / opens the drawer; elsewhere it 25 + // falls back to opening a new page. 26 + export function useOpenThread() { 27 + const drawerNav = useContext(DrawerThreadContext); 28 + return (parent: OpenPage | undefined, thread: DrawerThread) => { 29 + if (drawerNav) drawerNav.push(thread); 30 + // standardSitePost only exists inside the drawer; it has no page form, so 31 + // it's never reached here without a drawer-aware provider in scope. 32 + else if (thread.type !== "standardSitePost") openPage(parent, thread); 33 + }; 34 + } 35 + 36 + // Wraps document-body content so Bluesky posts within it open their thread in 37 + // the interaction drawer (onto a fresh stack) rather than in a new page. 38 + export function DrawerThreadPageProvider(props: { 39 + document_uri: string; 40 + pageId?: string; 41 + children: React.ReactNode; 42 + }) { 43 + const value = useMemo( 44 + () => ({ 45 + push: (thread: DrawerThread) => 46 + openDrawerThread(props.document_uri, thread, props.pageId), 47 + }), 48 + [props.document_uri, props.pageId], 49 + ); 50 + return ( 51 + <DrawerThreadContext.Provider value={value}> 52 + {props.children} 53 + </DrawerThreadContext.Provider> 54 + ); 55 + }
+82
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts
··· 1 + "use client"; 2 + import useSWR from "swr"; 3 + import { AtUri } from "@atproto/api"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import type { DocumentContextValue } from "contexts/DocumentContext"; 6 + import { 7 + getDocumentPages, 8 + type NormalizedDocument, 9 + type NormalizedPublication, 10 + } from "src/utils/normalizeRecords"; 11 + import type { Comment } from "./Comments"; 12 + 13 + export type DocumentInteractionsData = { 14 + comments: Comment[]; 15 + quotesAndMentions: { uri: string; link?: string }[]; 16 + document: NormalizedDocument | null; 17 + publication: NormalizedPublication | null; 18 + }; 19 + 20 + // Fetches a document's comments and Bluesky mentions and builds the Document / 21 + // LeafletContent context values that the shared drawer content (Comments / 22 + // Quotes) reads off `useDocument`. Used by the DiscussionModal and the 23 + // standard-site-post drawer view, which both render another document's 24 + // discussion outside of that document's own post page. 25 + export function useDocumentDiscussionData( 26 + document_uri: string, 27 + enabled: boolean, 28 + ) { 29 + const swr = useSWR(enabled ? ["doc_interactions", document_uri] : null, () => 30 + callRPC("get_document_interactions", { document_uri }), 31 + ); 32 + const data = swr.data as unknown as DocumentInteractionsData | undefined; 33 + 34 + let did = ""; 35 + try { 36 + did = new AtUri(document_uri).host; 37 + } catch { 38 + did = ""; 39 + } 40 + 41 + const documentRecord = data?.document ?? null; 42 + const pages = documentRecord ? (getDocumentPages(documentRecord) ?? []) : []; 43 + 44 + const commentsCountByPage: Record<string, number> = {}; 45 + for (const c of data?.comments ?? []) { 46 + const onPage = (c.record as { onPage?: string } | null)?.onPage ?? ""; 47 + commentsCountByPage[onPage] = (commentsCountByPage[onPage] ?? 0) + 1; 48 + } 49 + 50 + // The drawer content only reads uri / normalizedDocument / normalizedPublication 51 + // off the document context; the rest is filled with sensible defaults. 52 + const documentContextValue: DocumentContextValue | null = documentRecord 53 + ? ({ 54 + uri: document_uri, 55 + normalizedDocument: documentRecord, 56 + normalizedPublication: data?.publication ?? null, 57 + theme: null, 58 + prevNext: null, 59 + quotesAndMentions: data?.quotesAndMentions ?? [], 60 + publication: null, 61 + commentsCount: data?.comments.length ?? 0, 62 + commentsCountByPage, 63 + mentions: [], 64 + leafletId: null, 65 + recommendsCount: 0, 66 + } as unknown as DocumentContextValue) 67 + : null; 68 + 69 + const prefs = data?.publication?.preferences; 70 + 71 + return { 72 + isLoading: swr.isLoading, 73 + data, 74 + did, 75 + pages, 76 + documentContextValue, 77 + comments: (data?.comments ?? []) as Comment[], 78 + quotesAndMentions: data?.quotesAndMentions ?? [], 79 + showComments: prefs?.showComments !== false, 80 + showMentions: prefs?.showMentions !== false, 81 + }; 82 + }
+17
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts
··· 1 + "use client"; 2 + import { useSearchParams } from "next/navigation"; 3 + import { InteractionState, useInteractionState } from "./Interactions"; 4 + 5 + export const useDrawerOpen = (uri: string) => { 6 + let params = useSearchParams(); 7 + let interactionDrawerSearchParam = params.get("interactionDrawer"); 8 + let pageParam = params.get("page"); 9 + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 10 + if (open === false || (open === undefined && !interactionDrawerSearchParam)) 11 + return null; 12 + drawer = 13 + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 14 + // Use pageId from state, or fall back to page search param 15 + const resolvedPageId = pageId ?? pageParam ?? undefined; 16 + return { drawer, pageId: resolvedPageId }; 17 + };
+16 -13
app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 8 8 import { PostContent } from "./PostContent"; 9 9 import { PostHeader } from "./PostHeader/PostHeader"; 10 10 import { AppBskyFeedDefs } from "@atproto/api"; 11 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 11 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 12 + import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; 12 13 import { PageWrapper } from "components/Pages/Page"; 13 14 import { decodeQuotePosition } from "./quotePosition"; 14 15 import { PollData } from "./fetchPollData"; ··· 80 81 preferences={preferences} 81 82 /> 82 83 )} 83 - <PostContent 84 - pollData={pollData} 85 - pages={pages as PubLeafletPagesLinearDocument.Main[]} 86 - pageId={pageId} 87 - bskyPostData={bskyPostData} 88 - standardSitePostData={standardSitePostData} 89 - blocks={blocks} 90 - did={did} 91 - prerenderedCodeBlocks={prerenderedCodeBlocks} 92 - footnoteIndexMap={footnoteIndexMap} 93 - /> 84 + <DrawerThreadPageProvider document_uri={document_uri} pageId={pageId}> 85 + <PostContent 86 + pollData={pollData} 87 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 88 + pageId={pageId} 89 + bskyPostData={bskyPostData} 90 + standardSitePostData={standardSitePostData} 91 + blocks={blocks} 92 + did={did} 93 + prerenderedCodeBlocks={prerenderedCodeBlocks} 94 + footnoteIndexMap={footnoteIndexMap} 95 + /> 96 + </DrawerThreadPageProvider> 94 97 <PublishedFootnoteSection footnotes={footnotes} /> 95 98 <PostPrevNextButtons 96 99 showPrevNext={preferences.showPrevNext !== false && !isSubpage} ··· 100 103 showComments={preferences.showComments !== false} 101 104 showMentions={preferences.showMentions !== false} 102 105 showRecommends={preferences.showRecommends !== false} 103 - commentsCount={document.commentsCount} 106 + commentsCount={document.commentsCountByPage[pageId ?? ""] ?? 0} 104 107 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 105 108 recommendsCount={document.recommendsCount} 106 109 />
+18 -12
app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 248 248 </div> 249 249 ); 250 250 } 251 + // default to "medium" to match the draft (StandardSitePostBlock), 252 + // since the publish step omits size when it hasn't been explicitly set 251 253 let size: "large" | "medium" | "small" = 252 254 b.block.size === "large" 253 255 ? "large" 254 - : b.block.size === "medium" 255 - ? "medium" 256 - : "small"; 256 + : b.block.size === "small" 257 + ? "small" 258 + : "medium"; 257 259 return ( 258 260 <div className={className} {...blockProps}> 259 - <WithStandardSitePostPublicationTheme 260 - post={post} 261 - enabled={b.block.showPublicationTheme !== false} 262 - > 263 - <StandardSitePostItemView 261 + <div className="standardSitePostBlock block-border overflow-hidden w-full"> 262 + <WithStandardSitePostPublicationTheme 264 263 post={post} 265 - size={size} 266 - currentPublicationUri={currentPublicationUri} 267 - /> 268 - </WithStandardSitePostPublicationTheme> 264 + enabled={b.block.showPublicationTheme !== false} 265 + > 266 + <div className="bg-bg-page"> 267 + <StandardSitePostItemView 268 + post={post} 269 + size={size} 270 + currentPublicationUri={currentPublicationUri} 271 + /> 272 + </div> 273 + </WithStandardSitePostPublicationTheme> 274 + </div> 269 275 </div> 270 276 ); 271 277 }
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 96 96 quotesCount={ 97 97 getQuoteCount(document?.quotesAndMentions || []) || 0 98 98 } 99 - commentsCount={document?.commentsCount || 0} 99 + commentsCount={document?.commentsCountByPage[""] || 0} 100 100 recommendsCount={document?.recommendsCount || 0} 101 101 /> 102 102 )}
+6 -3
app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 1 1 "use client"; 2 2 import { AppBskyFeedDefs } from "@atproto/api"; 3 3 import { preload } from "swr"; 4 - import { openPage, OpenPage } from "./postPageState"; 4 + import { OpenPage } from "./postPageState"; 5 + import { useOpenThread } from "./Interactions/drawerThreadContext"; 5 6 6 7 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 7 8 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; ··· 62 63 onClick?: (e: React.MouseEvent) => void; 63 64 }) { 64 65 const { postUri, parent, children, className, onClick } = props; 66 + const openThread = useOpenThread(); 65 67 66 68 const handleClick = (e: React.MouseEvent) => { 67 69 e.stopPropagation(); 68 70 onClick?.(e); 69 71 if (e.defaultPrevented) return; 70 - openPage(parent, { type: "thread", uri: postUri }); 72 + openThread(parent, { type: "thread", uri: postUri }); 71 73 }; 72 74 73 75 const handlePrefetch = () => { ··· 95 97 onClick?: (e: React.MouseEvent) => void; 96 98 }) { 97 99 const { postUri, parent, children, className, onClick } = props; 100 + const openThread = useOpenThread(); 98 101 99 102 const handleClick = (e: React.MouseEvent) => { 100 103 e.stopPropagation(); 101 104 onClick?.(e); 102 105 if (e.defaultPrevented) return; 103 - openPage(parent, { type: "quotes", uri: postUri }); 106 + openThread(parent, { type: "quotes", uri: postUri }); 104 107 }; 105 108 106 109 const handlePrefetch = () => {
+6 -46
app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 10 10 import { PostPageData } from "./getPostPageData"; 11 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 12 import { AppBskyFeedDefs } from "@atproto/api"; 13 - import { 14 - InteractionDrawer, 15 - useDrawerOpen, 16 - } from "./Interactions/InteractionDrawer"; 13 + import { InteractionDrawer } from "./Interactions/InteractionDrawer"; 14 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 17 15 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 16 import { PageOptionButton } from "components/Pages/PageOptions"; 19 17 import { CloseTiny } from "components/Icons/CloseTiny"; ··· 22 20 import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 23 21 import { LinearDocumentPage } from "./LinearDocumentPage"; 24 22 import { CanvasPage } from "./CanvasPage"; 25 - import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 26 - import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 27 23 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 28 24 import { 29 25 type OpenPage, ··· 203 199 {openPageIds.map((openPage, openPageIndex) => { 204 200 const pageKey = getPageKey(openPage); 205 201 206 - // Handle thread pages 207 - if (openPage.type === "thread") { 208 - return ( 209 - <Fragment key={pageKey}> 210 - <SandwichSpacer /> 211 - <ThreadPageComponent 212 - parentUri={openPage.uri} 213 - pageId={pageKey} 214 - hasPageBackground={hasPageBackground} 215 - pageOptions={ 216 - <PageOptions 217 - onClick={() => closePage(openPage)} 218 - hasPageBackground={hasPageBackground} 219 - /> 220 - } 221 - /> 222 - </Fragment> 223 - ); 224 - } 225 - 226 - // Handle quotes pages 227 - if (openPage.type === "quotes") { 228 - return ( 229 - <Fragment key={pageKey}> 230 - <SandwichSpacer /> 231 - <BlueskyQuotesPage 232 - postUri={openPage.uri} 233 - pageId={pageKey} 234 - hasPageBackground={hasPageBackground} 235 - pageOptions={ 236 - <PageOptions 237 - onClick={() => closePage(openPage)} 238 - hasPageBackground={hasPageBackground} 239 - /> 240 - } 241 - /> 242 - </Fragment> 243 - ); 244 - } 245 - 246 202 // Handle iframe pages 247 203 if (openPage.type === "iframe") { 248 204 return ( ··· 263 219 </Fragment> 264 220 ); 265 221 } 222 + 223 + // Only document pages can be opened now; thread/quotes views render in 224 + // the interaction drawer rather than as their own pages. 225 + if (openPage.type !== "doc") return null; 266 226 267 227 // Handle document pages 268 228 let page = pages.find(
+1
app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
··· 150 150 pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), 151 151 }, 152 152 commentsCount: 0, 153 + commentsCountByPage: {}, 153 154 mentions: [], 154 155 leafletId: null, 155 156 recommendsCount: 0,
+274 -222
app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 - import { useEffect, useMemo, useRef } from "react"; 2 + import { useContext, useEffect, useMemo, useRef, useState } from "react"; 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, ··· 8 8 } from "@atproto/api"; 9 9 import { AtUri } from "@atproto/syntax"; 10 10 import useSWR from "swr"; 11 - import { PageWrapper } from "components/Pages/Page"; 12 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 11 + import { DrawerThreadContext } from "./Interactions/drawerThreadContext"; 13 12 import { DotLoader } from "components/utils/DotLoader"; 14 13 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 15 14 import { useThreadState } from "src/useThreadState"; 16 - import { 17 - BskyPostContent, 18 - CompactBskyPostContent, 19 - ClientDate, 20 - } from "./BskyPostContent"; 15 + import { BskyPostContent, CompactBskyPostContent } from "./BskyPostContent"; 21 16 import { 22 17 ThreadLink, 23 18 getThreadKey, 24 19 fetchThread, 25 - prefetchThread, 20 + getQuotesKey, 21 + fetchQuotes, 26 22 } from "./PostLinks"; 23 + import { Tabs } from "components/Tabs"; 24 + import { CollapsibleReplies } from "components/CollapsibleReplies"; 27 25 import { useDocument } from "contexts/DocumentContext"; 28 - import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 29 26 import { QuoteContent } from "./Interactions/Quotes"; 30 27 import { 31 28 decodeQuotePosition, 29 + getDocumentUrls, 30 + matchDocumentUrl, 32 31 type QuotePosition, 33 32 } from "./quotePosition"; 34 - 35 - // Re-export for backwards compatibility 36 - export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; 37 33 38 34 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 39 35 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; ··· 69 65 return chain; 70 66 } 71 67 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 68 // Scan a post's facets and embed for links to the current document 106 69 function findDocumentQuoteLink( 107 70 post: AppBskyFeedDefs.PostView, ··· 129 92 130 93 // Check external embed URI 131 94 if (post.embed && AppBskyEmbedExternal.isView(post.embed)) { 132 - const match = matchDocumentUrl( 133 - post.embed.external.uri, 134 - documentUrls, 135 - ); 95 + const match = matchDocumentUrl(post.embed.external.uri, documentUrls); 136 96 if (match) return { ...match, isEmbed: true }; 137 97 } 138 98 139 99 return null; 140 100 } 141 101 142 - export function ThreadPage(props: { 102 + // Fetches a thread and renders its content (loading/error states included). 103 + // Used both as a standalone page and inside the interaction drawer. `initialTab` 104 + // selects whether replies or quote posts are shown first (defaults to replies). 105 + export function ThreadView(props: { 143 106 parentUri: string; 144 - pageId: string; 145 - pageOptions?: React.ReactNode; 146 - hasPageBackground: boolean; 107 + initialTab?: "replies" | "quotes"; 147 108 }) { 148 - const { parentUri, pageId, pageOptions } = props; 149 - const drawer = useDrawerOpen(parentUri); 150 - 109 + const { parentUri, initialTab } = props; 151 110 const { 152 111 data: thread, 153 112 isLoading, ··· 156 115 fetchThread(parentUri), 157 116 ); 158 117 159 - return ( 160 - <PageWrapper 161 - pageType="doc" 162 - fullPageScroll={false} 163 - id={`post-page-${pageId}`} 164 - drawerOpen={false} 165 - pageOptions={pageOptions} 166 - fixedWidth 167 - > 168 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 169 - {isLoading ? ( 170 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 171 - <span>loading thread</span> 172 - <DotLoader /> 173 - </div> 174 - ) : error ? ( 175 - <div className="text-tertiary italic text-sm text-center py-8"> 176 - Failed to load thread 177 - </div> 178 - ) : thread ? ( 179 - <ThreadContent post={thread} parentUri={parentUri} /> 180 - ) : null} 118 + if (isLoading) { 119 + return ( 120 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 121 + <span>loading thread</span> 122 + <DotLoader /> 181 123 </div> 182 - </PageWrapper> 124 + ); 125 + } 126 + if (error) { 127 + return ( 128 + <div className="text-tertiary italic text-sm text-center py-8"> 129 + Failed to load thread 130 + </div> 131 + ); 132 + } 133 + if (!thread) return null; 134 + return ( 135 + <ThreadContent post={thread} parentUri={parentUri} initialTab={initialTab} /> 183 136 ); 184 137 } 185 138 186 - function ThreadContent(props: { post: ThreadType; parentUri: string }) { 139 + function ThreadContent(props: { 140 + post: ThreadType; 141 + parentUri: string; 142 + initialTab?: "replies" | "quotes"; 143 + }) { 187 144 const { post, parentUri } = props; 188 145 const mainPostRef = useRef<HTMLDivElement>(null); 146 + // Inside the interaction drawer the header (back/close) shares the scroll 147 + // container, so we let the drawer handle scroll position instead of pulling 148 + // the main post to the top (which would hide the header). 149 + const inDrawer = useContext(DrawerThreadContext) !== null; 189 150 190 151 // Compute document URLs for leaflet link detection 191 152 const { ··· 196 157 const docAtUri = useMemo(() => new AtUri(docUri), [docUri]); 197 158 const docDid = docAtUri.host; 198 159 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]); 160 + const documentUrls = useMemo( 161 + () => getDocumentUrls(normalizedDocument, docUri, normalizedPublication), 162 + [docUri, normalizedDocument, normalizedPublication], 163 + ); 221 164 222 165 // Scroll the main post into view when the thread loads 223 166 useEffect(() => { 167 + if (inDrawer) return; 224 168 if (mainPostRef.current) { 225 169 mainPostRef.current.scrollIntoView({ 226 170 behavior: "instant", 227 171 block: "start", 228 172 }); 229 173 } 230 - }, []); 174 + }, [inDrawer]); 231 175 232 176 if (AppBskyFeedDefs.isNotFoundPost(post)) { 233 177 return <PostNotAvailable />; ··· 274 218 <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 275 219 </div> 276 220 277 - {/* Replies */} 278 - {post.replies && post.replies.length > 0 && ( 279 - <div className="threadReplies flex flex-col mt-4 pt-4 border-t border-border-light w-full"> 280 - <Replies 281 - replies={post.replies as any[]} 282 - pageUri={post.post.uri} 283 - parentPostUri={post.post.uri} 284 - depth={0} 285 - parentAuthorDid={post.post.author.did} 286 - rootAuthorDid={rootAuthorDid} 287 - documentUrls={documentUrls} 288 - docDid={docDid} 289 - /> 221 + {/* Replies and quote posts */} 222 + <ThreadInteractions 223 + post={post} 224 + rootAuthorDid={rootAuthorDid} 225 + documentUrls={documentUrls} 226 + docDid={docDid} 227 + initialTab={props.initialTab} 228 + /> 229 + </div> 230 + ); 231 + } 232 + 233 + // Tabbed section under the main post showing its replies and quote posts. 234 + // When only one of the two has content, a header is shown instead of tabs. 235 + function ThreadInteractions(props: { 236 + post: ThreadViewPost; 237 + rootAuthorDid: string; 238 + documentUrls: string[]; 239 + docDid: string; 240 + initialTab?: "replies" | "quotes"; 241 + }) { 242 + const { post, rootAuthorDid, documentUrls, docDid } = props; 243 + 244 + const replies = (post.replies as any[]) ?? []; 245 + const replyCount = post.post.replyCount ?? replies.length; 246 + const quoteCount = post.post.quoteCount ?? 0; 247 + const hasReplies = replies.length > 0; 248 + const hasQuotes = quoteCount > 0; 249 + const showTabs = hasReplies && hasQuotes; 250 + 251 + const [activeTab, setActiveTab] = useState<"replies" | "quotes">( 252 + props.initialTab ?? (hasReplies ? "replies" : "quotes"), 253 + ); 254 + 255 + if (!hasReplies && !hasQuotes) return null; 256 + 257 + // Default to whichever tab actually has content 258 + const tab = !hasReplies ? "quotes" : !hasQuotes ? "replies" : activeTab; 259 + 260 + return ( 261 + <div className="threadInteractions flex flex-col mt-4 w-full"> 262 + {showTabs ? ( 263 + <Tabs 264 + value={tab} 265 + onChange={(value) => setActiveTab(value)} 266 + options={[ 267 + { value: "replies", label: `Replies (${replyCount})` }, 268 + { value: "quotes", label: `Quote Posts (${quoteCount})` }, 269 + ]} 270 + /> 271 + ) : ( 272 + <div className="text-tertiary text-sm font-bold"> 273 + {hasReplies 274 + ? `Replies (${replyCount})` 275 + : `Quote Posts (${quoteCount})`} 276 + <hr className="border-border-light mt-[6px]" /> 290 277 </div> 291 278 )} 279 + 280 + {tab === "replies" ? ( 281 + <Replies 282 + replies={replies} 283 + pageUri={post.post.uri} 284 + parentPostUri={post.post.uri} 285 + depth={0} 286 + parentAuthorDid={post.post.author.did} 287 + rootAuthorDid={rootAuthorDid} 288 + documentUrls={documentUrls} 289 + docDid={docDid} 290 + /> 291 + ) : ( 292 + <ThreadQuotes postUri={post.post.uri} pageUri={post.post.uri} /> 293 + )} 294 + </div> 295 + ); 296 + } 297 + 298 + // Fetches and renders the posts that quote the main post 299 + function ThreadQuotes(props: { postUri: string; pageUri: string }) { 300 + const { 301 + data: quotesData, 302 + isLoading, 303 + error, 304 + } = useSWR(getQuotesKey(props.postUri), () => fetchQuotes(props.postUri)); 305 + 306 + if (isLoading) { 307 + return ( 308 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 309 + <span>loading quotes</span> 310 + <DotLoader /> 311 + </div> 312 + ); 313 + } 314 + 315 + if (error) { 316 + return ( 317 + <div className="text-tertiary italic text-sm text-center py-8"> 318 + Failed to load quotes 319 + </div> 320 + ); 321 + } 322 + 323 + if (!quotesData || quotesData.posts.length === 0) { 324 + return ( 325 + <div className="text-tertiary italic text-sm text-center py-8"> 326 + No quotes yet 327 + </div> 328 + ); 329 + } 330 + 331 + return ( 332 + <div className="flex flex-col gap-0 pt-4"> 333 + {quotesData.posts.map((post, index) => { 334 + const parent = { type: "thread" as const, uri: props.pageUri }; 335 + // let isPinnedPost = post.uri === 336 + return ( 337 + <> 338 + <BskyPostContent 339 + key={post.uri} 340 + post={post} 341 + parent={parent} 342 + showEmbed 343 + compactEmbed 344 + showBlueskyLink 345 + quoteEnabled 346 + replyEnabled 347 + className="relative rounded text-sm" 348 + /> 349 + 350 + <hr className="last:hidden border-border-light my-4" /> 351 + </> 352 + ); 353 + })} 292 354 </div> 293 355 ); 294 356 } ··· 310 372 parent={page} 311 373 avatarSize="large" 312 374 showBlueskyLink={true} 375 + showInteractions={false} 313 376 showEmbed={true} 314 377 compactEmbed 315 378 quoteEnabled ··· 353 416 documentUrls, 354 417 docDid, 355 418 } = props; 356 - const collapsedThreads = useThreadState((s) => s.collapsedThreads); 357 - const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 358 - 359 419 // Sort replies so that replies from the parent author come first 360 420 const sortedReplies = useMemo( 361 421 () => ··· 376 436 ); 377 437 378 438 return ( 379 - <div className="replies flex flex-col gap-0 w-full"> 439 + <div 440 + className={`replies flex flex-col w-full pt-4 ${ 441 + props.depth === 0 ? "gap-0" : "gap-8" 442 + }`} 443 + > 380 444 {sortedReplies.map((reply, index) => { 381 445 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 382 446 return ( ··· 405 469 } 406 470 407 471 const hasReplies = reply.replies && reply.replies.length > 0; 408 - const isCollapsed = collapsedThreads.has(reply.post.uri); 409 472 410 473 return ( 411 - <ReplyPost 412 - key={reply.post.uri} 413 - post={reply} 414 - isLast={index === replies.length - 1 && !hasReplies} 415 - pageUri={pageUri} 416 - parentPostUri={parentPostUri} 417 - toggleCollapsed={toggleCollapsed} 418 - isCollapsed={isCollapsed} 419 - depth={props.depth} 420 - rootAuthorDid={rootAuthorDid} 421 - documentUrls={documentUrls} 422 - docDid={docDid} 423 - /> 474 + <> 475 + <ReplyPost 476 + key={reply.post.uri} 477 + post={reply} 478 + isLast={index === replies.length - 1 && !hasReplies} 479 + pageUri={pageUri} 480 + depth={props.depth} 481 + rootAuthorDid={rootAuthorDid} 482 + documentUrls={documentUrls} 483 + docDid={docDid} 484 + /> 485 + {props.depth === 0 && ( 486 + <hr className="border-border-light my-4 last:hidden" /> 487 + )} 488 + </> 424 489 ); 425 490 })} 426 491 </div> 427 492 ); 428 493 } 429 494 495 + // Wraps a nested reply list in the same indented thread-line + collapse 496 + // affordance used by document comment replies (Interactions/Comments), and 497 + // animates its height when it opens/closes so threads collapse with the same 498 + // motion as comments. 499 + function NestedReplies(props: { 500 + open: boolean; 501 + onCollapse: () => void; 502 + children: React.ReactNode; 503 + }) { 504 + return ( 505 + <CollapsibleReplies open={props.open}> 506 + <div className="repliesWrapper relative pt-1 pl-[26px] "> 507 + {/* the thread line itself is non-interactive; a transparent button is 508 + overlaid on top of it (z-10) so clicking the line collapses the 509 + thread. The button has to sit over the line (left-[28px]) rather 510 + than in the empty gutter, otherwise clicks land on the post's 511 + absolute-inset overlay underneath and open the thread instead. */} 512 + <div className="absolute top-0 bottom-0 left-[38px] w-[2px] bg-border-light pointer-events-none" /> 513 + <button 514 + className="repliesCollapse absolute top-0 bottom-0 left-[28px] w-[20px] z-10" 515 + onClick={(e) => { 516 + e.preventDefault(); 517 + e.stopPropagation(); 518 + props.onCollapse(); 519 + }} 520 + /> 521 + {props.children} 522 + </div> 523 + </CollapsibleReplies> 524 + ); 525 + } 526 + 430 527 const ReplyPost = (props: { 431 528 post: ThreadViewPost; 432 529 isLast: boolean; 433 530 pageUri: string; 434 - parentPostUri: string; 435 - toggleCollapsed: (uri: string) => void; 436 - isCollapsed: boolean; 437 531 depth: number; 438 532 rootAuthorDid: string; 439 533 documentUrls: string[]; 440 534 docDid: string; 441 535 }) => { 442 - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props; 536 + const { post, pageUri, rootAuthorDid, documentUrls, docDid } = props; 537 + const collapsedThreads = useThreadState((s) => s.collapsedThreads); 538 + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 443 539 444 540 // Flatten same-author chains 445 541 const chain = flattenSameAuthorChain(post, rootAuthorDid); 446 542 const lastInChain = chain[chain.length - 1]; 447 543 const hasReplies = lastInChain.replies && lastInChain.replies.length > 0; 544 + const isCollapsed = collapsedThreads.has(lastInChain.post.uri); 448 545 const isTruncated = 449 546 !hasReplies && 450 547 lastInChain.post.replyCount != null && ··· 452 549 453 550 return ( 454 551 <div className="flex h-fit relative"> 455 - {props.depth > 0 && ( 456 - <> 457 - <div className="absolute replyLine top-0 bottom-0 left-0 w-6 pointer-events-none "> 458 - <div className="bg-border-light w-[2px] h-full mx-auto" /> 459 - </div> 460 - <button 461 - className="absolute top-0 bottom-0 left-0 w-6 z-10" 462 - onClick={(e) => { 463 - e.preventDefault(); 464 - e.stopPropagation(); 465 - props.toggleCollapsed(parentPostUri); 466 - }} 467 - /> 468 - </> 469 - )} 470 - <div 471 - className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`} 472 - > 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} 552 + <div className={`reply relative flex flex-col w-full `}> 553 + <ReplyPostContent 554 + post={post.post} 555 + pageUri={pageUri} 556 + documentUrls={documentUrls} 557 + docDid={docDid} 558 + toggleCollapsed={() => toggleCollapsed(post.post.uri)} 559 + /> 560 + 561 + {/* Render child replies, styled like replies to a comment */} 562 + {hasReplies && props.depth < 10 && ( 563 + <NestedReplies 564 + open={!isCollapsed} 565 + onCollapse={() => toggleCollapsed(lastInChain.post.uri)} 566 + > 567 + <Replies 495 568 pageUri={pageUri} 569 + parentPostUri={lastInChain.post.uri} 570 + replies={lastInChain.replies as any[]} 571 + depth={props.depth + 1} 572 + parentAuthorDid={lastInChain.post.author.did} 573 + rootAuthorDid={rootAuthorDid} 496 574 documentUrls={documentUrls} 497 575 docDid={docDid} 498 - compact 499 - toggleCollapsed={() => 500 - props.toggleCollapsed(lastInChain.post.uri) 501 - } 502 576 /> 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 - /> 577 + </NestedReplies> 512 578 )} 513 579 514 - {/* Render child replies */} 515 - {hasReplies && props.depth < 10 && ( 516 - <div className="ml-[28px] flex grow"> 517 - {!props.isCollapsed && ( 518 - <Replies 580 + {/* Auto-load truncated replies */} 581 + {isTruncated && props.depth < 10 && ( 582 + <NestedReplies 583 + open={!isCollapsed} 584 + onCollapse={() => toggleCollapsed(lastInChain.post.uri)} 585 + > 586 + {!isCollapsed && ( 587 + <SubThread 588 + postUri={lastInChain.post.uri} 519 589 pageUri={pageUri} 520 - parentPostUri={lastInChain.post.uri} 521 - replies={lastInChain.replies as any[]} 522 - depth={props.depth + 1} 523 - parentAuthorDid={lastInChain.post.author.did} 590 + depth={props.depth} 524 591 rootAuthorDid={rootAuthorDid} 525 592 documentUrls={documentUrls} 526 593 docDid={docDid} 527 594 /> 528 595 )} 529 - </div> 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> 596 + </NestedReplies> 544 597 )} 545 598 546 599 {/* Safety fallback at extreme depth */} ··· 583 636 584 637 if (compact) { 585 638 return ( 586 - <div className="flex flex-col w-full"> 639 + <div className="bskyPostReplyCompact flex flex-col w-full"> 587 640 {quoteBlock} 588 641 <CompactBskyPostContent 589 642 post={post} ··· 604 657 } 605 658 606 659 return ( 607 - <div className="flex flex-col w-full"> 660 + <div className="bskyPostReply flex flex-col w-full "> 608 661 {quoteBlock} 609 662 <BskyPostContent 610 663 post={post} ··· 621 674 } 622 675 : undefined 623 676 } 624 - className="text-sm" 677 + className=" text-sm" 625 678 /> 626 679 </div> 627 680 ); ··· 636 689 documentUrls: string[]; 637 690 docDid: string; 638 691 }) { 639 - const { data: thread, isLoading } = useSWR( 640 - getThreadKey(props.postUri), 641 - () => fetchThread(props.postUri), 692 + const { data: thread, isLoading } = useSWR(getThreadKey(props.postUri), () => 693 + fetchThread(props.postUri), 642 694 ); 643 695 644 696 if (isLoading) {
+18 -3
app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 5 5 normalizePublicationRecord, 6 6 } from "src/utils/normalizeRecords"; 7 7 import { resolvePublicationTheme } from "lexicons/src/normalize"; 8 - import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 8 + import { 9 + PubLeafletComment, 10 + PubLeafletPublication, 11 + SiteStandardPublication, 12 + } from "lexicons/api"; 9 13 import { documentUriFilter } from "src/utils/uriHelpers"; 10 14 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 11 15 ··· 16 20 ` 17 21 data, 18 22 uri, 19 - comments_on_documents(count), 23 + comments_on_documents(record), 20 24 documents_in_publications(publications(*, 21 25 documents_in_publications(documents(uri, data)), 22 26 publication_subscriptions(*), ··· 150 154 } 151 155 : null; 152 156 const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 153 - const commentsCount = document.comments_on_documents?.[0]?.count ?? 0; 157 + 158 + // Comments are counted per-page so subpages (and the main page) each show only 159 + // their own discussion. Comments on the main page have no `onPage`, so they're 160 + // keyed under "". `commentsCount` remains the document-wide total. 161 + const commentRecords = document.comments_on_documents ?? []; 162 + const commentsCountByPage: Record<string, number> = {}; 163 + for (const c of commentRecords) { 164 + const onPage = (c.record as PubLeafletComment.Record)?.onPage ?? ""; 165 + commentsCountByPage[onPage] = (commentsCountByPage[onPage] ?? 0) + 1; 166 + } 167 + const commentsCount = commentRecords.length; 154 168 155 169 return { 156 170 ...document, ··· 163 177 // Explicit relational data for DocumentContext 164 178 publication, 165 179 commentsCount, 180 + commentsCountByPage, 166 181 mentions: document.document_mentions_in_bsky, 167 182 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 168 183 // Recommends data
+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 + }
+1
app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 143 143 recommendsCount={doc.recommendsCount} 144 144 documentUri={doc.uri} 145 145 tags={doc.record.tags || []} 146 + title={doc.record.title} 146 147 showComments={pubRecord?.preferences?.showComments !== false} 147 148 showMentions={pubRecord?.preferences?.showMentions !== false} 148 149 showRecommends={pubRecord?.preferences?.showRecommends !== false}
+2
app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 48 48 prevNext: undefined, 49 49 publication: publication || null, 50 50 commentsCount: 0, 51 + commentsCountByPage: {}, 51 52 comments_on_documents: [], 52 53 mentions: [], 53 54 document_mentions_in_bsky: [], ··· 111 112 quotesAndMentions: [], 112 113 publication: pubInfo, 113 114 commentsCount: 0, 115 + commentsCountByPage: {}, 114 116 mentions: [], 115 117 leafletId: null, 116 118 recommendsCount: 0,
+9 -1
app/api/rpc/[command]/get_document_interactions.ts
··· 35 35 .single(); 36 36 37 37 if (!document) { 38 - return { comments: [], quotesAndMentions: [], totalMentionsCount: 0 }; 38 + return { 39 + comments: [], 40 + quotesAndMentions: [], 41 + totalMentionsCount: 0, 42 + document: null, 43 + publication: null, 44 + }; 39 45 } 40 46 41 47 const normalizedData = normalizeDocumentRecord( ··· 107 113 comments, 108 114 quotesAndMentions, 109 115 totalMentionsCount: quotesAndMentions.length, 116 + document: normalizedData, 117 + publication: normalizedPubRecord, 110 118 }; 111 119 }, 112 120 });
+29 -1
app/globals.css
··· 37 37 rgb(var(--primary)), 38 38 rgb(var(--bg-page)) 85% 39 39 ); 40 + --color-bg-light: color-mix( 41 + in oklab, 42 + rgb(var(--primary)), 43 + rgb(var(--bg-page)) 95% 44 + ); 40 45 --color-white: #ffffff; 41 46 --color-accent-1: rgb(var(--accent-1)); 42 47 --color-accent-2: rgb(var(--accent-2)); ··· 497 502 } 498 503 499 504 .light-container { 500 - background: color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%); 505 + background: var(--color-bg-light); 501 506 @apply border; 502 507 @apply border-border-light; 503 508 @apply rounded-md; ··· 664 669 } 665 670 .mobile-sidebar-content[data-state="closed"] { 666 671 animation: mobile-sidebar-slide-out 80ms ease-in; 672 + } 673 + 674 + @keyframes bottom-sheet-slide-in { 675 + from { 676 + transform: translateY(100%); 677 + } 678 + to { 679 + transform: translateY(0); 680 + } 681 + } 682 + @keyframes bottom-sheet-slide-out { 683 + from { 684 + transform: translateY(0); 685 + } 686 + to { 687 + transform: translateY(100%); 688 + } 689 + } 690 + .mobileSheet[data-state="open"] { 691 + animation: bottom-sheet-slide-in 200ms ease-out; 692 + } 693 + .mobileSheet[data-state="closed"] { 694 + animation: bottom-sheet-slide-out 200ms ease-in; 667 695 } 668 696 669 697 .footnote-side-item {
+6 -6
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 11 11 AppBskyLabelerDefs, 12 12 } from "@atproto/api"; 13 13 import { Avatar } from "components/Avatar"; 14 - import { 15 - OpenPage, 16 - openPage, 17 - } from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; 14 + import { OpenPage } from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; 15 + import { useOpenThread } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext"; 18 16 import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; 19 17 20 18 export const BlueskyEmbed = (props: { ··· 24 22 compact?: boolean; 25 23 parent?: OpenPage; 26 24 }) => { 25 + const openThread = useOpenThread(); 27 26 // check this file from bluesky for ref 28 27 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx 29 28 switch (true) { 30 29 case AppBskyEmbedImages.isView(props.embed): 31 30 let imageEmbed = props.embed; 32 31 return ( 33 - <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 32 + <div className="imageEmbed flex flex-wrap rounded-lg w-full overflow-hidden"> 34 33 {imageEmbed.images.map( 35 34 ( 36 35 image: { ··· 161 160 e.preventDefault(); 162 161 e.stopPropagation(); 163 162 164 - openPage(props.parent, { type: "thread", uri: record.uri }); 163 + openThread(props.parent, { type: "thread", uri: record.uri }); 165 164 }} 166 165 > 167 166 <Avatar ··· 185 184 <div className="flex flex-col gap-2 "> 186 185 {text && ( 187 186 <pre 187 + style={{ fontFamily: "inherit" }} 188 188 className={`whitespace-pre-wrap text-secondary ${props.compact ? "line-clamp-6" : ""}`} 189 189 > 190 190 {text}
+19 -4
components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx
··· 26 26 uri, 27 27 size = "medium", 28 28 currentPublicationUri, 29 + pageWidth, 30 + hideInteractions, 29 31 }: { 30 32 uri: string; 31 33 size?: StandardSitePostSize; 32 34 currentPublicationUri?: string | null; 35 + pageWidth?: number; 36 + hideInteractions?: boolean; 33 37 }) { 34 38 const { data, isLoading } = useStandardSitePost(uri); 35 39 const { rootEntity } = useReplicache(); 36 - const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 40 + const postPageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 37 41 38 42 if (isLoading) { 39 43 return ( 40 - <StandardSitePostItemPlaceholder size={size} pageWidth={pageWidth} /> 44 + <StandardSitePostItemPlaceholder 45 + size={size} 46 + pageWidth={pageWidth ? pageWidth : postPageWidth} 47 + /> 41 48 ); 42 49 } 43 50 ··· 49 56 <StandardSitePostItemView 50 57 post={data} 51 58 size={size} 59 + pageWidth={pageWidth} 52 60 currentPublicationUri={currentPublicationUri} 61 + hideInteractions={hideInteractions} 53 62 /> 54 63 ); 55 64 } ··· 155 164 post, 156 165 size = "medium", 157 166 currentPublicationUri, 167 + hideInteractions, 168 + pageWidth: pageWidthProp, 158 169 }: { 159 170 post: StandardSitePostData; 160 171 size?: StandardSitePostSize; 161 172 currentPublicationUri?: string | null; 173 + hideInteractions?: boolean; 174 + pageWidth?: number; 162 175 }) { 163 176 const docUrl = getDocumentURL( 164 177 post.record, ··· 193 206 : undefined; 194 207 195 208 const { rootEntity } = useReplicache(); 196 - const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 209 + const themePageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 210 + const pageWidth = pageWidthProp ?? themePageWidth; 197 211 198 212 const publicationPrefs = post.publication?.record?.preferences; 199 213 const showComments = publicationPrefs?.showComments !== false; ··· 201 215 const showRecommends = publicationPrefs?.showRecommends !== false; 202 216 const commentsCount = showComments ? post.commentsCount : 0; 203 217 204 - const interactions = ( 218 + const interactions = hideInteractions ? undefined : ( 205 219 <InteractionPreview 206 220 quotesCount={post.mentionsCount} 207 221 commentsCount={commentsCount} ··· 209 223 documentUri={post.uri} 210 224 tags={post.record.tags || []} 211 225 postUrl={docUrl} 226 + title={post.record.title} 212 227 showComments={showComments} 213 228 showMentions={showMentions} 214 229 showRecommends={showRecommends}
+1 -7
components/Canvas.tsx
··· 18 18 import { Popover } from "./Popover"; 19 19 import { Separator } from "./Layout"; 20 20 import { CommentTiny } from "./Icons/CommentTiny"; 21 - import { QuoteTiny } from "./Icons/QuoteTiny"; 22 21 import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; 23 22 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 23 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; ··· 192 191 <RecommendTinyEmpty className="text-border" /> — 193 192 </div> 194 193 )} 195 - {showComments && ( 194 + {(showComments || showMentions) && ( 196 195 <div className="flex gap-1 text-tertiary items-center"> 197 196 <CommentTiny className="text-border" /> — 198 - </div> 199 - )} 200 - {showMentions && ( 201 - <div className="flex gap-1 text-tertiary items-center"> 202 - <QuoteTiny className="text-border" /> — 203 197 </div> 204 198 )} 205 199
+39
components/CollapsibleReplies.tsx
··· 1 + "use client"; 2 + import { useEffect, useRef } from "react"; 3 + import { useSpring, animated } from "@react-spring/web"; 4 + import useMeasure from "react-use-measure"; 5 + 6 + // Animates a replies list's height when it opens/closes instead of snapping 7 + // it in and out of the DOM. Shared by document comments and Bluesky thread 8 + // replies so both collapse with the same motion. 9 + export const CollapsibleReplies = (props: { 10 + open: boolean; 11 + children: React.ReactNode; 12 + }) => { 13 + let [ref, { height }] = useMeasure(); 14 + // Skip the spring on the first render with a real height so replies that 15 + // are open by default don't animate in on page load. 16 + let measured = useRef(false); 17 + let style = useSpring({ 18 + height: props.open ? height : 0, 19 + opacity: props.open ? 1 : 0, 20 + immediate: !measured.current, 21 + config: { tension: 280, friction: 30 }, 22 + }); 23 + useEffect(() => { 24 + if (height > 0) measured.current = true; 25 + }, [height]); 26 + 27 + // overflow:hidden is needed to animate height, but it also clips horizontally. 28 + // Reply rows pull their avatar slightly left (negative margins) to sit on the 29 + // thread line, so extend the clip box leftward (pl-2 + matching -ml-2 keeps the 30 + // content in place) to keep avatars from getting shaved off. 31 + return ( 32 + <animated.div 33 + className="pl-2 -ml-2" 34 + style={{ ...style, overflow: "hidden" }} 35 + > 36 + <div ref={ref}>{props.children}</div> 37 + </animated.div> 38 + ); 39 + };
+145
components/DiscussionModal.tsx
··· 1 + "use client"; 2 + import { useEffect, useState } from "react"; 3 + import { Modal } from "./Modal"; 4 + import { ToggleGroup } from "./ToggleGroup"; 5 + import { SpeedyLink } from "./SpeedyLink"; 6 + import { DotLoader } from "./utils/DotLoader"; 7 + import { DocumentProvider } from "contexts/DocumentContext"; 8 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 9 + import { CommentsDrawerContent } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 10 + import { MentionsDrawerContent } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes"; 11 + import { decodeQuotePosition } from "app/(app)/lish/[did]/[publication]/[rkey]/quotePosition"; 12 + import { useDocumentDiscussionData } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData"; 13 + import { GoToArrow } from "./Icons/GoToArrow"; 14 + import { ButtonPrimary } from "./Buttons"; 15 + import { StandardSitePostItem } from "./Blocks/StandardSitePostBlock/StandardSitePostItem"; 16 + 17 + // A self-contained modal that renders a post's comments and Bluesky mentions 18 + // with a toggle between them. Used from post listings, the reader feed, and 19 + // page-link blocks where the full post page (and its interaction drawer) isn't 20 + // mounted. It fetches everything it needs from get_document_interactions and 21 + // provides the Document/LeafletContent contexts that the shared drawer content 22 + // expects, so the post page's own drawer (#3) can stay untouched. 23 + export function DiscussionModal(props: { 24 + open: boolean; 25 + onOpenChange: (open: boolean) => void; 26 + document_uri: string; 27 + postUrl: string; 28 + title?: string; 29 + commentsCount: number; 30 + quotesCount: number; 31 + showComments: boolean; 32 + showMentions: boolean; 33 + pageId?: string; 34 + }) { 35 + const commentsAvailable = props.showComments && props.commentsCount > 0; 36 + const mentionsAvailable = props.showMentions && props.quotesCount > 0; 37 + const bothAvailable = commentsAvailable && mentionsAvailable; 38 + 39 + const [tab, setTab] = useState<"comments" | "quotes">( 40 + commentsAvailable ? "comments" : "quotes", 41 + ); 42 + // Reset to the most relevant tab each time the modal is opened. 43 + useEffect(() => { 44 + if (props.open) setTab(commentsAvailable ? "comments" : "quotes"); 45 + }, [props.open]); 46 + 47 + const { isLoading, data, did, pages, documentContextValue, comments } = 48 + useDocumentDiscussionData(props.document_uri, props.open); 49 + 50 + // Restrict mentions to the page this modal is about (mirrors InteractionDrawer). 51 + const quotesAndMentions = (data?.quotesAndMentions ?? []).filter((q) => { 52 + if (!q.link) return !props.pageId; 53 + const url = new URL(q.link); 54 + const quoteParam = url.pathname.split("/l-quote/")[1]; 55 + if (!quoteParam) return !props.pageId; 56 + const quotePosition = decodeQuotePosition(quoteParam); 57 + return quotePosition?.pageId === props.pageId; 58 + }); 59 + 60 + return ( 61 + <Modal 62 + open={props.open} 63 + onOpenChange={props.onOpenChange} 64 + className="px-3! pt-0! pb-4 gap-0 sm:w-lg max-w-full relative bg-[var(--color-bg-light)]! max-h-full h-[1000px]!" 65 + > 66 + <div className="standardSitePostBlock block-border overflow-hidden w-full bg-bg-page my-3"> 67 + <StandardSitePostItem 68 + pageWidth={448} 69 + uri={props.document_uri} 70 + size="small" 71 + hideInteractions 72 + /> 73 + </div> 74 + <div className="discussionModalStickyHeader sticky top-0 z-10 bg-[var(--color-bg-light)]! -mx-3"> 75 + <div className="flex items-center justify-between gap-3 pt-3 pb-2 px-3"> 76 + {bothAvailable ? ( 77 + <ToggleGroup 78 + fullWidth 79 + value={tab} 80 + onChange={(value) => setTab(value)} 81 + options={[ 82 + { 83 + value: "comments", 84 + label: 85 + props.commentsCount > 0 86 + ? `Comments (${props.commentsCount})` 87 + : "Comments", 88 + }, 89 + { 90 + value: "quotes", 91 + label: ( 92 + <div> 93 + Bluesky <span className="hidden sm:inline">Mentions</span>{" "} 94 + {props.quotesCount > 0 && `(${props.quotesCount})`} 95 + </div> 96 + ), 97 + }, 98 + ]} 99 + /> 100 + ) : ( 101 + <div className="font-bold text-tertiary text-sm! "> 102 + {commentsAvailable 103 + ? `Comments (${props.commentsCount})` 104 + : `Bluesky Mentions (${props.quotesCount})`} 105 + </div> 106 + )}{" "} 107 + <SpeedyLink 108 + href={props.postUrl} 109 + className="shrink-0 hover:no-underline!" 110 + > 111 + <ButtonPrimary className="text-sm py-[3px]! rounded-lg!"> 112 + Full Post <GoToArrow /> 113 + </ButtonPrimary> 114 + </SpeedyLink> 115 + </div> 116 + <hr className="border-border-light" /> 117 + </div> 118 + <div className="pb-3 pt-4"> 119 + {!data && isLoading ? ( 120 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 121 + <span>loading</span> 122 + <DotLoader /> 123 + </div> 124 + ) : documentContextValue ? ( 125 + <LeafletContentProvider value={{ pages }}> 126 + <DocumentProvider value={documentContextValue}> 127 + {tab === "comments" ? ( 128 + <CommentsDrawerContent 129 + document_uri={props.document_uri} 130 + comments={comments} 131 + pageId={props.pageId} 132 + /> 133 + ) : ( 134 + <MentionsDrawerContent 135 + did={did} 136 + quotesAndMentions={quotesAndMentions} 137 + /> 138 + )} 139 + </DocumentProvider> 140 + </LeafletContentProvider> 141 + ) : null} 142 + </div> 143 + </Modal> 144 + ); 145 + }
+27
components/Icons/DoubleArrowRightTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DoubleArrowRightTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M9.05487 2.30299L13.8044 7.31194C14.1702 7.69772 14.1702 8.30228 13.8044 8.68806L9.05487 13.697C8.6718 14.101 8.05073 14.101 7.66767 13.697C7.2846 13.293 7.2846 12.638 7.66767 12.234L11.6824 8L7.66767 3.76597C7.2846 3.36198 7.2846 2.70698 7.66767 2.30299C8.05073 1.899 8.6718 1.899 9.05487 2.30299Z" 17 + fill="currentColor" 18 + /> 19 + <path 20 + fillRule="evenodd" 21 + clipRule="evenodd" 22 + d="M3.6745 2.30299L8.42399 7.31194C8.78978 7.69772 8.78978 8.30228 8.42399 8.68806L3.6745 13.697C3.29143 14.101 2.67036 14.101 2.2873 13.697C1.90423 13.293 1.90423 12.638 2.2873 12.234L6.30201 8L2.2873 3.76597C1.90423 3.36198 1.90423 2.70698 2.2873 2.30299C2.67036 1.899 3.29143 1.899 3.6745 2.30299Z" 23 + fill="currentColor" 24 + /> 25 + </svg> 26 + ); 27 + };
+41 -19
components/InteractionsPreview.tsx
··· 1 1 "use client"; 2 + import { useContext, useState } from "react"; 2 3 import { Separator } from "./Layout"; 3 4 import { CommentTiny } from "./Icons/CommentTiny"; 4 - import { QuoteTiny } from "./Icons/QuoteTiny"; 5 5 import { useSmoker } from "./Toast"; 6 6 import { Tag } from "./Tags"; 7 7 import { Popover } from "./Popover"; 8 8 import { TagTiny } from "./Icons/TagTiny"; 9 - import { SpeedyLink } from "./SpeedyLink"; 10 9 import { RecommendButton } from "./RecommendButton"; 10 + import { DiscussionModal } from "./DiscussionModal"; 11 + import { DrawerThreadContext } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext"; 11 12 12 13 export const InteractionPreview = (props: { 13 14 quotesCount: number; ··· 16 17 documentUri: string; 17 18 tags?: string[]; 18 19 postUrl: string; 20 + title?: string; 19 21 showComments: boolean; 20 22 showMentions: boolean; 21 23 showRecommends: boolean; ··· 23 25 share?: boolean; 24 26 }) => { 25 27 let smoker = useSmoker(); 28 + // Inside a published post body a DrawerThreadContext is in scope; there we 29 + // open this post's discussion in the interaction drawer (like a Bluesky post's 30 + // thread) instead of the standalone modal used in listings/feeds. 31 + let drawerNav = useContext(DrawerThreadContext); 32 + let [discussionsOpen, setDiscussionsOpen] = useState(false); 33 + let commentsAvailable = props.showComments !== false && props.commentsCount > 0; 34 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 35 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 26 36 let interactionsAvailable = 27 - (props.quotesCount > 0 && props.showMentions) || 28 - (props.showComments !== false && props.commentsCount > 0) || 37 + discussionsAvailable || 29 38 (props.showRecommends !== false && props.recommendsCount > 0); 30 39 31 40 const tagsCount = props.tags?.length || 0; ··· 39 48 /> 40 49 )} 41 50 42 - {!props.showMentions || props.quotesCount === 0 ? null : ( 43 - <SpeedyLink 44 - aria-label="Post quotes" 45 - href={`${props.postUrl}?interactionDrawer=quotes`} 46 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 51 + {!discussionsAvailable ? null : ( 52 + <button 53 + aria-label="Post discussions" 54 + onClick={(e) => { 55 + e.preventDefault(); 56 + e.stopPropagation(); 57 + if (drawerNav) 58 + drawerNav.push({ 59 + type: "standardSitePost", 60 + uri: props.documentUri, 61 + }); 62 + else setDiscussionsOpen(true); 63 + }} 64 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 47 65 > 48 - <QuoteTiny /> {props.quotesCount} 49 - </SpeedyLink> 66 + <CommentTiny /> {props.commentsCount + props.quotesCount} 67 + </button> 50 68 )} 51 - {!props.showComments || props.commentsCount === 0 ? null : ( 52 - <SpeedyLink 53 - aria-label="Post comments" 54 - href={`${props.postUrl}?interactionDrawer=comments`} 55 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 56 - > 57 - <CommentTiny /> {props.commentsCount} 58 - </SpeedyLink> 69 + {discussionsAvailable && !drawerNav && ( 70 + <DiscussionModal 71 + open={discussionsOpen} 72 + onOpenChange={setDiscussionsOpen} 73 + document_uri={props.documentUri} 74 + postUrl={props.postUrl} 75 + title={props.title} 76 + commentsCount={props.commentsCount} 77 + quotesCount={props.quotesCount} 78 + showComments={props.showComments} 79 + showMentions={props.showMentions} 80 + /> 59 81 )} 60 82 {tagsCount === 0 ? null : ( 61 83 <>
+100
components/MobileSheet.tsx
··· 1 + "use client"; 2 + import * as Dialog from "@radix-ui/react-dialog"; 3 + import React from "react"; 4 + import { isIOS } from "src/utils/isDevice"; 5 + import { CloseTiny } from "./Icons/CloseTiny"; 6 + import { useVisualViewport } from "./ViewportSizeLayout"; 7 + 8 + // A mobile drawer sheet that slides up from the bottom of the screen. Built on 9 + // Radix Dialog so it traps focus, handles the escape key, and animates open and 10 + // closed via the .bottom-sheet-content keyframes in globals.css. 11 + export const MobileSheet = ({ 12 + className, 13 + open, 14 + onOpenChange, 15 + asChild, 16 + trigger, 17 + title, 18 + id, 19 + contentRef, 20 + children, 21 + }: { 22 + className?: string; 23 + open?: boolean; 24 + onOpenChange?: (open: boolean) => void; 25 + asChild?: boolean; 26 + trigger?: React.ReactNode; 27 + title?: React.ReactNode; 28 + // Forwarded to the scrolling content container so callers can scroll it into 29 + // view or reset its scroll position. 30 + id?: string; 31 + contentRef?: React.Ref<HTMLDivElement>; 32 + children: React.ReactNode; 33 + }) => { 34 + let { height, offsetTop, difference } = useVisualViewport(); 35 + // iOS keyboard open: the layout viewport (and dvh) don't shrink for the 36 + // keyboard, so a bottom-anchored sheet would sit behind it. Lift the sheet to 37 + // the top of the keyboard and shrink it to the visual viewport. Android 38 + // resizes the layout viewport via interactiveWidget: "resizes-content", so 39 + // bottom-0 + dvh already follow the keyboard there. 40 + let keyboardOpen = isIOS() && difference !== 0 && height > 0; 41 + return ( 42 + <Dialog.Root open={open} onOpenChange={onOpenChange}> 43 + {trigger !== undefined && ( 44 + <Dialog.Trigger asChild={asChild}>{trigger}</Dialog.Trigger> 45 + )} 46 + <Dialog.Portal> 47 + <Dialog.Overlay className="fixed z-50 inset-0 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-overlayShow" /> 48 + <Dialog.Content 49 + style={ 50 + keyboardOpen 51 + ? { 52 + bottom: `${difference - offsetTop}px`, 53 + height: `${height - 16}px`, 54 + } 55 + : undefined 56 + } 57 + className={` 58 + mobileSheet 59 + z-50 fixed bottom-0 left-0 right-0 60 + w-full h-[85dvh] flex flex-col text-primary 61 + `} 62 + > 63 + <div 64 + ref={contentRef} 65 + id={id} 66 + className={` 67 + opaque-container pwa-padding-bottom 68 + p-3 pt-2 flex flex-col rounded-b-none! rounded-t-lg! 69 + h-full overflow-y-scroll 70 + ${className}`} 71 + > 72 + {/* When a title is given the sheet supplies its own header + close 73 + button; otherwise the children are expected to render their own 74 + chrome. Radix still requires a Dialog.Title for accessibility. */} 75 + {title ? ( 76 + <div className="w-full flex items-center gap-2 mb-2"> 77 + <div className="flex-1 min-w-0"> 78 + <Dialog.Title asChild> 79 + <h3 className="text-primary">{title}</h3> 80 + </Dialog.Title> 81 + </div> 82 + <Dialog.Close className="text-tertiary shrink-0"> 83 + <CloseTiny /> 84 + </Dialog.Close> 85 + </div> 86 + ) : ( 87 + <Dialog.Title /> 88 + )} 89 + <Dialog.Description asChild> 90 + <div className="flex flex-col"> 91 + {children} 92 + <div className="spacer h-6" /> 93 + </div> 94 + </Dialog.Description> 95 + </div> 96 + </Dialog.Content> 97 + </Dialog.Portal> 98 + </Dialog.Root> 99 + ); 100 + };
+2 -2
components/Modal.tsx
··· 38 38 ? { top: `${offsetTop}px`, height: `${height}px` } 39 39 : undefined 40 40 } 41 - className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" 41 + className="fixed z-50 inset-0 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-overlayShow" 42 42 /> 43 43 <Dialog.Content 44 44 style={ ··· 60 60 <div 61 61 className={` 62 62 opaque-container p-3 63 - flex flex-col rounded-lg! 63 + flex flex-col rounded-lg! max-h-full overflow-y-scroll 64 64 ${className}`} 65 65 > 66 66 {title ? (
+1 -1
components/Pages/Page.tsx
··· 16 16 import { focusPage } from "src/utils/focusPage"; 17 17 import { PageOptions } from "./PageOptions"; 18 18 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 19 - import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen"; 20 20 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 21 21 import { usePageFootnotes } from "components/Footnotes/usePageFootnotes"; 22 22 import { FootnoteContext } from "components/Footnotes/FootnoteContext";
+2 -7
components/Pages/PublicationMetadata.tsx
··· 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { timeAgo } from "src/utils/timeAgo"; 15 15 import { CommentTiny } from "components/Icons/CommentTiny"; 16 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 17 16 import { TagTiny } from "components/Icons/TagTiny"; 18 17 import { Popover } from "components/Popover"; 19 18 import { TagSelector } from "components/Tags"; ··· 139 138 </div> 140 139 )} 141 140 142 - {merged.showMentions !== false && ( 143 - <div className="flex gap-1 items-center"> 144 - <QuoteTiny />— 145 - </div> 146 - )} 147 - {merged.showComments !== false && ( 141 + {(merged.showComments !== false || 142 + merged.showMentions !== false) && ( 148 143 <div className="flex gap-1 items-center"> 149 144 <CommentTiny />— 150 145 </div>
+32 -16
components/PostListing.tsx
··· 17 17 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 18 18 import { useSmoker } from "./Toast"; 19 19 import { CommentTiny } from "./Icons/CommentTiny"; 20 - import { QuoteTiny } from "./Icons/QuoteTiny"; 21 20 import { ShareTiny } from "./Icons/ShareTiny"; 22 21 import { useSelectedPostListing } from "src/useSelectedPostState"; 23 22 import { mergePreferences } from "src/utils/mergePreferences"; ··· 25 24 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 26 25 import { RecommendButton } from "./RecommendButton"; 27 26 import { getFirstParagraph } from "src/utils/getFirstParagraph"; 27 + import { DiscussionModal } from "./DiscussionModal"; 28 28 29 29 export const PostListing = (props: Post & { selected?: boolean }) => { 30 30 let pubRecord = props.publication?.pubRecord as ··· 245 245 let setSelectedPostListing = useSelectedPostListing( 246 246 (s) => s.setSelectedPostListing, 247 247 ); 248 - let selectPostListing = (drawer: "quotes" | "comments") => { 248 + let [discussionsOpen, setDiscussionsOpen] = useState(false); 249 + let defaultDrawer: "comments" | "quotes" = 250 + props.showComments && props.commentsCount > 0 ? "comments" : "quotes"; 251 + let openDiscussions = () => { 252 + // Keep the listing highlighted (read by the reader feed) while the modal is up. 249 253 setSelectedPostListing({ 250 254 document_uri: props.documentUri, 251 255 document: props.document, 252 256 publication: props.publication, 253 - drawer, 257 + drawer: defaultDrawer, 254 258 }); 259 + setDiscussionsOpen(true); 255 260 }; 256 261 262 + let commentsAvailable = props.showComments && props.commentsCount > 0; 263 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 264 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 265 + 257 266 return ( 258 267 <div 259 268 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} ··· 263 272 documentUri={props.documentUri} 264 273 recommendsCount={props.recommendsCount} 265 274 /> 266 - {!props.showMentions || props.quotesCount === 0 ? null : ( 267 - <button 268 - aria-label="Post quotes" 269 - onClick={() => selectPostListing("quotes")} 270 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 271 - > 272 - <QuoteTiny /> {props.quotesCount} 273 - </button> 274 - )} 275 - {!props.showComments || props.commentsCount === 0 ? null : ( 275 + {!discussionsAvailable ? null : ( 276 276 <button 277 - aria-label="Post comments" 278 - onClick={() => selectPostListing("comments")} 277 + aria-label="Post discussions" 278 + onClick={openDiscussions} 279 279 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 280 280 > 281 - <CommentTiny /> {props.commentsCount} 281 + <CommentTiny /> {props.commentsCount + props.quotesCount} 282 282 </button> 283 283 )} 284 284 </div> 285 + {discussionsAvailable && ( 286 + <DiscussionModal 287 + open={discussionsOpen} 288 + onOpenChange={(open) => { 289 + setDiscussionsOpen(open); 290 + if (!open) setSelectedPostListing(null); 291 + }} 292 + document_uri={props.documentUri} 293 + postUrl={props.postUrl} 294 + title={props.document.title} 295 + commentsCount={props.commentsCount} 296 + quotesCount={props.quotesCount} 297 + showComments={props.showComments} 298 + showMentions={props.showMentions} 299 + /> 300 + )} 285 301 </div> 286 302 ); 287 303 };
+41
components/Tabs.tsx
··· 1 + import { type ReactNode } from "react"; 2 + 3 + export function Tabs<T extends string>(props: { 4 + value: T; 5 + onChange: (value: T, e?: React.MouseEvent) => void; 6 + options: { value: T; label: ReactNode }[]; 7 + className?: string; 8 + optionClassName?: string; 9 + selectedOptionClassName?: string; 10 + }) { 11 + return ( 12 + <div className="w-full"> 13 + <div 14 + className={`tabs flex gap-4 justify-start w-full ${props.className || ""}`} 15 + > 16 + {props.options.map((option) => { 17 + const selected = props.value === option.value; 18 + return ( 19 + <button 20 + key={option.value} 21 + type="button" 22 + className={`tab text-sm font-bold pb-0.5 px-1 border-b-3 ${ 23 + selected 24 + ? `text-accent-contrast border-accent-contrast ${props.selectedOptionClassName || ""}` 25 + : "text-tertiary border-transparent" 26 + } ${props.optionClassName || ""}`} 27 + onClick={(e) => { 28 + e.preventDefault(); 29 + e.stopPropagation(); 30 + props.onChange(option.value, e); 31 + }} 32 + > 33 + {option.label} 34 + </button> 35 + ); 36 + })} 37 + </div> 38 + <hr className="border-border-light" /> 39 + </div> 40 + ); 41 + }
+1 -1
components/ToggleGroup.tsx
··· 2 2 3 3 export function ToggleGroup<T extends string>(props: { 4 4 value: T; 5 - onChange: (value: T) => void; 5 + onChange: (value: T, e?: React.MouseEvent) => void; 6 6 options: { value: T; label: ReactNode }[]; 7 7 className?: string; 8 8 optionClassName?: string;
+1
contexts/DocumentContext.tsx
··· 18 18 | "quotesAndMentions" 19 19 | "publication" 20 20 | "commentsCount" 21 + | "commentsCountByPage" 21 22 | "mentions" 22 23 | "leafletId" 23 24 | "recommendsCount"