From 2166f3a932c3bf2874052be919d30f61d000cd9b Mon Sep 17 00:00:00 2001 From: celine Date: Mon, 1 Jun 2026 12:35:51 -0500 Subject: [PATCH] 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 --- .../(home-pages)/reader/PreviewDrawer.tsx | 0 .../reader/ReaderMentionsContent.tsx | 91 ---- .../[publication]/PublicationPostItem.tsx | 75 +-- .../[publication]/PublicationPostsList.tsx | 1 + .../[rkey]/Blocks/PublishBskyPostBlock.tsx | 2 +- .../[rkey]/Blocks/PublishedPageBlock.tsx | 80 +-- .../[rkey]/BlueskyQuotesPage.tsx | 104 ---- .../[publication]/[rkey]/BskyPostContent.tsx | 211 +++++--- .../[did]/[publication]/[rkey]/CanvasPage.tsx | 27 +- .../Interactions/Comments/CommentBox.tsx | 11 +- .../[rkey]/Interactions/Comments/index.tsx | 318 +++++------ .../[rkey]/Interactions/InteractionDrawer.tsx | 278 ++++++++-- .../[rkey]/Interactions/Interactions.tsx | 168 +++--- .../[rkey]/Interactions/Quotes.tsx | 191 +++---- .../StandardSitePostDrawerView.tsx | 64 +++ .../Interactions/drawerThreadContext.tsx | 55 ++ .../Interactions/useDocumentDiscussionData.ts | 82 +++ .../[rkey]/Interactions/useDrawerOpen.ts | 17 + .../[rkey]/LinearDocumentPage.tsx | 29 +- .../[publication]/[rkey]/PostContent.tsx | 30 +- .../[rkey]/PostHeader/PostHeader.tsx | 2 +- .../[did]/[publication]/[rkey]/PostLinks.tsx | 9 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 52 +- .../[rkey]/PublicationPageRenderer.tsx | 1 + .../[did]/[publication]/[rkey]/ThreadPage.tsx | 496 ++++++++++-------- .../[publication]/[rkey]/getPostPageData.ts | 21 +- .../[publication]/[rkey]/quotePosition.ts | 66 +++ .../dashboard/PublishedPostsLists.tsx | 1 + .../theme-settings/PostPreview.tsx | 2 + .../[command]/get_document_interactions.ts | 10 +- app/globals.css | 30 +- .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 12 +- .../StandardSitePostItem.tsx | 23 +- components/Canvas.tsx | 8 +- components/CollapsibleReplies.tsx | 39 ++ components/DiscussionModal.tsx | 145 +++++ components/Icons/DoubleArrowRightTiny.tsx | 27 + components/InteractionsPreview.tsx | 60 ++- components/MobileSheet.tsx | 100 ++++ components/Modal.tsx | 4 +- components/Pages/Page.tsx | 2 +- components/Pages/PublicationMetadata.tsx | 9 +- components/PostListing.tsx | 48 +- components/Tabs.tsx | 41 ++ components/ToggleGroup.tsx | 2 +- contexts/DocumentContext.tsx | 1 + 46 files changed, 1942 insertions(+), 1103 deletions(-) delete mode 100644 app/(app)/(home-pages)/reader/PreviewDrawer.tsx delete mode 100644 app/(app)/(home-pages)/reader/ReaderMentionsContent.tsx delete mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts create mode 100644 components/CollapsibleReplies.tsx create mode 100644 components/DiscussionModal.tsx create mode 100644 components/Icons/DoubleArrowRightTiny.tsx create mode 100644 components/MobileSheet.tsx create mode 100644 components/Tabs.tsx diff --git a/app/(app)/(home-pages)/reader/PreviewDrawer.tsx b/app/(app)/(home-pages)/reader/PreviewDrawer.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/app/(app)/(home-pages)/reader/ReaderMentionsContent.tsx b/app/(app)/(home-pages)/reader/ReaderMentionsContent.tsx deleted file mode 100644 index 5c738637..00000000 --- a/app/(app)/(home-pages)/reader/ReaderMentionsContent.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; -import useSWR from "swr"; -import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { BskyPostContent } from "app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent"; -import { EmptyState } from "components/EmptyState"; -import { DotLoader } from "components/utils/DotLoader"; - -async function fetchBskyPosts(uris: string[]): Promise { - const params = new URLSearchParams({ - uris: JSON.stringify(uris), - }); - const response = await fetch(`/api/bsky/hydrate?${params.toString()}`); - if (!response.ok) throw new Error("Failed to fetch Bluesky posts"); - return response.json(); -} - -export function ReaderMentionsContent(props: { - quotesAndMentions: { uri: string; link?: string }[]; -}) { - const uris = props.quotesAndMentions.map((q) => q.uri); - const key = - uris.length > 0 - ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` - : null; - - const { data: bskyPosts, isLoading } = useSWR(key, () => - fetchBskyPosts(uris), - ); - - if (props.quotesAndMentions.length === 0) { - return ( - - ); - } - - if (isLoading) { - return ( -
- loading - -
- ); - } - - const postViewMap = new Map(); - bskyPosts?.forEach((pv) => postViewMap.set(pv.uri, pv)); - - // Sort by engagement: likes count 1, replies and quotes count 1.5 - const sorted = [...props.quotesAndMentions].sort((a, b) => { - const postA = postViewMap.get(a.uri); - const postB = postViewMap.get(b.uri); - const scoreA = - (postA?.likeCount ?? 0) + - (postA?.replyCount ?? 0) * 1.5 + - (postA?.quoteCount ?? 0) * 1.5; - const scoreB = - (postB?.likeCount ?? 0) + - (postB?.replyCount ?? 0) * 1.5 + - (postB?.quoteCount ?? 0) * 1.5; - return scoreB - scoreA; - }); - - return ( -
- {sorted.map((q, index) => { - const post = postViewMap.get(q.uri); - if (!post) return null; - return ( -
- - {index < sorted.length - 1 && ( -
- )} -
- ); - })} -
- ); -} diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx b/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx index 05ac204e..5bb0312b 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx +++ b/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx @@ -37,11 +37,9 @@ function MetaRow({ const hasDate = date !== undefined && date !== null; return (
-

+

{hasAuthor && ( {author} @@ -102,36 +100,49 @@ export function PublicationPostItemMedium(props: MediumProps) { const hasCoverImage = !!props.coverImageSrc; return ( -

-
- - {props.title && ( -

- {props.title} -

- )} -

- {props.description} -

-
- -
{props.footer}
-
- {hasCoverImage && ( -
- {props.coverImageAlt +
+
+ + {props.title && ( +

+ {props.title} +

+ )} +

+ {props.description} +

+
+ + {props.footer && ( +
{props.footer}
+ )}
+ {hasCoverImage && ( +
+ {props.coverImageAlt +
+ )} +
+ + {props.footer && ( +
{props.footer}
)}
); diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx b/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx index 49e78ed8..c0fbc158 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx +++ b/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx @@ -103,6 +103,7 @@ export function PublicationPostsList({ documentUri={post.uri} tags={tags} postUrl={docUrl} + title={doc_record.title} showComments={ publicationRecord?.preferences?.showComments !== false } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx index 10b5d210..e6183b20 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx @@ -33,7 +33,7 @@ export const PubBlueskyPostBlock = (props: { avatarSize="large" quoteEnabled replyEnabled - 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!" + 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!" clientHost={props.clientHost} /> ); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index 1dcfdced..0a4f5cd9 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -8,7 +8,6 @@ import { PostContent, Block } from "../PostContent"; import { PubLeafletBlocksHeader, PubLeafletBlocksText, - PubLeafletComment, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletPublication, @@ -18,13 +17,8 @@ import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_si import { TextBlock } from "./TextBlock"; import { useDocument } from "contexts/DocumentContext"; import { openPage, useOpenPages } from "../postPageState"; -import { - openInteractionDrawer, - setInteractionState, - useInteractionState, -} from "../Interactions/Interactions"; +import { openInteractionDrawer } from "../Interactions/Interactions"; import { CommentTiny } from "components/Icons/CommentTiny"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; import { CanvasBackgroundPattern } from "components/Canvas"; export function PublishedPageLinkBlock(props: { @@ -208,59 +202,39 @@ export function PagePreview(props: { } const Interactions = (props: { pageId: string; parentPageId?: string }) => { - const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); + const { + uri: document_uri, + commentsCountByPage, + mentions, + } = useDocument(); + let comments = commentsCountByPage[props.pageId] ?? 0; let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); + if (quotes + comments === 0) return null; return (
- {quotes > 0 && ( - - )} - {comments > 0 && ( - - )} +
); }; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx deleted file mode 100644 index 83c79789..00000000 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; -import { AppBskyFeedDefs } from "@atproto/api"; -import useSWR from "swr"; -import { PageWrapper } from "components/Pages/Page"; -import { useDrawerOpen } from "./Interactions/InteractionDrawer"; -import { DotLoader } from "components/utils/DotLoader"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { openPage } from "./postPageState"; -import { BskyPostContent } from "./BskyPostContent"; -import { - QuotesLink, - getQuotesKey, - fetchQuotes, - prefetchQuotes, -} from "./PostLinks"; - -// Re-export for backwards compatibility -export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; - -type PostView = AppBskyFeedDefs.PostView; - -export function BlueskyQuotesPage(props: { - postUri: string; - pageId: string; - pageOptions?: React.ReactNode; - hasPageBackground: boolean; -}) { - const { postUri, pageId, pageOptions } = props; - const drawer = useDrawerOpen(postUri); - - const { - data: quotesData, - isLoading, - error, - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => - fetchQuotes(postUri), - ); - - return ( - -
-

Bluesky Quotes

- {isLoading ? ( -
- loading quotes - -
- ) : error ? ( -
- Failed to load quotes -
- ) : quotesData && quotesData.posts.length > 0 ? ( - - ) : ( -
- No quotes yet -
- )} -
-
- ); -} - -function QuotesContent(props: { posts: PostView[]; postUri: string }) { - const { posts, postUri } = props; - - return ( -
- {posts.map((post, index) => ( - <> - - {posts.length !== index + 1 && ( -
- )} - - ))} -
- ); -} - -function QuotePost(props: { post: PostView; quotesUri: string }) { - const { post, quotesUri } = props; - const parent = { type: "quotes" as const, uri: quotesUri }; - - return ( - - ); -} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index a6ce9f07..ba1234d2 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -8,12 +8,15 @@ import { QuoteTiny } from "components/Icons/QuoteTiny"; import { Separator } from "components/Layout"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; -import { OpenPage, openPage } from "./postPageState"; +import { OpenPage } from "./postPageState"; +import { useOpenThread } from "./Interactions/drawerThreadContext"; import { ThreadLink, QuotesLink } from "./PostLinks"; import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { Avatar } from "components/Avatar"; import { timeAgo } from "src/utils/timeAgo"; import { ProfilePopover } from "components/ProfilePopover"; +import { QuotePosition } from "./quotePosition"; +import { QuoteContent } from "./Interactions/Quotes"; type PostView = AppBskyFeedDefs.PostView; @@ -25,10 +28,16 @@ export function BskyPostContent(props: { showEmbed?: boolean; compactEmbed?: boolean; showBlueskyLink?: boolean; + showInteractions?: boolean; quoteEnabled?: boolean; replyEnabled?: boolean; replyOnClick?: (e: React.MouseEvent) => void; clientHost?: string; + hasQuote?: { + position: QuotePosition; + index: number; + did: string; + }; }) { const { post, @@ -37,24 +46,45 @@ export function BskyPostContent(props: { showEmbed = true, compactEmbed = false, showBlueskyLink = true, + showInteractions = true, quoteEnabled, replyEnabled, replyOnClick, clientHost = "bsky.app", + hasQuote, } = props; + const openThread = useOpenThread(); const record = post.record as AppBskyFeedPost.Record; const postId = post.uri.split("/")[4]; const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; + // Only allow opening the thread page when there's a discussion to show + const hasThreadContent = + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; + return (
-
+ } + didOrHandle={handle} + />
+
{replyContent && (props.replyEnabled ? ( - + + + ); } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index 8b22ca0a..6b96498f 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -219,12 +219,14 @@ export function CommentBox(props: { uri: result.uri, profile: { did: new AtUri(result.uri).host, - handle: null, + handle: record?.handle ?? null, displayName: + record?.displayName ?? (result.profile as { displayName?: string } | null) - ?.displayName ?? null, - avatar: null, - description: null, + ?.displayName ?? + null, + avatar: record?.avatar ?? null, + description: record?.description ?? null, }, }, ], @@ -418,6 +420,7 @@ export function CommentBox(props: {
 {
             // Close mention dropdown when editor gains focus (reset stale state)
             handleMentionOpenChange(false);
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
index 1549407b..605435d6 100644
--- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
@@ -6,6 +6,7 @@ import { Json } from "supabase/database.types";
 import { PubLeafletComment } from "lexicons/api";
 import { BaseTextBlock } from "../../Blocks/BaseTextBlock";
 import { useMemo, useState } from "react";
+import { CollapsibleReplies } from "components/CollapsibleReplies";
 import { CommentTiny } from "components/Icons/CommentTiny";
 import { Separator } from "components/Layout";
 import { Popover } from "components/Popover";
@@ -17,6 +18,9 @@ import { useLocalizedDate } from "src/hooks/useLocalizedDate";
 import { ProfilePopover } from "components/ProfilePopover";
 import { LoginModal } from "components/LoginButton";
 import { type Profile } from "src/identity";
+import { PostInfo } from "../../BskyPostContent";
+import { Avatar } from "components/Avatar";
+import { EmptyState } from "components/EmptyState";
 
 export type Comment = {
   record: Json;
@@ -27,17 +31,21 @@ export function CommentsDrawerContent(props: {
   document_uri: string;
   comments: Comment[];
   noCommentBox?: boolean;
+  pageId?: string;
 }) {
   let { identity } = useIdentityData();
-  let { localComments, pageId } = useInteractionState(props.document_uri);
+  let { localComments, pageId: statePageId } = useInteractionState(
+    props.document_uri,
+  );
+  // Callers (e.g. the discussion modal) can pin the page explicitly; otherwise
+  // fall back to the page tracked in the shared interaction state.
+  let pageId = props.pageId ?? statePageId;
   let comments = useMemo(() => {
     let filtered = props.comments.filter(
       (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId,
     );
     return [
-      ...localComments.filter(
-        (c) => (c.record as any)?.onPage === pageId,
-      ),
+      ...localComments.filter((c) => (c.record as any)?.onPage === pageId),
       ...filtered,
     ];
   }, [props.comments, localComments, pageId]);
@@ -76,33 +84,38 @@ export function CommentsDrawerContent(props: {
           
)} -
- {comments - .sort((a, b) => { - let aRecord = a.record as PubLeafletComment.Record; - let bRecord = b.record as PubLeafletComment.Record; - return ( - new Date(bRecord.createdAt).getTime() - - new Date(aRecord.createdAt).getTime() - ); - }) - .filter( - (comment) => !(comment.record as PubLeafletComment.Record).reply, - ) - .map((comment) => { - let record = comment.record as PubLeafletComment.Record; - return ( - - ); - })} +
+ {comments.length === 0 && No comments yet…} + {comments.length > 0 && + comments + .sort((a, b) => { + let aRecord = a.record as PubLeafletComment.Record; + let bRecord = b.record as PubLeafletComment.Record; + return ( + new Date(bRecord.createdAt).getTime() - + new Date(aRecord.createdAt).getTime() + ); + }) + .filter( + (comment) => !(comment.record as PubLeafletComment.Record).reply, + ) + .map((comment) => { + let record = comment.record as PubLeafletComment.Record; + return ( + <> + +
+ + ); + })}
); @@ -118,52 +131,57 @@ const Comment = (props: { }) => { const did = props.profile?.did; - let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); - return ( -
-
- {did ? ( - - {props.profile?.displayName} -
- } - /> - ) : null} +
+ -
-
{timeAgoDate}
-
- {props.record.attachment && - PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( -
- -
- )} -
-        
+        
-      
- + + {props.record.attachment && + PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( +
+ +
+ )} +
+          
+        
+ +
); }; @@ -194,90 +212,90 @@ const Replies = (props: { ); }); - let repliesOrReplyBoxOpen = - replyBoxOpen || (repliesOpen && replies.length > 0); return ( <>
- + {(replies.length !== 0 || identity?.atp_did) && ( + + )} {identity?.atp_did && ( - <> - - - + )}
- {repliesOrReplyBoxOpen && ( -
- {replyBoxOpen && ( -
- - { - setReplyBoxOpen(false); - }} - /> + {replyBoxOpen && ( +
+ + { + setReplyBoxOpen(false); + }} + /> +
+ )} + {replies.length > 0 && ( + +
+ {/* the thread line itself is non-interactive; a transparent button + is overlaid on top of it (z-10) to catch clicks, so the line + stays clickable even though the comments re-enable pointer + events with pointer-events-auto */} +
+
- )} - {repliesOpen && replies.length > 0 && ( -
- -
- {replies.map((reply) => { - return ( - - ); - })} -
+
+
+ )} ); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index d86b116d..b2d4b376 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -1,14 +1,27 @@ "use client"; import { MentionsDrawerContent } from "./Quotes"; import { - InteractionState, setInteractionState, useInteractionState, + pushDrawerThread, + popDrawerThread, + popDrawerThreadToRoot, } from "./Interactions"; -import { useSearchParams } from "next/navigation"; import { SandwichSpacer } from "components/LeafletLayout"; import { decodeQuotePosition } from "../quotePosition"; import { CloseTiny } from "components/Icons/CloseTiny"; +import { GoBackTiny } from "components/Icons/GoBackTiny"; +import { DoubleArrowRightTiny } from "components/Icons/DoubleArrowRightTiny"; +import { ToggleGroup } from "components/ToggleGroup"; +import { useDocument } from "contexts/DocumentContext"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { DrawerThread, DrawerThreadContext } from "./drawerThreadContext"; +import { useDrawerOpen } from "./useDrawerOpen"; +import { ThreadView } from "../ThreadPage"; +import { StandardSitePostDrawerView } from "./StandardSitePostDrawerView"; +import { useDocumentDiscussionData } from "./useDocumentDiscussionData"; +import { useIsMobile } from "src/hooks/isMobile"; +import { MobileSheet } from "components/MobileSheet"; export const InteractionDrawer = (props: { showPageBackground: boolean | undefined; @@ -17,10 +30,104 @@ export const InteractionDrawer = (props: { commentsSlot: React.ReactNode; did: string; pageId?: string; +}) => { + // Reset the drawer's scroll to the top whenever we navigate between views, so + // a pushed thread (or a back navigation) doesn't start scrolled partway down. + const scrollRef = useRef(null); + let { threadStack } = useInteractionState(props.document_uri); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [threadStack.length]); + + let isMobile = useIsMobile(); + + // On mobile the drawer slides up from the bottom as a sheet instead of sitting + // inline in the horizontal page sandwich. The content renders its own header + // and close button, so the sheet supplies no title/chrome of its own. This + // component only mounts while the drawer is open (gated in PostPages), so the + // sheet is always open; closing it clears the drawer state. + if (isMobile) { + return ( + { + if (!open) + setInteractionState(props.document_uri, { drawerOpen: false }); + }} + id="interaction-drawer" + contentRef={scrollRef} + > + + + ); + } + + return ( + <> + +
+
+ +
+
+ + ); +}; + +const InteractionDrawerContent = (props: { + showPageBackground: boolean | undefined; + document_uri: string; + quotesAndMentions: { uri: string; link?: string }[]; + commentsSlot: React.ReactNode; + did: string; + pageId?: string; }) => { let drawer = useDrawerOpen(props.document_uri); if (!drawer) return null; + let { commentsCountByPage } = useDocument(); + let commentsCount = commentsCountByPage[props.pageId ?? ""] ?? 0; + let { threadStack } = useInteractionState(props.document_uri); + const drawerNav = useMemo( + () => ({ + push: (thread: DrawerThread) => + pushDrawerThread(props.document_uri, thread), + }), + [props.document_uri], + ); + + // The innermost thread/quotes view opened within the drawer, if any. When + // present it replaces the comments/mentions tabs. + const activeThread = threadStack[threadStack.length - 1]; + + // A standard-site-post thread shows another post's own discussion. It's always + // at the root of the stack (Bluesky threads opened from its mentions become + // the active thread instead), so its comments/mentions toggle lives in the + // drawer header in place of a Back button. Its data is fetched here too (SWR + // dedupes with the view below) to drive that toggle. + const sspUri = + activeThread?.type === "standardSitePost" ? activeThread.uri : null; + const ssp = useDocumentDiscussionData(sspUri ?? "", !!sspUri); + const [sspTab, setSspTab] = useState<"comments" | "quotes">("comments"); + useEffect(() => { + setSspTab("comments"); + }, [sspUri]); + + const sspCommentsAvailable = ssp.showComments && ssp.comments.length > 0; + const sspMentionsAvailable = + ssp.showMentions && ssp.quotesAndMentions.length > 0; + const sspBothAvailable = sspCommentsAvailable && sspMentionsAvailable; + let sspActiveTab: "comments" | "quotes" = sspTab; + if (sspActiveTab === "comments" && !sspCommentsAvailable) + sspActiveTab = "quotes"; + if (sspActiveTab === "quotes" && !sspMentionsAvailable) + sspActiveTab = "comments"; + const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page const url = new URL(q.link); @@ -30,63 +137,134 @@ export const InteractionDrawer = (props: { return quotePosition?.pageId === props.pageId; }); + // commentsSlot is null when comments are disabled by permissions; mentions + // are only available when there's something to show on this page. + const commentsAvailable = props.commentsSlot != null; + const mentionsAvailable = filteredQuotesAndMentions.length > 0; + const bothAvailable = commentsAvailable && mentionsAvailable; + + // Resolve the active tab, falling back to whichever option is available. + let activeTab: "comments" | "quotes" = + drawer.drawer === "quotes" ? "quotes" : "comments"; + if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; + if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; return ( <> - -
-
- {drawer.drawer === "quotes" ? ( - <> - - +
+ {sspUri ? ( + sspBothAvailable ? ( + { + e?.preventDefault(); + setSspTab(value); + }} + options={[ + { + value: "comments", + label: + ssp.comments.length > 0 + ? `Comments (${ssp.comments.length})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky{" "} + Mentions{" "} + {ssp.quotesAndMentions.length > 0 && + `(${ssp.quotesAndMentions.length})`} +
+ ), + }, + ]} /> - - ) : ( - <> -
-

Comments

+ ) : ( +

+ {sspActiveTab === "quotes" + ? `Bluesky Mentions${ssp.quotesAndMentions.length > 0 ? ` (${ssp.quotesAndMentions.length})` : ""}` + : `Comments${ssp.comments.length > 0 ? ` (${ssp.comments.length})` : ""}`} +

+ ) + ) : activeThread ? ( +
+ {threadStack.length >= 2 && ( -
- {props.commentsSlot} - + )} + +
+ ) : bothAvailable ? ( + { + e?.preventDefault(); + setInteractionState(props.document_uri, { drawer: value }); + }} + options={[ + { + value: "comments", + label: + commentsCount > 0 + ? `Comments (${commentsCount})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky Mentions{" "} + {filteredQuotesAndMentions.length > 0 && + `(${filteredQuotesAndMentions.length})`} +
+ ), + }, + ]} + /> + ) : ( +

{activeTab === "quotes" ? "Bluesky Mentions" : "Comments"}

)}
+
+ + {sspUri ? ( + + ) : activeThread ? ( + + ) : activeTab === "quotes" ? ( + + ) : ( + props.commentsSlot + )} + ); }; - -export const useDrawerOpen = (uri: string) => { - let params = useSearchParams(); - let interactionDrawerSearchParam = params.get("interactionDrawer"); - let pageParam = params.get("page"); - let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); - if (open === false || (open === undefined && !interactionDrawerSearchParam)) - return null; - drawer = - drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); - // Use pageId from state, or fall back to page search param - const resolvedPageId = pageId ?? pageParam ?? undefined; - return { drawer, pageId: resolvedPageId }; -}; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 892d614f..afb4b71c 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -1,6 +1,5 @@ "use client"; import { CommentTiny } from "components/Icons/CommentTiny"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; import { flushSync } from "react-dom"; import type { Json } from "supabase/database.types"; import { create } from "zustand"; @@ -19,6 +18,7 @@ import { EditTiny } from "components/Icons/EditTiny"; import { RecommendButton } from "components/RecommendButton"; import { ButtonSecondary } from "components/Buttons"; import { Separator } from "components/Layout"; +import type { DrawerThread } from "./drawerThreadContext"; export type InteractionState = { drawerOpen: undefined | boolean; @@ -26,6 +26,10 @@ export type InteractionState = { drawer: undefined | "comments" | "quotes"; localComments: Comment[]; commentBox: { quote: QuotePosition | null }; + // Thread/quotes views opened within the drawer, innermost last. When + // non-empty the drawer shows the top entry instead of the comments/mentions + // tabs, with a back button to work up the tree. + threadStack: DrawerThread[]; }; const defaultInteractionState: InteractionState = { @@ -33,6 +37,7 @@ const defaultInteractionState: InteractionState = { drawer: undefined, localComments: [], commentBox: { quote: null }, + threadStack: [], }; export let useInteractionStateStore = create<{ @@ -80,7 +85,16 @@ export function setInteractionState( const url = new URL(window.location.href); const newDocState = newState[document_uri]; - if (newDocState.drawerOpen && newDocState.drawer) { + // The drawer counts as open if drawerOpen is explicitly true, or if it + // was opened via the URL (drawerOpen still undefined but the param is + // present). This mirrors useDrawerOpen — otherwise updating just the tab + // while the drawer is param-opened would delete the param and close it. + const drawerCurrentlyOpen = + newDocState.drawerOpen === true || + (newDocState.drawerOpen === undefined && + url.searchParams.has("interactionDrawer")); + + if (drawerCurrentlyOpen && newDocState.drawer) { url.searchParams.set("interactionDrawer", newDocState.drawer); } else { url.searchParams.delete("interactionDrawer"); @@ -98,11 +112,59 @@ export function openInteractionDrawer( pageId?: string, ) { flushSync(() => { - setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); + setInteractionState(document_uri, { + drawerOpen: true, + drawer, + pageId, + threadStack: [], + }); }); scrollIntoView("interaction-drawer"); } +// Open the drawer straight onto a thread/quotes view. Used when a Bluesky post +// in the document body is clicked, so its thread opens in the drawer instead of +// a new page (mirroring how the post's own comments/mentions open the drawer). +export function openDrawerThread( + document_uri: string, + thread: DrawerThread, + pageId?: string, +) { + flushSync(() => { + setInteractionState(document_uri, (s) => ({ + drawerOpen: true, + drawer: s.drawer ?? "comments", + pageId, + threadStack: [thread], + })); + }); + scrollIntoView("interaction-drawer"); +} + +// Open a thread/quotes view inside the drawer, replacing its content. Clicking +// the view you're already on (e.g. the main post of the current thread) is a +// no-op rather than stacking a duplicate. +export function pushDrawerThread(document_uri: string, thread: DrawerThread) { + setInteractionState(document_uri, (s) => { + const top = s.threadStack[s.threadStack.length - 1]; + if (top && top.type === thread.type && top.uri === thread.uri) return {}; + return { threadStack: [...s.threadStack, thread] }; + }); +} + +// Step back up the drawer's thread navigation tree. +export function popDrawerThread(document_uri: string) { + setInteractionState(document_uri, (s) => ({ + threadStack: s.threadStack.slice(0, -1), + })); +} + +// Jump all the way back out of the thread navigation, to the drawer's top +// level (the comments/mentions tabs). +export function popDrawerThreadToRoot(document_uri: string) { + setInteractionState(document_uri, { threadStack: [] }); +} + export const Interactions = (props: { quotesCount: number; commentsCount: number; @@ -131,10 +193,15 @@ export const Interactions = (props: { const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; - let interactionsAvailable = - props.showComments || - (props.showMentions && props.quotesCount > 0) || - props.showRecommends; + let commentsAvailable = props.showComments; + let mentionsAvailable = props.showMentions && props.quotesCount > 0; + let discussionsAvailable = commentsAvailable || mentionsAvailable; + let defaultDiscussionTab: "comments" | "quotes" = + commentsAvailable && (props.commentsCount > 0 || !mentionsAvailable) + ? "comments" + : "quotes"; + + let interactionsAvailable = discussionsAvailable || props.showRecommends; return (
)} - {/*MENTIONS BUTTON*/} - {props.quotesCount === 0 || props.showMentions === false ? null : ( + {/*DISCUSSIONS BUTTON*/} + {!discussionsAvailable ? null : ( - )} - {/*COMMENT BUTTON*/} - {props.showComments === false ? null : ( - )} @@ -218,8 +279,15 @@ export const ExpandedInteractions = (props: { const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; - let noInteractions = - !props.showComments && !props.showMentions && !props.showRecommends; + let commentsAvailable = props.showComments; + let mentionsAvailable = props.showMentions && props.quotesCount > 0; + let discussionsAvailable = commentsAvailable || mentionsAvailable; + let defaultDiscussionTab: "comments" | "quotes" = + commentsAvailable && (props.commentsCount > 0 || !mentionsAvailable) + ? "comments" + : "quotes"; + + let noInteractions = !discussionsAvailable && !props.showRecommends; return (
)} - {props.quotesCount === 0 || !props.showMentions ? null : ( - { - if (!drawerOpen || drawer !== "quotes") - openInteractionDrawer( - "quotes", - document_uri, - props.pageId, - ); - else - setInteractionState(document_uri, { drawerOpen: false }); - }} - onMouseEnter={handleQuotePrefetch} - onTouchStart={handleQuotePrefetch} - aria-label="Post quotes" - > - {props.quotesCount} - - Mention{props.quotesCount > 1 ? "s" : ""} - - )} - {!props.showComments ? null : ( + {!discussionsAvailable ? null : ( { if ( !drawerOpen || - drawer !== "comments" || + (drawer !== "comments" && drawer !== "quotes") || pageId !== props.pageId ) openInteractionDrawer( - "comments", + defaultDiscussionTab, document_uri, props.pageId, ); else setInteractionState(document_uri, { drawerOpen: false }); }} - aria-label="Post comments" + onMouseEnter={handleQuotePrefetch} + onTouchStart={handleQuotePrefetch} + aria-label="Discussions" > - {" "} - {props.commentsCount > 0 && ( + + {props.quotesCount + props.commentsCount !== 0 && ( <> - {props.commentsCount} + {props.quotesCount + props.commentsCount}{" "} )} - Comment{props.commentsCount > 1 ? "s" : ""} + Discussion )}
@@ -366,7 +415,6 @@ export function getQuoteCountFromArray( } } - const EditButton = (props: { publication: { identity_did: string } | null; leafletId: string | null; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index fbb1c6f5..ad10a9f1 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -3,7 +3,7 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { useIsMobile } from "src/hooks/isMobile"; import { setInteractionState } from "./Interactions"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { AtUri, AppBskyFeedPost } from "@atproto/api"; +import { AtUri, AppBskyFeedPost, AppBskyEmbedExternal } from "@atproto/api"; import { PubLeafletBlocksText, PubLeafletBlocksUnorderedList, @@ -13,7 +13,12 @@ import { } from "lexicons/api"; import { useDocument } from "contexts/DocumentContext"; import { useLeafletContent } from "contexts/LeafletContentContext"; -import { decodeQuotePosition, QuotePosition } from "../quotePosition"; +import { + decodeQuotePosition, + getDocumentUrls, + matchDocumentUrl, + QuotePosition, +} from "../quotePosition"; import { useActiveHighlightState } from "../useHighlight"; import { PostContent } from "../PostContent"; import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; @@ -25,6 +30,7 @@ import { CommentTiny } from "components/Icons/CommentTiny"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { ThreadLink, QuotesLink } from "../PostLinks"; import { BskyPostContent } from "../BskyPostContent"; +import { EmptyState } from "components/EmptyState"; function engagementScore(post: PostView | undefined): number { if (!post) return 0; @@ -75,7 +81,19 @@ export const MentionsDrawerContent = (props: { quotesAndMentions: { uri: string; link?: string }[]; did: string; }) => { - const { uri: document_uri } = useDocument(); + const { + uri: document_uri, + normalizedDocument, + normalizedPublication, + } = useDocument(); + + // URLs that point to this document, used to detect when a post's embed is + // just a link card back to this leaflet (redundant with the quote we render) + const documentUrls = getDocumentUrls( + normalizedDocument, + document_uri, + normalizedPublication, + ); // Fetch Bluesky post data for all URIs const uris = props.quotesAndMentions.map((q) => q.uri); @@ -85,8 +103,8 @@ export const MentionsDrawerContent = (props: { ); // Separate quotes with links (quoted content) from direct mentions - const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); - const directMentions = props.quotesAndMentions.filter((q) => !q.link); + // const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); + // const directMentions = props.quotesAndMentions.filter((q) => !q.link); // Create a map of URIs to post views for easy lookup const postViewMap = new Map(); @@ -100,123 +118,77 @@ export const MentionsDrawerContent = (props: { const scoreB = engagementScore(postViewMap.get(b.uri)); return scoreB - scoreA; }; - quotesWithLinks.sort(byEngagement); - directMentions.sort(byEngagement); + let sortedBskyMentions = props.quotesAndMentions.sort(byEngagement); return ( <> {props.quotesAndMentions.length === 0 ? ( -
+
no quotes yet!
highlight any part of this post to quote it
-
+ ) : isLoading ? (
loading
) : ( -
- {quotesWithLinks.length > 0 && ( -
-

Quotes on Bluesky

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

Mentions on Bluesky

- {directMentions.map((q, index) => { - const post = postViewMap.get(q.uri); - if (!post) return null; - - const parent = { type: "thread" as const, uri: q.uri }; - return ( - <> - - {directMentions.length !== index + 1 && ( -
- )} - - ); - })} -
- )} -
+ sortedBskyMentions.length > 0 && ( +
+ {sortedBskyMentions.map((q, index) => { + const post = postViewMap.get(q.uri); + if (!post) return null; + const parent = { type: "thread" as const, uri: q.uri }; + let quotePosition: QuotePosition | null = null; + if (q.link) { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (quoteParam) { + quotePosition = decodeQuotePosition(quoteParam) ?? null; + } + } + + // Hide the embed when it's just a link card pointing back to this + // document; show it for any other embed (image, link, quoted post) + const showEmbed = !( + AppBskyEmbedExternal.isView(post.embed) && + matchDocumentUrl(post.embed.external.uri, documentUrls) + ); + + return ( + <> + +
+ + ); + })} +
+ ) )} ); }; -const Quote = (props: { - q: { - uri: string; - link?: string; - }; - index: number; - did: string; - postViewMap: Map; -}) => { - const post = props.postViewMap.get(props.q.uri); - if (!post || !props.q.link) return null; - const parent = { type: "thread" as const, uri: props.q.uri }; - const url = new URL(props.q.link); - const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; - const quotePosition = decodeQuotePosition(quoteParam); - if (!quotePosition) return null; - - return ( -
- - -
- -
- ); -}; - export const QuoteContent = (props: { position: QuotePosition; index: number; @@ -276,7 +248,12 @@ export const QuoteContent = (props: { }); }} > -
+
+
+
+ +
+
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx new file mode 100644 index 00000000..1aa86a6b --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx @@ -0,0 +1,64 @@ +"use client"; +import { DotLoader } from "components/utils/DotLoader"; +import { StandardSitePostItem } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; +import { DocumentProvider } from "contexts/DocumentContext"; +import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { CommentsDrawerContent } from "./Comments"; +import { MentionsDrawerContent } from "./Quotes"; +import { useDocumentDiscussionData } from "./useDocumentDiscussionData"; + +// Renders a referenced post's discussion inside the interaction drawer: the +// post itself (as a StandardSitePostItem) at the top of the stack, then its +// comments or Bluesky mentions for the active tab. The tab is driven by the +// drawer header's toggle (InteractionDrawer), which shares the same fetch via +// SWR — this view is otherwise self-contained. Pushed onto the drawer's thread +// stack when a standard-site-post block's interactions are clicked from within +// a published post body, mirroring how a Bluesky post opens its thread. +export function StandardSitePostDrawerView(props: { + uri: string; + tab: "comments" | "quotes"; +}) { + const { + isLoading, + data, + did, + pages, + documentContextValue, + comments, + quotesAndMentions, + } = useDocumentDiscussionData(props.uri, true); + + return ( +
+
+ +
+ + {!data && isLoading ? ( +
+ loading + +
+ ) : documentContextValue ? ( + + + {props.tab === "comments" ? ( + + ) : ( + <> +
+ + + )} +
+
+ ) : null} +
+ ); +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx new file mode 100644 index 00000000..058dba5a --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx @@ -0,0 +1,55 @@ +"use client"; +import { createContext, useContext, useMemo } from "react"; +import { OpenPage, openPage } from "../postPageState"; +import { openDrawerThread } from "./Interactions"; + +// A thread or quotes view that can be shown inside the interaction drawer. +// `standardSitePost` shows a referenced post's own discussion (the post itself +// plus its comments / Bluesky mentions) rather than a Bluesky thread. +export type DrawerThread = + | { type: "thread"; uri: string } + | { type: "quotes"; uri: string } + | { type: "standardSitePost"; uri: string }; + +type DrawerThreadNav = { + push: (thread: DrawerThread) => void; +}; + +// Set by the InteractionDrawer (to navigate within the drawer) and by the +// document page (to open the drawer onto a thread). When present, thread/quotes +// links replace the drawer's content instead of opening a new page. +export const DrawerThreadContext = createContext(null); + +// Returns a function that opens a thread or quotes view. When a drawer-aware +// provider is in scope it navigates within / opens the drawer; elsewhere it +// falls back to opening a new page. +export function useOpenThread() { + const drawerNav = useContext(DrawerThreadContext); + return (parent: OpenPage | undefined, thread: DrawerThread) => { + if (drawerNav) drawerNav.push(thread); + // standardSitePost only exists inside the drawer; it has no page form, so + // it's never reached here without a drawer-aware provider in scope. + else if (thread.type !== "standardSitePost") openPage(parent, thread); + }; +} + +// Wraps document-body content so Bluesky posts within it open their thread in +// the interaction drawer (onto a fresh stack) rather than in a new page. +export function DrawerThreadPageProvider(props: { + document_uri: string; + pageId?: string; + children: React.ReactNode; +}) { + const value = useMemo( + () => ({ + push: (thread: DrawerThread) => + openDrawerThread(props.document_uri, thread, props.pageId), + }), + [props.document_uri, props.pageId], + ); + return ( + + {props.children} + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts new file mode 100644 index 00000000..a3b90067 --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts @@ -0,0 +1,82 @@ +"use client"; +import useSWR from "swr"; +import { AtUri } from "@atproto/api"; +import { callRPC } from "app/api/rpc/client"; +import type { DocumentContextValue } from "contexts/DocumentContext"; +import { + getDocumentPages, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import type { Comment } from "./Comments"; + +export type DocumentInteractionsData = { + comments: Comment[]; + quotesAndMentions: { uri: string; link?: string }[]; + document: NormalizedDocument | null; + publication: NormalizedPublication | null; +}; + +// Fetches a document's comments and Bluesky mentions and builds the Document / +// LeafletContent context values that the shared drawer content (Comments / +// Quotes) reads off `useDocument`. Used by the DiscussionModal and the +// standard-site-post drawer view, which both render another document's +// discussion outside of that document's own post page. +export function useDocumentDiscussionData( + document_uri: string, + enabled: boolean, +) { + const swr = useSWR(enabled ? ["doc_interactions", document_uri] : null, () => + callRPC("get_document_interactions", { document_uri }), + ); + const data = swr.data as unknown as DocumentInteractionsData | undefined; + + let did = ""; + try { + did = new AtUri(document_uri).host; + } catch { + did = ""; + } + + const documentRecord = data?.document ?? null; + const pages = documentRecord ? (getDocumentPages(documentRecord) ?? []) : []; + + const commentsCountByPage: Record = {}; + for (const c of data?.comments ?? []) { + const onPage = (c.record as { onPage?: string } | null)?.onPage ?? ""; + commentsCountByPage[onPage] = (commentsCountByPage[onPage] ?? 0) + 1; + } + + // The drawer content only reads uri / normalizedDocument / normalizedPublication + // off the document context; the rest is filled with sensible defaults. + const documentContextValue: DocumentContextValue | null = documentRecord + ? ({ + uri: document_uri, + normalizedDocument: documentRecord, + normalizedPublication: data?.publication ?? null, + theme: null, + prevNext: null, + quotesAndMentions: data?.quotesAndMentions ?? [], + publication: null, + commentsCount: data?.comments.length ?? 0, + commentsCountByPage, + mentions: [], + leafletId: null, + recommendsCount: 0, + } as unknown as DocumentContextValue) + : null; + + const prefs = data?.publication?.preferences; + + return { + isLoading: swr.isLoading, + data, + did, + pages, + documentContextValue, + comments: (data?.comments ?? []) as Comment[], + quotesAndMentions: data?.quotesAndMentions ?? [], + showComments: prefs?.showComments !== false, + showMentions: prefs?.showMentions !== false, + }; +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts new file mode 100644 index 00000000..36202a8d --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts @@ -0,0 +1,17 @@ +"use client"; +import { useSearchParams } from "next/navigation"; +import { InteractionState, useInteractionState } from "./Interactions"; + +export const useDrawerOpen = (uri: string) => { + let params = useSearchParams(); + let interactionDrawerSearchParam = params.get("interactionDrawer"); + let pageParam = params.get("page"); + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); + if (open === false || (open === undefined && !interactionDrawerSearchParam)) + return null; + drawer = + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); + // Use pageId from state, or fall back to page search param + const resolvedPageId = pageId ?? pageParam ?? undefined; + return { drawer, pageId: resolvedPageId }; +}; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 24c447de..89ad50d0 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -8,7 +8,8 @@ import { import { PostContent } from "./PostContent"; import { PostHeader } from "./PostHeader/PostHeader"; import { AppBskyFeedDefs } from "@atproto/api"; -import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { useDrawerOpen } from "./Interactions/useDrawerOpen"; +import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; import { PageWrapper } from "components/Pages/Page"; import { decodeQuotePosition } from "./quotePosition"; import { PollData } from "./fetchPollData"; @@ -80,17 +81,19 @@ export function LinearDocumentPage({ preferences={preferences} /> )} - + + + diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx index a55bc39c..839dcd84 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -248,24 +248,30 @@ export let Block = ({
); } + // default to "medium" to match the draft (StandardSitePostBlock), + // since the publish step omits size when it hasn't been explicitly set let size: "large" | "medium" | "small" = b.block.size === "large" ? "large" - : b.block.size === "medium" - ? "medium" - : "small"; + : b.block.size === "small" + ? "small" + : "medium"; return (
- - + - + enabled={b.block.showPublicationTheme !== false} + > +
+ +
+
+
); } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 72cd56b7..2610afb2 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -96,7 +96,7 @@ export function PostHeader(props: { quotesCount={ getQuoteCount(document?.quotesAndMentions || []) || 0 } - commentsCount={document?.commentsCount || 0} + commentsCount={document?.commentsCountByPage[""] || 0} recommendsCount={document?.recommendsCount || 0} /> )} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx index 25169896..a8c23c7b 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx @@ -1,7 +1,8 @@ "use client"; import { AppBskyFeedDefs } from "@atproto/api"; import { preload } from "swr"; -import { openPage, OpenPage } from "./postPageState"; +import { OpenPage } from "./postPageState"; +import { useOpenThread } from "./Interactions/drawerThreadContext"; type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; type NotFoundPost = AppBskyFeedDefs.NotFoundPost; @@ -62,12 +63,13 @@ export function ThreadLink(props: { onClick?: (e: React.MouseEvent) => void; }) { const { postUri, parent, children, className, onClick } = props; + const openThread = useOpenThread(); const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(e); if (e.defaultPrevented) return; - openPage(parent, { type: "thread", uri: postUri }); + openThread(parent, { type: "thread", uri: postUri }); }; const handlePrefetch = () => { @@ -95,12 +97,13 @@ export function QuotesLink(props: { onClick?: (e: React.MouseEvent) => void; }) { const { postUri, parent, children, className, onClick } = props; + const openThread = useOpenThread(); const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(e); if (e.defaultPrevented) return; - openPage(parent, { type: "quotes", uri: postUri }); + openThread(parent, { type: "quotes", uri: postUri }); }; const handlePrefetch = () => { diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx index 9c044030..f439dbbe 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -10,10 +10,8 @@ import { useDocument } from "contexts/DocumentContext"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AppBskyFeedDefs } from "@atproto/api"; -import { - InteractionDrawer, - useDrawerOpen, -} from "./Interactions/InteractionDrawer"; +import { InteractionDrawer } from "./Interactions/InteractionDrawer"; +import { useDrawerOpen } from "./Interactions/useDrawerOpen"; import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; import { PageOptionButton } from "components/Pages/PageOptions"; import { CloseTiny } from "components/Icons/CloseTiny"; @@ -22,8 +20,6 @@ import { PollData } from "./fetchPollData"; import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; -import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; -import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { type OpenPage, @@ -203,46 +199,6 @@ export function PostPages({ {openPageIds.map((openPage, openPageIndex) => { const pageKey = getPageKey(openPage); - // Handle thread pages - if (openPage.type === "thread") { - return ( - - - closePage(openPage)} - hasPageBackground={hasPageBackground} - /> - } - /> - - ); - } - - // Handle quotes pages - if (openPage.type === "quotes") { - return ( - - - closePage(openPage)} - hasPageBackground={hasPageBackground} - /> - } - /> - - ); - } - // Handle iframe pages if (openPage.type === "iframe") { return ( @@ -264,6 +220,10 @@ export function PostPages({ ); } + // Only document pages can be opened now; thread/quotes views render in + // the interaction drawer rather than as their own pages. + if (openPage.type !== "doc") return null; + // Handle document pages let page = pages.find( (p) => diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index d4d77b0e..02ad3b3b 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -150,6 +150,7 @@ export async function PublicationPageRenderer({ pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), }, commentsCount: 0, + commentsCountByPage: {}, mentions: [], leafletId: null, recommendsCount: 0, diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx index 4005c0ee..86cb695a 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { AppBskyFeedDefs, AppBskyFeedPost, @@ -8,33 +8,29 @@ import { } from "@atproto/api"; import { AtUri } from "@atproto/syntax"; import useSWR from "swr"; -import { PageWrapper } from "components/Pages/Page"; -import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { DrawerThreadContext } from "./Interactions/drawerThreadContext"; import { DotLoader } from "components/utils/DotLoader"; import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { useThreadState } from "src/useThreadState"; -import { - BskyPostContent, - CompactBskyPostContent, - ClientDate, -} from "./BskyPostContent"; +import { BskyPostContent, CompactBskyPostContent } from "./BskyPostContent"; import { ThreadLink, getThreadKey, fetchThread, - prefetchThread, + getQuotesKey, + fetchQuotes, } from "./PostLinks"; +import { Tabs } from "components/Tabs"; +import { CollapsibleReplies } from "components/CollapsibleReplies"; import { useDocument } from "contexts/DocumentContext"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; import { QuoteContent } from "./Interactions/Quotes"; import { decodeQuotePosition, + getDocumentUrls, + matchDocumentUrl, type QuotePosition, } from "./quotePosition"; -// Re-export for backwards compatibility -export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; - type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; type NotFoundPost = AppBskyFeedDefs.NotFoundPost; type BlockedPost = AppBskyFeedDefs.BlockedPost; @@ -69,39 +65,6 @@ function flattenSameAuthorChain( return chain; } -// Check if a URL matches any of the document's known URLs, -// and extract the quote position if present -function matchDocumentUrl( - uri: string, - documentUrls: string[], -): { url: string; quotePosition: QuotePosition | null } | null { - try { - const url = new URL(uri); - const parts = url.pathname.split("/l-quote/"); - const pathWithoutQuote = parts[0]; - const quoteParam = parts[1]; - const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace( - /\/$/, - "", - ); - - for (const docUrl of documentUrls) { - const normalized = docUrl.replace(/\/$/, ""); - if (fullUrlWithoutQuote === normalized) { - return { - url: uri, - quotePosition: quoteParam - ? decodeQuotePosition(quoteParam) - : null, - }; - } - } - } catch { - return null; - } - return null; -} - // Scan a post's facets and embed for links to the current document function findDocumentQuoteLink( post: AppBskyFeedDefs.PostView, @@ -129,25 +92,21 @@ function findDocumentQuoteLink( // Check external embed URI if (post.embed && AppBskyEmbedExternal.isView(post.embed)) { - const match = matchDocumentUrl( - post.embed.external.uri, - documentUrls, - ); + const match = matchDocumentUrl(post.embed.external.uri, documentUrls); if (match) return { ...match, isEmbed: true }; } return null; } -export function ThreadPage(props: { +// Fetches a thread and renders its content (loading/error states included). +// Used both as a standalone page and inside the interaction drawer. `initialTab` +// selects whether replies or quote posts are shown first (defaults to replies). +export function ThreadView(props: { parentUri: string; - pageId: string; - pageOptions?: React.ReactNode; - hasPageBackground: boolean; + initialTab?: "replies" | "quotes"; }) { - const { parentUri, pageId, pageOptions } = props; - const drawer = useDrawerOpen(parentUri); - + const { parentUri, initialTab } = props; const { data: thread, isLoading, @@ -156,36 +115,38 @@ export function ThreadPage(props: { fetchThread(parentUri), ); - return ( - -
- {isLoading ? ( -
- loading thread - -
- ) : error ? ( -
- Failed to load thread -
- ) : thread ? ( - - ) : null} + if (isLoading) { + return ( +
+ loading thread +
- + ); + } + if (error) { + return ( +
+ Failed to load thread +
+ ); + } + if (!thread) return null; + return ( + ); } -function ThreadContent(props: { post: ThreadType; parentUri: string }) { +function ThreadContent(props: { + post: ThreadType; + parentUri: string; + initialTab?: "replies" | "quotes"; +}) { const { post, parentUri } = props; const mainPostRef = useRef(null); + // Inside the interaction drawer the header (back/close) shares the scroll + // container, so we let the drawer handle scroll position instead of pulling + // the main post to the top (which would hide the header). + const inDrawer = useContext(DrawerThreadContext) !== null; // Compute document URLs for leaflet link detection const { @@ -196,38 +157,21 @@ function ThreadContent(props: { post: ThreadType; parentUri: string }) { const docAtUri = useMemo(() => new AtUri(docUri), [docUri]); const docDid = docAtUri.host; - const documentUrls = useMemo(() => { - const urls: string[] = []; - const canonicalUrl = getDocumentURL( - normalizedDocument, - docUri, - normalizedPublication, - ); - if (canonicalUrl.startsWith("http")) { - urls.push(canonicalUrl); - } else { - urls.push(`https://leaflet.pub${canonicalUrl}`); - } - urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`); - if ( - normalizedDocument.site && - normalizedDocument.site.startsWith("http") - ) { - const path = normalizedDocument.path || "/" + docAtUri.rkey; - urls.push(normalizedDocument.site + path); - } - return urls; - }, [docUri, docAtUri, normalizedDocument, normalizedPublication]); + const documentUrls = useMemo( + () => getDocumentUrls(normalizedDocument, docUri, normalizedPublication), + [docUri, normalizedDocument, normalizedPublication], + ); // Scroll the main post into view when the thread loads useEffect(() => { + if (inDrawer) return; if (mainPostRef.current) { mainPostRef.current.scrollIntoView({ behavior: "instant", block: "start", }); } - }, []); + }, [inDrawer]); if (AppBskyFeedDefs.isNotFoundPost(post)) { return ; @@ -274,21 +218,139 @@ function ThreadContent(props: { post: ThreadType; parentUri: string }) {
- {/* Replies */} - {post.replies && post.replies.length > 0 && ( -
- + {/* Replies and quote posts */} + +
+ ); +} + +// Tabbed section under the main post showing its replies and quote posts. +// When only one of the two has content, a header is shown instead of tabs. +function ThreadInteractions(props: { + post: ThreadViewPost; + rootAuthorDid: string; + documentUrls: string[]; + docDid: string; + initialTab?: "replies" | "quotes"; +}) { + const { post, rootAuthorDid, documentUrls, docDid } = props; + + const replies = (post.replies as any[]) ?? []; + const replyCount = post.post.replyCount ?? replies.length; + const quoteCount = post.post.quoteCount ?? 0; + const hasReplies = replies.length > 0; + const hasQuotes = quoteCount > 0; + const showTabs = hasReplies && hasQuotes; + + const [activeTab, setActiveTab] = useState<"replies" | "quotes">( + props.initialTab ?? (hasReplies ? "replies" : "quotes"), + ); + + if (!hasReplies && !hasQuotes) return null; + + // Default to whichever tab actually has content + const tab = !hasReplies ? "quotes" : !hasQuotes ? "replies" : activeTab; + + return ( +
+ {showTabs ? ( + setActiveTab(value)} + options={[ + { value: "replies", label: `Replies (${replyCount})` }, + { value: "quotes", label: `Quote Posts (${quoteCount})` }, + ]} + /> + ) : ( +
+ {hasReplies + ? `Replies (${replyCount})` + : `Quote Posts (${quoteCount})`} +
)} + + {tab === "replies" ? ( + + ) : ( + + )} +
+ ); +} + +// Fetches and renders the posts that quote the main post +function ThreadQuotes(props: { postUri: string; pageUri: string }) { + const { + data: quotesData, + isLoading, + error, + } = useSWR(getQuotesKey(props.postUri), () => fetchQuotes(props.postUri)); + + if (isLoading) { + return ( +
+ loading quotes + +
+ ); + } + + if (error) { + return ( +
+ Failed to load quotes +
+ ); + } + + if (!quotesData || quotesData.posts.length === 0) { + return ( +
+ No quotes yet +
+ ); + } + + return ( +
+ {quotesData.posts.map((post, index) => { + const parent = { type: "thread" as const, uri: props.pageUri }; + // let isPinnedPost = post.uri === + return ( + <> + + +
+ + ); + })}
); } @@ -310,6 +372,7 @@ function ThreadPost(props: { parent={page} avatarSize="large" showBlueskyLink={true} + showInteractions={false} showEmbed={true} compactEmbed quoteEnabled @@ -353,9 +416,6 @@ function Replies(props: { documentUrls, docDid, } = props; - const collapsedThreads = useThreadState((s) => s.collapsedThreads); - const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); - // Sort replies so that replies from the parent author come first const sortedReplies = useMemo( () => @@ -376,7 +436,11 @@ function Replies(props: { ); return ( -
+
{sortedReplies.map((reply, index) => { if (AppBskyFeedDefs.isNotFoundPost(reply)) { return ( @@ -405,46 +469,79 @@ function Replies(props: { } const hasReplies = reply.replies && reply.replies.length > 0; - const isCollapsed = collapsedThreads.has(reply.post.uri); return ( - + <> + + {props.depth === 0 && ( +
+ )} + ); })}
); } +// Wraps a nested reply list in the same indented thread-line + collapse +// affordance used by document comment replies (Interactions/Comments), and +// animates its height when it opens/closes so threads collapse with the same +// motion as comments. +function NestedReplies(props: { + open: boolean; + onCollapse: () => void; + children: React.ReactNode; +}) { + return ( + +
+ {/* the thread line itself is non-interactive; a transparent button is + overlaid on top of it (z-10) so clicking the line collapses the + thread. The button has to sit over the line (left-[28px]) rather + than in the empty gutter, otherwise clicks land on the post's + absolute-inset overlay underneath and open the thread instead. */} +
+
+ + ); +} + const ReplyPost = (props: { post: ThreadViewPost; isLast: boolean; pageUri: string; - parentPostUri: string; - toggleCollapsed: (uri: string) => void; - isCollapsed: boolean; depth: number; rootAuthorDid: string; documentUrls: string[]; docDid: string; }) => { - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props; + const { post, pageUri, rootAuthorDid, documentUrls, docDid } = props; + const collapsedThreads = useThreadState((s) => s.collapsedThreads); + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); // Flatten same-author chains const chain = flattenSameAuthorChain(post, rootAuthorDid); const lastInChain = chain[chain.length - 1]; const hasReplies = lastInChain.replies && lastInChain.replies.length > 0; + const isCollapsed = collapsedThreads.has(lastInChain.post.uri); const isTruncated = !hasReplies && lastInChain.post.replyCount != null && @@ -452,95 +549,51 @@ const ReplyPost = (props: { return (
- {props.depth > 0 && ( - <> -
-
-
- )} - {!props.showComments || props.commentsCount === 0 ? null : ( - - {props.commentsCount} - + {discussionsAvailable && !drawerNav && ( + )} {tagsCount === 0 ? null : ( <> diff --git a/components/MobileSheet.tsx b/components/MobileSheet.tsx new file mode 100644 index 00000000..2aca48bb --- /dev/null +++ b/components/MobileSheet.tsx @@ -0,0 +1,100 @@ +"use client"; +import * as Dialog from "@radix-ui/react-dialog"; +import React from "react"; +import { isIOS } from "src/utils/isDevice"; +import { CloseTiny } from "./Icons/CloseTiny"; +import { useVisualViewport } from "./ViewportSizeLayout"; + +// A mobile drawer sheet that slides up from the bottom of the screen. Built on +// Radix Dialog so it traps focus, handles the escape key, and animates open and +// closed via the .bottom-sheet-content keyframes in globals.css. +export const MobileSheet = ({ + className, + open, + onOpenChange, + asChild, + trigger, + title, + id, + contentRef, + children, +}: { + className?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + asChild?: boolean; + trigger?: React.ReactNode; + title?: React.ReactNode; + // Forwarded to the scrolling content container so callers can scroll it into + // view or reset its scroll position. + id?: string; + contentRef?: React.Ref; + children: React.ReactNode; +}) => { + let { height, offsetTop, difference } = useVisualViewport(); + // iOS keyboard open: the layout viewport (and dvh) don't shrink for the + // keyboard, so a bottom-anchored sheet would sit behind it. Lift the sheet to + // the top of the keyboard and shrink it to the visual viewport. Android + // resizes the layout viewport via interactiveWidget: "resizes-content", so + // bottom-0 + dvh already follow the keyboard there. + let keyboardOpen = isIOS() && difference !== 0 && height > 0; + return ( + + {trigger !== undefined && ( + {trigger} + )} + + + +
+ {/* When a title is given the sheet supplies its own header + close + button; otherwise the children are expected to render their own + chrome. Radix still requires a Dialog.Title for accessibility. */} + {title ? ( +
+
+ +

{title}

+
+
+ + + +
+ ) : ( + + )} + +
+ {children} +
+
+ +
+ + + + ); +}; diff --git a/components/Modal.tsx b/components/Modal.tsx index 99fe3242..d8d1504d 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -38,7 +38,7 @@ export const Modal = ({ ? { top: `${offsetTop}px`, height: `${height}px` } : undefined } - className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" + className="fixed z-50 inset-0 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-overlayShow" /> {title ? ( diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index f783963e..b8b0c10e 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -16,7 +16,7 @@ import { useCardBorderHidden } from "./useCardBorderHidden"; import { focusPage } from "src/utils/focusPage"; import { PageOptions } from "./PageOptions"; import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; +import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen"; import { usePreserveScroll } from "src/hooks/usePreserveScroll"; import { usePageFootnotes } from "components/Footnotes/usePageFootnotes"; import { FootnoteContext } from "components/Footnotes/FootnoteContext"; diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index 1a10e1ad..f095713c 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -13,7 +13,6 @@ import { useSubscribe } from "src/replicache/useSubscribe"; import { useEntitySetContext } from "components/EntitySetProvider"; import { timeAgo } from "src/utils/timeAgo"; import { CommentTiny } from "components/Icons/CommentTiny"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; import { TagTiny } from "components/Icons/TagTiny"; import { Popover } from "components/Popover"; import { TagSelector } from "components/Tags"; @@ -139,12 +138,8 @@ export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
)} - {merged.showMentions !== false && ( -
- — -
- )} - {merged.showComments !== false && ( + {(merged.showComments !== false || + merged.showMentions !== false) && (
—
diff --git a/components/PostListing.tsx b/components/PostListing.tsx index c6d967cc..63a9c0d4 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -17,7 +17,6 @@ import { TagPopover } from "./InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useSmoker } from "./Toast"; import { CommentTiny } from "./Icons/CommentTiny"; -import { QuoteTiny } from "./Icons/QuoteTiny"; import { ShareTiny } from "./Icons/ShareTiny"; import { useSelectedPostListing } from "src/useSelectedPostState"; import { mergePreferences } from "src/utils/mergePreferences"; @@ -25,6 +24,7 @@ import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; import { RecommendButton } from "./RecommendButton"; import { getFirstParagraph } from "src/utils/getFirstParagraph"; +import { DiscussionModal } from "./DiscussionModal"; export const PostListing = (props: Post & { selected?: boolean }) => { let pubRecord = props.publication?.pubRecord as @@ -245,15 +245,24 @@ const Interactions = (props: { let setSelectedPostListing = useSelectedPostListing( (s) => s.setSelectedPostListing, ); - let selectPostListing = (drawer: "quotes" | "comments") => { + let [discussionsOpen, setDiscussionsOpen] = useState(false); + let defaultDrawer: "comments" | "quotes" = + props.showComments && props.commentsCount > 0 ? "comments" : "quotes"; + let openDiscussions = () => { + // Keep the listing highlighted (read by the reader feed) while the modal is up. setSelectedPostListing({ document_uri: props.documentUri, document: props.document, publication: props.publication, - drawer, + drawer: defaultDrawer, }); + setDiscussionsOpen(true); }; + let commentsAvailable = props.showComments && props.commentsCount > 0; + let mentionsAvailable = props.showMentions && props.quotesCount > 0; + let discussionsAvailable = commentsAvailable || mentionsAvailable; + return (
- {!props.showMentions || props.quotesCount === 0 ? null : ( + {!discussionsAvailable ? null : ( - )} - {!props.showComments || props.commentsCount === 0 ? null : ( - )}
+ {discussionsAvailable && ( + { + setDiscussionsOpen(open); + if (!open) setSelectedPostListing(null); + }} + document_uri={props.documentUri} + postUrl={props.postUrl} + title={props.document.title} + commentsCount={props.commentsCount} + quotesCount={props.quotesCount} + showComments={props.showComments} + showMentions={props.showMentions} + /> + )}
); }; diff --git a/components/Tabs.tsx b/components/Tabs.tsx new file mode 100644 index 00000000..e8a9a850 --- /dev/null +++ b/components/Tabs.tsx @@ -0,0 +1,41 @@ +import { type ReactNode } from "react"; + +export function Tabs(props: { + value: T; + onChange: (value: T, e?: React.MouseEvent) => void; + options: { value: T; label: ReactNode }[]; + className?: string; + optionClassName?: string; + selectedOptionClassName?: string; +}) { + return ( +
+
+ {props.options.map((option) => { + const selected = props.value === option.value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/components/ToggleGroup.tsx b/components/ToggleGroup.tsx index b27f738d..f5cc901e 100644 --- a/components/ToggleGroup.tsx +++ b/components/ToggleGroup.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from "react"; export function ToggleGroup(props: { value: T; - onChange: (value: T) => void; + onChange: (value: T, e?: React.MouseEvent) => void; options: { value: T; label: ReactNode }[]; className?: string; optionClassName?: string; diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx index 3271542b..89a9f62e 100644 --- a/contexts/DocumentContext.tsx +++ b/contexts/DocumentContext.tsx @@ -18,6 +18,7 @@ export type DocumentContextValue = Pick< | "quotesAndMentions" | "publication" | "commentsCount" + | "commentsCountByPage" | "mentions" | "leafletId" | "recommendsCount" -- 2.51.2