···1818import { RecommendButton } from "components/RecommendButton";
1919import { ButtonSecondary } from "components/Buttons";
2020import { Separator } from "components/Layout";
2121+import type { DrawerThread } from "./drawerThreadContext";
21222223export type InteractionState = {
2324 drawerOpen: undefined | boolean;
···2526 drawer: undefined | "comments" | "quotes";
2627 localComments: Comment[];
2728 commentBox: { quote: QuotePosition | null };
2929+ // Thread/quotes views opened within the drawer, innermost last. When
3030+ // non-empty the drawer shows the top entry instead of the comments/mentions
3131+ // tabs, with a back button to work up the tree.
3232+ threadStack: DrawerThread[];
2833};
29343035const defaultInteractionState: InteractionState = {
···3237 drawer: undefined,
3338 localComments: [],
3439 commentBox: { quote: null },
4040+ threadStack: [],
3541};
36423743export let useInteractionStateStore = create<{
···97103 pageId?: string,
98104) {
99105 flushSync(() => {
100100- setInteractionState(document_uri, { drawerOpen: true, drawer, pageId });
106106+ setInteractionState(document_uri, {
107107+ drawerOpen: true,
108108+ drawer,
109109+ pageId,
110110+ threadStack: [],
111111+ });
101112 });
102113 scrollIntoView("interaction-drawer");
114114+}
115115+116116+// Open the drawer straight onto a thread/quotes view. Used when a Bluesky post
117117+// in the document body is clicked, so its thread opens in the drawer instead of
118118+// a new page (mirroring how the post's own comments/mentions open the drawer).
119119+export function openDrawerThread(
120120+ document_uri: string,
121121+ thread: DrawerThread,
122122+ pageId?: string,
123123+) {
124124+ flushSync(() => {
125125+ setInteractionState(document_uri, (s) => ({
126126+ drawerOpen: true,
127127+ drawer: s.drawer ?? "comments",
128128+ pageId,
129129+ threadStack: [thread],
130130+ }));
131131+ });
132132+ scrollIntoView("interaction-drawer");
133133+}
134134+135135+// Open a thread/quotes view inside the drawer, replacing its content. Clicking
136136+// the view you're already on (e.g. the main post of the current thread) is a
137137+// no-op rather than stacking a duplicate.
138138+export function pushDrawerThread(document_uri: string, thread: DrawerThread) {
139139+ setInteractionState(document_uri, (s) => {
140140+ const top = s.threadStack[s.threadStack.length - 1];
141141+ if (top && top.type === thread.type && top.uri === thread.uri) return {};
142142+ return { threadStack: [...s.threadStack, thread] };
143143+ });
144144+}
145145+146146+// Step back up the drawer's thread navigation tree.
147147+export function popDrawerThread(document_uri: string) {
148148+ setInteractionState(document_uri, (s) => ({
149149+ threadStack: s.threadStack.slice(0, -1),
150150+ }));
151151+}
152152+153153+// Jump all the way back out of the thread navigation, to the drawer's top
154154+// level (the comments/mentions tabs).
155155+export function popDrawerThreadToRoot(document_uri: string) {
156156+ setInteractionState(document_uri, { threadStack: [] });
103157}
104158105159export const Interactions = (props: {
···11+"use client";
22+import { createContext, useContext, useMemo } from "react";
33+import { OpenPage, openPage } from "../postPageState";
44+import { openDrawerThread } from "./Interactions";
55+66+// A thread or quotes view that can be shown inside the interaction drawer.
77+export type DrawerThread =
88+ | { type: "thread"; uri: string }
99+ | { type: "quotes"; uri: string };
1010+1111+type DrawerThreadNav = {
1212+ push: (thread: DrawerThread) => void;
1313+};
1414+1515+// Set by the InteractionDrawer (to navigate within the drawer) and by the
1616+// document page (to open the drawer onto a thread). When present, thread/quotes
1717+// links replace the drawer's content instead of opening a new page.
1818+export const DrawerThreadContext = createContext<DrawerThreadNav | null>(null);
1919+2020+// Returns a function that opens a thread or quotes view. When a drawer-aware
2121+// provider is in scope it navigates within / opens the drawer; elsewhere it
2222+// falls back to opening a new page.
2323+export function useOpenThread() {
2424+ const drawerNav = useContext(DrawerThreadContext);
2525+ return (parent: OpenPage | undefined, thread: DrawerThread) => {
2626+ if (drawerNav) drawerNav.push(thread);
2727+ else openPage(parent, thread);
2828+ };
2929+}
3030+3131+// Wraps document-body content so Bluesky posts within it open their thread in
3232+// the interaction drawer (onto a fresh stack) rather than in a new page.
3333+export function DrawerThreadPageProvider(props: {
3434+ document_uri: string;
3535+ pageId?: string;
3636+ children: React.ReactNode;
3737+}) {
3838+ const value = useMemo(
3939+ () => ({
4040+ push: (thread: DrawerThread) =>
4141+ openDrawerThread(props.document_uri, thread, props.pageId),
4242+ }),
4343+ [props.document_uri, props.pageId],
4444+ );
4545+ return (
4646+ <DrawerThreadContext.Provider value={value}>
4747+ {props.children}
4848+ </DrawerThreadContext.Provider>
4949+ );
5050+}