- {bothAvailable ? (
+ {activeThread ? (
+
+ {threadStack.length >= 2 && (
+
+ )}
+
+
+ ) : 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";