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 3e45e21e..00000000 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx +++ /dev/null @@ -1,97 +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 { 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) => { - const parent = { type: "quotes" as const, uri: postUri }; - return ( - <> - {" "} - {posts.length !== index + 1 && ( -
- )} - - ); - })} -
- ); -} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index 6f1fb5d5..ba1234d2 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -8,7 +8,8 @@ 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"; @@ -52,6 +53,7 @@ export function BskyPostContent(props: { clientHost = "bsky.app", hasQuote, } = props; + const openThread = useOpenThread(); const record = post.record as AppBskyFeedPost.Record; const postId = post.uri.split("/")[4]; @@ -67,7 +69,7 @@ export function BskyPostContent(props: { + )} + + + ) : bothAvailable ? ( - {activeTab === "quotes" ? ( - - ) : ( - props.commentsSlot - )} + + {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 6d4a9f64..4e394947 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -18,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; @@ -25,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 = { @@ -32,6 +37,7 @@ const defaultInteractionState: InteractionState = { drawer: undefined, localComments: [], commentBox: { quote: null }, + threadStack: [], }; export let useInteractionStateStore = create<{ @@ -97,11 +103,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; 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..3145dbdc --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx @@ -0,0 +1,50 @@ +"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. +export type DrawerThread = + | { type: "thread"; uri: string } + | { type: "quotes"; 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); + else 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/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..94db6908 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} /> )} - + + + 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]/ThreadPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx index cf66bbd3..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 { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { AppBskyFeedDefs, AppBskyFeedPost, @@ -8,21 +8,15 @@ 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"; @@ -37,9 +31,6 @@ import { 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; @@ -108,15 +99,14 @@ function findDocumentQuoteLink( 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, @@ -125,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 { @@ -172,13 +164,14 @@ function ThreadContent(props: { post: ThreadType; parentUri: string }) { // 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 ; @@ -231,6 +224,7 @@ function ThreadContent(props: { post: ThreadType; parentUri: string }) { rootAuthorDid={rootAuthorDid} documentUrls={documentUrls} docDid={docDid} + initialTab={props.initialTab} />
); @@ -243,6 +237,7 @@ function ThreadInteractions(props: { rootAuthorDid: string; documentUrls: string[]; docDid: string; + initialTab?: "replies" | "quotes"; }) { const { post, rootAuthorDid, documentUrls, docDid } = props; @@ -254,7 +249,7 @@ function ThreadInteractions(props: { const showTabs = hasReplies && hasQuotes; const [activeTab, setActiveTab] = useState<"replies" | "quotes">( - hasReplies ? "replies" : "quotes", + props.initialTab ?? (hasReplies ? "replies" : "quotes"), ); if (!hasReplies && !hasQuotes) return null; diff --git a/app/globals.css b/app/globals.css index 20c5e32d..7368c344 100644 --- a/app/globals.css +++ b/app/globals.css @@ -37,6 +37,11 @@ rgb(var(--primary)), rgb(var(--bg-page)) 85% ); + --color-bg-light: color-mix( + in oklab, + rgb(var(--primary)), + rgb(var(--bg-page)) 95% + ); --color-white: #ffffff; --color-accent-1: rgb(var(--accent-1)); --color-accent-2: rgb(var(--accent-2)); @@ -497,7 +502,7 @@ pre.shiki { } .light-container { - background: color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%); + background: var(--color-bg-light); @apply border; @apply border-border-light; @apply rounded-md; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index e9859f49..4749f791 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -11,10 +11,8 @@ import { AppBskyLabelerDefs, } from "@atproto/api"; import { Avatar } from "components/Avatar"; -import { - OpenPage, - openPage, -} from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; +import { OpenPage } from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; +import { useOpenThread } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext"; import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; export const BlueskyEmbed = (props: { @@ -24,6 +22,7 @@ export const BlueskyEmbed = (props: { compact?: boolean; parent?: OpenPage; }) => { + const openThread = useOpenThread(); // check this file from bluesky for ref // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx switch (true) { @@ -161,7 +160,7 @@ export const BlueskyEmbed = (props: { e.preventDefault(); e.stopPropagation(); - openPage(props.parent, { type: "thread", uri: record.uri }); + openThread(props.parent, { type: "thread", uri: record.uri }); }} > { + return ( + + + + + ); +}; 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";