From fc10476ade7dbe7c27e2d5e5b346f53472d0cece Mon Sep 17 00:00:00 2001 From: celine Date: Mon, 1 Jun 2026 00:46:03 -0400 Subject: [PATCH] standard site posts open in interaction drawer on posts --- .../[publication]/PublicationPostItem.tsx | 2 +- .../[rkey]/Interactions/InteractionDrawer.tsx | 79 ++++++++- .../[rkey]/Interactions/Quotes.tsx | 7 +- .../StandardSitePostDrawerView.tsx | 64 +++++++ .../Interactions/drawerThreadContext.tsx | 9 +- .../Interactions/useDocumentDiscussionData.ts | 75 +++++++++ .../[publication]/[rkey]/PostContent.tsx | 30 ++-- .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 2 +- .../StandardSitePostItem.tsx | 22 ++- components/DiscussionModal.tsx | 157 ++++++------------ components/InteractionsPreview.tsx | 16 +- 11 files changed, 325 insertions(+), 138 deletions(-) create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx create mode 100644 app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx b/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx index 05ac204e..854c3bec 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx +++ b/app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx @@ -129,7 +129,7 @@ export function PublicationPostItemMedium(props: MediumProps) { {props.coverImageAlt )} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index ba77e57a..0d095cbd 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -14,10 +14,12 @@ 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 } from "react"; +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"; export const InteractionDrawer = (props: { showPageBackground: boolean | undefined; @@ -37,6 +39,24 @@ export const InteractionDrawer = (props: { }), [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]); + // 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); @@ -45,6 +65,16 @@ export const InteractionDrawer = (props: { }, [threadStack.length]); if (!drawer) return null; + 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); @@ -66,10 +96,6 @@ export const InteractionDrawer = (props: { if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; - // 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]; - return ( <> @@ -81,7 +107,44 @@ export const InteractionDrawer = (props: { >
- {activeThread ? ( + {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})`} +
+ ), + }, + ]} + /> + ) : ( +

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

+ ) + ) : activeThread ? (
{threadStack.length >= 2 && (
- {activeThread ? ( + {sspUri ? ( + + ) : activeThread ? (
-
- -
+
+
+ +
+
+ +
+ + {!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 index 3145dbdc..058dba5a 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx @@ -4,9 +4,12 @@ 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: "quotes"; uri: string } + | { type: "standardSitePost"; uri: string }; type DrawerThreadNav = { push: (thread: DrawerThread) => void; @@ -24,7 +27,9 @@ export function useOpenThread() { const drawerNav = useContext(DrawerThreadContext); return (parent: OpenPage | undefined, thread: DrawerThread) => { if (drawerNav) drawerNav.push(thread); - else openPage(parent, 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); }; } 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..1299b0be --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts @@ -0,0 +1,75 @@ +"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) ?? []) : []; + + // 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, + 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]/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/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index 4749f791..fa9860d1 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -29,7 +29,7 @@ export const BlueskyEmbed = (props: { case AppBskyEmbedImages.isView(props.embed): let imageEmbed = props.embed; return ( -
+
{imageEmbed.images.map( ( image: { diff --git a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx index c388fb19..93e2dac1 100644 --- a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx +++ b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx @@ -26,18 +26,25 @@ export function StandardSitePostItem({ uri, size = "medium", currentPublicationUri, + pageWidth, + hideInteractions, }: { uri: string; size?: StandardSitePostSize; currentPublicationUri?: string | null; + pageWidth?: number; + hideInteractions?: boolean; }) { const { data, isLoading } = useStandardSitePost(uri); const { rootEntity } = useReplicache(); - const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; + const postPageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; if (isLoading) { return ( - + ); } @@ -49,7 +56,9 @@ export function StandardSitePostItem({ ); } @@ -155,10 +164,14 @@ export function StandardSitePostItemView({ post, size = "medium", currentPublicationUri, + hideInteractions, + pageWidth: pageWidthProp, }: { post: StandardSitePostData; size?: StandardSitePostSize; currentPublicationUri?: string | null; + hideInteractions?: boolean; + pageWidth?: number; }) { const docUrl = getDocumentURL( post.record, @@ -188,7 +201,8 @@ export function StandardSitePostItemView({ : undefined; const { rootEntity } = useReplicache(); - const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; + const themePageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; + const pageWidth = pageWidthProp ?? themePageWidth; const publicationPrefs = post.publication?.record?.preferences; const showComments = publicationPrefs?.showComments !== false; @@ -196,7 +210,7 @@ export function StandardSitePostItemView({ const showRecommends = publicationPrefs?.showRecommends !== false; const commentsCount = showComments ? post.commentsCount : 0; - const interactions = ( + const interactions = hideInteractions ? undefined : ( 0; const mentionsAvailable = props.showMentions && props.quotesCount > 0; const bothAvailable = commentsAvailable && mentionsAvailable; @@ -66,15 +44,8 @@ export function DiscussionModal(props: { 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; + const { isLoading, data, did, pages, documentContextValue, comments } = + useDocumentDiscussionData(props.document_uri, props.open); // Restrict mentions to the page this modal is about (mirrors InteractionDrawer). const quotesAndMentions = (data?.quotesAndMentions ?? []).filter((q) => { @@ -86,89 +57,65 @@ export function DiscussionModal(props: { 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"} -
+
+ +
+
+
+ {bothAvailable ? ( + setTab(value)} + options={[ + { + value: "comments", + label: + props.commentsCount > 0 + ? `Comments (${props.commentsCount})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky Mentions{" "} + {props.quotesCount > 0 && `(${props.quotesCount})`} +
+ ), + }, + ]} + /> + ) : ( +
+ {commentsAvailable + ? `Comments (${props.commentsCount})` + : `Bluesky Mentions (${props.quotesCount})`} +
+ )}{" "} - + 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 @@ -180,7 +127,7 @@ export function DiscussionModal(props: { {tab === "comments" ? ( ) : ( diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx index 3b52e25b..dae753eb 100644 --- a/components/InteractionsPreview.tsx +++ b/components/InteractionsPreview.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from "react"; +import { useContext, useState } from "react"; import { Separator } from "./Layout"; import { CommentTiny } from "./Icons/CommentTiny"; import { useSmoker } from "./Toast"; @@ -8,6 +8,7 @@ import { Popover } from "./Popover"; import { TagTiny } from "./Icons/TagTiny"; import { RecommendButton } from "./RecommendButton"; import { DiscussionModal } from "./DiscussionModal"; +import { DrawerThreadContext } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext"; export const InteractionPreview = (props: { quotesCount: number; @@ -24,6 +25,10 @@ export const InteractionPreview = (props: { share?: boolean; }) => { let smoker = useSmoker(); + // Inside a published post body a DrawerThreadContext is in scope; there we + // open this post's discussion in the interaction drawer (like a Bluesky post's + // thread) instead of the standalone modal used in listings/feeds. + let drawerNav = useContext(DrawerThreadContext); let [discussionsOpen, setDiscussionsOpen] = useState(false); let commentsAvailable = props.showComments !== false && props.commentsCount > 0; let mentionsAvailable = props.showMentions && props.quotesCount > 0; @@ -49,14 +54,19 @@ export const InteractionPreview = (props: { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setDiscussionsOpen(true); + if (drawerNav) + drawerNav.push({ + type: "standardSitePost", + uri: props.documentUri, + }); + else setDiscussionsOpen(true); }} className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" > {props.commentsCount + props.quotesCount} )} - {discussionsAvailable && ( + {discussionsAvailable && !drawerNav && (