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]/PublicationPostsList.tsx b/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx index 245b6e89..cf876b7c 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/PublishedPageBlock.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index 201541d2..25efe8ee 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -2,7 +2,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; -import { CSSProperties, useRef } from "react"; +import { CSSProperties, useRef, useState } from "react"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { PostContent, Block } from "../PostContent"; import { @@ -18,13 +18,10 @@ 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 { CommentTiny } from "components/Icons/CommentTiny"; import { CanvasBackgroundPattern } from "components/Canvas"; +import { DiscussionModal } from "components/DiscussionModal"; +import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; export function PublishedPageLinkBlock(props: { blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; @@ -207,42 +204,52 @@ export function PagePreview(props: { } const Interactions = (props: { pageId: string; parentPageId?: string }) => { - const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); + const { + uri: document_uri, + commentsCount: comments, + mentions, + normalizedDocument, + normalizedPublication, + } = useDocument(); let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); - - let defaultDrawer: "comments" | "quotes" = comments > 0 ? "comments" : "quotes"; + let [discussionsOpen, setDiscussionsOpen] = useState(false); + let postUrl = `${getDocumentURL( + normalizedDocument, + document_uri, + normalizedPublication, + )}?page=${props.pageId}`; return (
{quotes + comments > 0 && ( - + <> + + + )}
); 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 e1249170..84561929 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -31,9 +31,15 @@ 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, diff --git a/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index ea8cfc3d..0cef45d1 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -143,6 +143,7 @@ function PublishedPostItem(props: { recommendsCount={doc.recommendsCount} documentUri={doc.uri} tags={doc.record.tags || []} + title={doc.record.title} showComments={pubRecord?.preferences?.showComments !== false} showMentions={pubRecord?.preferences?.showMentions !== false} showRecommends={pubRecord?.preferences?.showRecommends !== false} diff --git a/app/api/rpc/[command]/get_document_interactions.ts b/app/api/rpc/[command]/get_document_interactions.ts index a16d228d..beca44b8 100644 --- a/app/api/rpc/[command]/get_document_interactions.ts +++ b/app/api/rpc/[command]/get_document_interactions.ts @@ -35,7 +35,13 @@ export const get_document_interactions = makeRoute({ .single(); if (!document) { - return { comments: [], quotesAndMentions: [], totalMentionsCount: 0 }; + return { + comments: [], + quotesAndMentions: [], + totalMentionsCount: 0, + document: null, + publication: null, + }; } const normalizedData = normalizeDocumentRecord( @@ -107,6 +113,8 @@ export const get_document_interactions = makeRoute({ comments, quotesAndMentions, totalMentionsCount: quotesAndMentions.length, + document: normalizedData, + publication: normalizedPubRecord, }; }, }); diff --git a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx index 7eb248dc..c388fb19 100644 --- a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx +++ b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx @@ -204,6 +204,7 @@ export function StandardSitePostItemView({ documentUri={post.uri} tags={post.record.tags || []} postUrl={docUrl} + title={post.record.title} showComments={showComments} showMentions={showMentions} showRecommends={showRecommends} diff --git a/components/DiscussionModal.tsx b/components/DiscussionModal.tsx new file mode 100644 index 00000000..e76c931d --- /dev/null +++ b/components/DiscussionModal.tsx @@ -0,0 +1,198 @@ +"use client"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { AtUri } from "@atproto/api"; +import { Modal } from "./Modal"; +import { ToggleGroup } from "./ToggleGroup"; +import { SpeedyLink } from "./SpeedyLink"; +import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; +import { DotLoader } from "./utils/DotLoader"; +import { callRPC } from "app/api/rpc/client"; +import { + DocumentProvider, + type DocumentContextValue, +} from "contexts/DocumentContext"; +import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { + getDocumentPages, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { + CommentsDrawerContent, + type Comment, +} from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments"; +import { MentionsDrawerContent } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes"; +import { decodeQuotePosition } from "app/(app)/lish/[did]/[publication]/[rkey]/quotePosition"; +import { GoToArrow } from "./Icons/GoToArrow"; +import { ButtonPrimary } from "./Buttons"; + +type InteractionsData = { + comments: Comment[]; + quotesAndMentions: { uri: string; link?: string }[]; + document: NormalizedDocument | null; + publication: NormalizedPublication | null; +}; + +// A self-contained modal that renders a post's comments and Bluesky mentions +// with a toggle between them. Used from post listings, the reader feed, and +// page-link blocks where the full post page (and its interaction drawer) isn't +// mounted. It fetches everything it needs from get_document_interactions and +// provides the Document/LeafletContent contexts that the shared drawer content +// expects, so the post page's own drawer (#3) can stay untouched. +export function DiscussionModal(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + document_uri: string; + postUrl: string; + title?: string; + commentsCount: number; + quotesCount: number; + showComments: boolean; + showMentions: boolean; + pageId?: string; +}) { + const did = new AtUri(props.document_uri).host; + + const commentsAvailable = props.showComments && props.commentsCount > 0; + const mentionsAvailable = props.showMentions && props.quotesCount > 0; + const bothAvailable = commentsAvailable && mentionsAvailable; + + const [tab, setTab] = useState<"comments" | "quotes">( + commentsAvailable ? "comments" : "quotes", + ); + // Reset to the most relevant tab each time the modal is opened. + useEffect(() => { + if (props.open) setTab(commentsAvailable ? "comments" : "quotes"); + }, [props.open]); + + const swr = useSWR( + props.open ? ["doc_interactions", props.document_uri] : null, + () => + callRPC("get_document_interactions", { + document_uri: props.document_uri, + }), + ); + const data = swr.data as unknown as InteractionsData | undefined; + const isLoading = swr.isLoading; + + // Restrict mentions to the page this modal is about (mirrors InteractionDrawer). + const quotesAndMentions = (data?.quotesAndMentions ?? []).filter((q) => { + if (!q.link) return !props.pageId; + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return !props.pageId; + const quotePosition = decodeQuotePosition(quoteParam); + return quotePosition?.pageId === props.pageId; + }); + + const documentRecord = data?.document as + | NormalizedDocument + | null + | undefined; + const pages = documentRecord ? getDocumentPages(documentRecord) ?? [] : []; + + // 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: props.document_uri, + normalizedDocument: documentRecord, + normalizedPublication: (data?.publication ?? + null) as NormalizedPublication | null, + theme: null, + prevNext: null, + quotesAndMentions: data?.quotesAndMentions ?? [], + publication: null, + commentsCount: data?.comments.length ?? props.commentsCount, + mentions: [], + leafletId: null, + recommendsCount: 0, + } as unknown as DocumentContextValue) + : null; + + return ( + +
+
+
+ {bothAvailable + ? "Discussion on" + : commentsAvailable + ? "Comments on" + : "Bluesky Mentions about"} +
+ + + Full Post + + +
+

+ {props.title || "Post"} +

+
+ + {bothAvailable && ( +
+ setTab(value)} + options={[ + { + value: "comments", + label: + props.commentsCount > 0 + ? `Comments (${props.commentsCount})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky Mentions{" "} + {props.quotesCount > 0 && `(${props.quotesCount})`} +
+ ), + }, + ]} + /> +
+ )} + +
+ {!data && isLoading ? ( +
+ loading + +
+ ) : documentContextValue ? ( + + + {tab === "comments" ? ( + + ) : ( + + )} + + + ) : null} +
+
+ ); +} diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx index e81a7ec6..3b52e25b 100644 --- a/components/InteractionsPreview.tsx +++ b/components/InteractionsPreview.tsx @@ -1,12 +1,13 @@ "use client"; +import { useState } from "react"; import { Separator } from "./Layout"; import { CommentTiny } from "./Icons/CommentTiny"; import { useSmoker } from "./Toast"; import { Tag } from "./Tags"; import { Popover } from "./Popover"; import { TagTiny } from "./Icons/TagTiny"; -import { SpeedyLink } from "./SpeedyLink"; import { RecommendButton } from "./RecommendButton"; +import { DiscussionModal } from "./DiscussionModal"; export const InteractionPreview = (props: { quotesCount: number; @@ -15,6 +16,7 @@ export const InteractionPreview = (props: { documentUri: string; tags?: string[]; postUrl: string; + title?: string; showComments: boolean; showMentions: boolean; showRecommends: boolean; @@ -22,12 +24,10 @@ export const InteractionPreview = (props: { share?: boolean; }) => { let smoker = useSmoker(); + let [discussionsOpen, setDiscussionsOpen] = useState(false); let commentsAvailable = props.showComments !== false && props.commentsCount > 0; let mentionsAvailable = props.showMentions && props.quotesCount > 0; let discussionsAvailable = commentsAvailable || mentionsAvailable; - let defaultDrawer: "comments" | "quotes" = commentsAvailable - ? "comments" - : "quotes"; let interactionsAvailable = discussionsAvailable || (props.showRecommends !== false && props.recommendsCount > 0); @@ -44,13 +44,30 @@ export const InteractionPreview = (props: { )} {!discussionsAvailable ? null : ( - { + e.preventDefault(); + e.stopPropagation(); + setDiscussionsOpen(true); + }} + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" > {props.commentsCount + props.quotesCount} - + + )} + {discussionsAvailable && ( + )} {tagsCount === 0 ? null : ( <> 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/PostListing.tsx b/components/PostListing.tsx index c56e7296..911978b8 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -24,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 @@ -239,21 +240,23 @@ 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; - let defaultDrawer: "comments" | "quotes" = commentsAvailable - ? "comments" - : "quotes"; return (
selectPostListing(defaultDrawer)} + onClick={openDiscussions} className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" > {props.commentsCount + props.quotesCount} )}
+ {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} + /> + )} ); };