a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

open threads in the drawer rather than as new pages

celine (May 31, 2026, 11:09 PM EDT) bf56508f d3b30722

+347 -283
-97
app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 1 - "use client"; 2 - import { AppBskyFeedDefs } from "@atproto/api"; 3 - import useSWR from "swr"; 4 - import { PageWrapper } from "components/Pages/Page"; 5 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 - import { DotLoader } from "components/utils/DotLoader"; 7 - 8 - import { BskyPostContent } from "./BskyPostContent"; 9 - import { 10 - QuotesLink, 11 - getQuotesKey, 12 - fetchQuotes, 13 - prefetchQuotes, 14 - } from "./PostLinks"; 15 - 16 - // Re-export for backwards compatibility 17 - export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; 18 - 19 - type PostView = AppBskyFeedDefs.PostView; 20 - 21 - export function BlueskyQuotesPage(props: { 22 - postUri: string; 23 - pageId: string; 24 - pageOptions?: React.ReactNode; 25 - hasPageBackground: boolean; 26 - }) { 27 - const { postUri, pageId, pageOptions } = props; 28 - const drawer = useDrawerOpen(postUri); 29 - 30 - const { 31 - data: quotesData, 32 - isLoading, 33 - error, 34 - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => 35 - fetchQuotes(postUri), 36 - ); 37 - 38 - return ( 39 - <PageWrapper 40 - pageType="doc" 41 - fullPageScroll={false} 42 - id={`post-page-${pageId}`} 43 - drawerOpen={false} 44 - pageOptions={pageOptions} 45 - fixedWidth 46 - > 47 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 48 - <h4 className="text-secondary font-bold mb-2">Bluesky Quotes</h4> 49 - {isLoading ? ( 50 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 51 - <span>loading quotes</span> 52 - <DotLoader /> 53 - </div> 54 - ) : error ? ( 55 - <div className="text-tertiary italic text-sm text-center py-8"> 56 - Failed to load quotes 57 - </div> 58 - ) : quotesData && quotesData.posts.length > 0 ? ( 59 - <QuotesContent posts={quotesData.posts} postUri={postUri} /> 60 - ) : ( 61 - <div className="text-tertiary italic text-sm text-center py-8"> 62 - No quotes yet 63 - </div> 64 - )} 65 - </div> 66 - </PageWrapper> 67 - ); 68 - } 69 - 70 - function QuotesContent(props: { posts: PostView[]; postUri: string }) { 71 - const { posts, postUri } = props; 72 - 73 - return ( 74 - <div className="flex flex-col gap-0 pt-3"> 75 - {posts.map((post, index) => { 76 - const parent = { type: "quotes" as const, uri: postUri }; 77 - return ( 78 - <> 79 - <BskyPostContent 80 - post={post} 81 - parent={parent} 82 - showEmbed={true} 83 - compactEmbed 84 - showBlueskyLink={true} 85 - quoteEnabled 86 - replyEnabled 87 - className="relative rounded text-sm" 88 - />{" "} 89 - {posts.length !== index + 1 && ( 90 - <hr className="border-border-light my-4" /> 91 - )} 92 - </> 93 - ); 94 - })} 95 - </div> 96 - ); 97 - }
+9 -6
app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 8 8 import { Separator } from "components/Layout"; 9 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 - import { OpenPage, openPage } from "./postPageState"; 11 + import { OpenPage } from "./postPageState"; 12 + import { useOpenThread } from "./Interactions/drawerThreadContext"; 12 13 import { ThreadLink, QuotesLink } from "./PostLinks"; 13 14 import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 14 15 import { Avatar } from "components/Avatar"; ··· 52 53 clientHost = "bsky.app", 53 54 hasQuote, 54 55 } = props; 56 + const openThread = useOpenThread(); 55 57 56 58 const record = post.record as AppBskyFeedPost.Record; 57 59 const postId = post.uri.split("/")[4]; ··· 67 69 <button 68 70 className="absolute inset-0" 69 71 onClick={() => { 70 - openPage(parent, { type: "thread", uri: post.uri }); 72 + openThread(parent, { type: "thread", uri: post.uri }); 71 73 }} 72 74 /> 73 75 )} ··· 108 110 createdAt={record.createdAt} 109 111 /> 110 112 111 - <div className={`bskyPostBody flex flex-col min-w-0 w-full pb-1`}> 113 + <div className={`bskyPostBody flex flex-col min-w-0 w-full`}> 112 114 {props.hasQuote && ( 113 115 <QuoteContent 114 116 index={props.hasQuote?.index} ··· 139 141 ((props.post.quoteCount && props.post.quoteCount > 0) || 140 142 (props.post.replyCount && props.post.replyCount > 0))) ? ( 141 143 <div 142 - className={`postCountsAndLink flex gap-2 items-center justify-between pointer-events-auto`} 144 + className={`postCountsAndLink flex gap-2 items-center justify-between pointer-events-auto mt-2`} 143 145 > 144 146 {showInteractions ? ( 145 147 <PostCounts ··· 193 195 replyOnClick, 194 196 clientHost = "bsky.app", 195 197 } = props; 198 + const openThread = useOpenThread(); 196 199 197 200 const record = post.record as AppBskyFeedPost.Record; 198 201 const postId = post.uri.split("/")[4]; ··· 208 211 <button 209 212 className="absolute inset-0 " 210 213 onClick={() => { 211 - openPage(parent, { type: "thread", uri: post.uri }); 214 + openThread(parent, { type: "thread", uri: post.uri }); 212 215 }} 213 216 /> 214 217 )} ··· 224 227 onClick={ 225 228 hasThreadContent 226 229 ? () => { 227 - openPage(parent, { type: "thread", uri: post.uri }); 230 + openThread(parent, { type: "thread", uri: post.uri }); 228 231 } 229 232 : undefined 230 233 }
+14 -11
app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 15 15 import { Popover } from "components/Popover"; 16 16 import { InfoSmall } from "components/Icons/InfoSmall"; 17 17 import { PostHeader } from "./PostHeader/PostHeader"; 18 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 18 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 19 + import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; 19 20 import { PollData } from "./fetchPollData"; 20 21 import { SharedPageProps } from "./PostPages"; 21 22 import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; ··· 71 72 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 72 73 recommendsCount={document.recommendsCount} 73 74 /> 74 - <CanvasContent 75 - blocks={blocks} 76 - did={did} 77 - prerenderedCodeBlocks={prerenderedCodeBlocks} 78 - bskyPostData={bskyPostData} 79 - standardSitePostData={standardSitePostData} 80 - pollData={pollData} 81 - pageId={pageId} 82 - pages={pages} 83 - /> 75 + <DrawerThreadPageProvider document_uri={document_uri} pageId={pageId}> 76 + <CanvasContent 77 + blocks={blocks} 78 + did={did} 79 + prerenderedCodeBlocks={prerenderedCodeBlocks} 80 + bskyPostData={bskyPostData} 81 + standardSitePostData={standardSitePostData} 82 + pollData={pollData} 83 + pageId={pageId} 84 + pages={pages} 85 + /> 86 + </DrawerThreadPageProvider> 84 87 </PageWrapper> 85 88 ); 86 89 }
+32 -29
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 20 20 import { type Profile } from "src/identity"; 21 21 import { PostInfo } from "../../BskyPostContent"; 22 22 import { Avatar } from "components/Avatar"; 23 + import { EmptyState } from "components/EmptyState"; 23 24 24 25 export type Comment = { 25 26 record: Json; ··· 84 85 </> 85 86 )} 86 87 <div className="comments flex flex-col gap-6 py-2"> 87 - {comments 88 - .sort((a, b) => { 89 - let aRecord = a.record as PubLeafletComment.Record; 90 - let bRecord = b.record as PubLeafletComment.Record; 91 - return ( 92 - new Date(bRecord.createdAt).getTime() - 93 - new Date(aRecord.createdAt).getTime() 94 - ); 95 - }) 96 - .filter( 97 - (comment) => !(comment.record as PubLeafletComment.Record).reply, 98 - ) 99 - .map((comment) => { 100 - let record = comment.record as PubLeafletComment.Record; 101 - return ( 102 - <> 103 - <Comment 104 - pageId={pageId} 105 - profile={comment.profile} 106 - document={props.document_uri} 107 - comment={comment} 108 - record={record} 109 - comments={comments} 110 - key={comment.uri} 111 - /> 112 - <hr className="border-border last:hidden" /> 113 - </> 114 - ); 115 - })} 88 + {comments.length === 0 && <EmptyState>No comments yet…</EmptyState>} 89 + {comments.length > 0 && 90 + comments 91 + .sort((a, b) => { 92 + let aRecord = a.record as PubLeafletComment.Record; 93 + let bRecord = b.record as PubLeafletComment.Record; 94 + return ( 95 + new Date(bRecord.createdAt).getTime() - 96 + new Date(aRecord.createdAt).getTime() 97 + ); 98 + }) 99 + .filter( 100 + (comment) => !(comment.record as PubLeafletComment.Record).reply, 101 + ) 102 + .map((comment) => { 103 + let record = comment.record as PubLeafletComment.Record; 104 + return ( 105 + <> 106 + <Comment 107 + pageId={pageId} 108 + profile={comment.profile} 109 + document={props.document_uri} 110 + comment={comment} 111 + record={record} 112 + comments={comments} 113 + key={comment.uri} 114 + /> 115 + <hr className="border-border last:hidden" /> 116 + </> 117 + ); 118 + })} 116 119 </div> 117 120 </div> 118 121 );
+65 -26
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { MentionsDrawerContent } from "./Quotes"; 3 3 import { 4 - InteractionState, 5 4 setInteractionState, 6 5 useInteractionState, 6 + pushDrawerThread, 7 + popDrawerThread, 8 + popDrawerThreadToRoot, 7 9 } from "./Interactions"; 8 - import { useSearchParams } from "next/navigation"; 9 10 import { SandwichSpacer } from "components/LeafletLayout"; 10 11 import { decodeQuotePosition } from "../quotePosition"; 11 12 import { CloseTiny } from "components/Icons/CloseTiny"; 13 + import { GoBackTiny } from "components/Icons/GoBackTiny"; 14 + import { DoubleArrowRightTiny } from "components/Icons/DoubleArrowRightTiny"; 12 15 import { ToggleGroup } from "components/ToggleGroup"; 13 16 import { useDocument } from "contexts/DocumentContext"; 17 + import { useEffect, useMemo, useRef } from "react"; 18 + import { DrawerThread, DrawerThreadContext } from "./drawerThreadContext"; 19 + import { useDrawerOpen } from "./useDrawerOpen"; 20 + import { ThreadView } from "../ThreadPage"; 14 21 15 22 export const InteractionDrawer = (props: { 16 23 showPageBackground: boolean | undefined; ··· 22 29 }) => { 23 30 let drawer = useDrawerOpen(props.document_uri); 24 31 let { commentsCount } = useDocument(); 32 + let { threadStack } = useInteractionState(props.document_uri); 33 + const drawerNav = useMemo( 34 + () => ({ 35 + push: (thread: DrawerThread) => 36 + pushDrawerThread(props.document_uri, thread), 37 + }), 38 + [props.document_uri], 39 + ); 40 + // Reset the drawer's scroll to the top whenever we navigate between views, so 41 + // a pushed thread (or a back navigation) doesn't start scrolled partway down. 42 + const scrollRef = useRef<HTMLDivElement>(null); 43 + useEffect(() => { 44 + scrollRef.current?.scrollTo({ top: 0 }); 45 + }, [threadStack.length]); 25 46 if (!drawer) return null; 26 47 27 48 const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { ··· 45 66 if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; 46 67 if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; 47 68 69 + // The innermost thread/quotes view opened within the drawer, if any. When 70 + // present it replaces the comments/mentions tabs. 71 + const activeThread = threadStack[threadStack.length - 1]; 72 + 48 73 return ( 49 74 <> 50 75 <SandwichSpacer noWidth /> 51 76 <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 52 77 <div 78 + ref={scrollRef} 53 79 id="interaction-drawer" 54 - className={`opaque-container relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 80 + className={` relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "light-container rounded-l-none! rounded-r-lg! -ml-[1px]" : " opaque-container rounded-lg! sm:ml-4"}`} 55 81 > 56 82 <div className="w-full flex items-center gap-2 mb-3"> 57 83 <div className="flex-1 min-w-0"> 58 - {bothAvailable ? ( 84 + {activeThread ? ( 85 + <div className="flex items-center gap-2"> 86 + {threadStack.length >= 2 && ( 87 + <button 88 + className="text-tertiary hover:text-secondary shrink-0" 89 + aria-label="Back to the top of the thread" 90 + onClick={() => popDrawerThreadToRoot(props.document_uri)} 91 + > 92 + <DoubleArrowRightTiny className="rotate-180" /> 93 + </button> 94 + )} 95 + <button 96 + className="flex items-center gap-1 text-tertiary hover:text-secondary font-bold text-sm" 97 + onClick={() => popDrawerThread(props.document_uri)} 98 + > 99 + <GoBackTiny /> Back 100 + </button> 101 + </div> 102 + ) : bothAvailable ? ( 59 103 <ToggleGroup 60 104 fullWidth 61 105 value={activeTab} ··· 99 143 <CloseTiny /> 100 144 </button> 101 145 </div> 102 - {activeTab === "quotes" ? ( 103 - <MentionsDrawerContent 104 - did={props.did} 105 - quotesAndMentions={filteredQuotesAndMentions} 106 - /> 107 - ) : ( 108 - props.commentsSlot 109 - )} 146 + <DrawerThreadContext.Provider value={drawerNav}> 147 + {activeThread ? ( 148 + <ThreadView 149 + parentUri={activeThread.uri} 150 + initialTab={ 151 + activeThread.type === "quotes" ? "quotes" : "replies" 152 + } 153 + /> 154 + ) : activeTab === "quotes" ? ( 155 + <MentionsDrawerContent 156 + did={props.did} 157 + quotesAndMentions={filteredQuotesAndMentions} 158 + /> 159 + ) : ( 160 + props.commentsSlot 161 + )} 162 + </DrawerThreadContext.Provider> 110 163 </div> 111 164 </div> 112 165 </> 113 166 ); 114 167 }; 115 - 116 - export const useDrawerOpen = (uri: string) => { 117 - let params = useSearchParams(); 118 - let interactionDrawerSearchParam = params.get("interactionDrawer"); 119 - let pageParam = params.get("page"); 120 - let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 121 - if (open === false || (open === undefined && !interactionDrawerSearchParam)) 122 - return null; 123 - drawer = 124 - drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 125 - // Use pageId from state, or fall back to page search param 126 - const resolvedPageId = pageId ?? pageParam ?? undefined; 127 - return { drawer, pageId: resolvedPageId }; 128 - };
+55 -1
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 18 18 import { RecommendButton } from "components/RecommendButton"; 19 19 import { ButtonSecondary } from "components/Buttons"; 20 20 import { Separator } from "components/Layout"; 21 + import type { DrawerThread } from "./drawerThreadContext"; 21 22 22 23 export type InteractionState = { 23 24 drawerOpen: undefined | boolean; ··· 25 26 drawer: undefined | "comments" | "quotes"; 26 27 localComments: Comment[]; 27 28 commentBox: { quote: QuotePosition | null }; 29 + // Thread/quotes views opened within the drawer, innermost last. When 30 + // non-empty the drawer shows the top entry instead of the comments/mentions 31 + // tabs, with a back button to work up the tree. 32 + threadStack: DrawerThread[]; 28 33 }; 29 34 30 35 const defaultInteractionState: InteractionState = { ··· 32 37 drawer: undefined, 33 38 localComments: [], 34 39 commentBox: { quote: null }, 40 + threadStack: [], 35 41 }; 36 42 37 43 export let useInteractionStateStore = create<{ ··· 97 103 pageId?: string, 98 104 ) { 99 105 flushSync(() => { 100 - setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); 106 + setInteractionState(document_uri, { 107 + drawerOpen: true, 108 + drawer, 109 + pageId, 110 + threadStack: [], 111 + }); 101 112 }); 102 113 scrollIntoView("interaction-drawer"); 114 + } 115 + 116 + // Open the drawer straight onto a thread/quotes view. Used when a Bluesky post 117 + // in the document body is clicked, so its thread opens in the drawer instead of 118 + // a new page (mirroring how the post's own comments/mentions open the drawer). 119 + export function openDrawerThread( 120 + document_uri: string, 121 + thread: DrawerThread, 122 + pageId?: string, 123 + ) { 124 + flushSync(() => { 125 + setInteractionState(document_uri, (s) => ({ 126 + drawerOpen: true, 127 + drawer: s.drawer ?? "comments", 128 + pageId, 129 + threadStack: [thread], 130 + })); 131 + }); 132 + scrollIntoView("interaction-drawer"); 133 + } 134 + 135 + // Open a thread/quotes view inside the drawer, replacing its content. Clicking 136 + // the view you're already on (e.g. the main post of the current thread) is a 137 + // no-op rather than stacking a duplicate. 138 + export function pushDrawerThread(document_uri: string, thread: DrawerThread) { 139 + setInteractionState(document_uri, (s) => { 140 + const top = s.threadStack[s.threadStack.length - 1]; 141 + if (top && top.type === thread.type && top.uri === thread.uri) return {}; 142 + return { threadStack: [...s.threadStack, thread] }; 143 + }); 144 + } 145 + 146 + // Step back up the drawer's thread navigation tree. 147 + export function popDrawerThread(document_uri: string) { 148 + setInteractionState(document_uri, (s) => ({ 149 + threadStack: s.threadStack.slice(0, -1), 150 + })); 151 + } 152 + 153 + // Jump all the way back out of the thread navigation, to the drawer's top 154 + // level (the comments/mentions tabs). 155 + export function popDrawerThreadToRoot(document_uri: string) { 156 + setInteractionState(document_uri, { threadStack: [] }); 103 157 } 104 158 105 159 export const Interactions = (props: {
+50
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext, useMemo } from "react"; 3 + import { OpenPage, openPage } from "../postPageState"; 4 + import { openDrawerThread } from "./Interactions"; 5 + 6 + // A thread or quotes view that can be shown inside the interaction drawer. 7 + export type DrawerThread = 8 + | { type: "thread"; uri: string } 9 + | { type: "quotes"; uri: string }; 10 + 11 + type DrawerThreadNav = { 12 + push: (thread: DrawerThread) => void; 13 + }; 14 + 15 + // Set by the InteractionDrawer (to navigate within the drawer) and by the 16 + // document page (to open the drawer onto a thread). When present, thread/quotes 17 + // links replace the drawer's content instead of opening a new page. 18 + export const DrawerThreadContext = createContext<DrawerThreadNav | null>(null); 19 + 20 + // Returns a function that opens a thread or quotes view. When a drawer-aware 21 + // provider is in scope it navigates within / opens the drawer; elsewhere it 22 + // falls back to opening a new page. 23 + export function useOpenThread() { 24 + const drawerNav = useContext(DrawerThreadContext); 25 + return (parent: OpenPage | undefined, thread: DrawerThread) => { 26 + if (drawerNav) drawerNav.push(thread); 27 + else openPage(parent, thread); 28 + }; 29 + } 30 + 31 + // Wraps document-body content so Bluesky posts within it open their thread in 32 + // the interaction drawer (onto a fresh stack) rather than in a new page. 33 + export function DrawerThreadPageProvider(props: { 34 + document_uri: string; 35 + pageId?: string; 36 + children: React.ReactNode; 37 + }) { 38 + const value = useMemo( 39 + () => ({ 40 + push: (thread: DrawerThread) => 41 + openDrawerThread(props.document_uri, thread, props.pageId), 42 + }), 43 + [props.document_uri, props.pageId], 44 + ); 45 + return ( 46 + <DrawerThreadContext.Provider value={value}> 47 + {props.children} 48 + </DrawerThreadContext.Provider> 49 + ); 50 + }
+17
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts
··· 1 + "use client"; 2 + import { useSearchParams } from "next/navigation"; 3 + import { InteractionState, useInteractionState } from "./Interactions"; 4 + 5 + export const useDrawerOpen = (uri: string) => { 6 + let params = useSearchParams(); 7 + let interactionDrawerSearchParam = params.get("interactionDrawer"); 8 + let pageParam = params.get("page"); 9 + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 10 + if (open === false || (open === undefined && !interactionDrawerSearchParam)) 11 + return null; 12 + drawer = 13 + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 14 + // Use pageId from state, or fall back to page search param 15 + const resolvedPageId = pageId ?? pageParam ?? undefined; 16 + return { drawer, pageId: resolvedPageId }; 17 + };
+15 -12
app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 8 8 import { PostContent } from "./PostContent"; 9 9 import { PostHeader } from "./PostHeader/PostHeader"; 10 10 import { AppBskyFeedDefs } from "@atproto/api"; 11 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 11 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 12 + import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; 12 13 import { PageWrapper } from "components/Pages/Page"; 13 14 import { decodeQuotePosition } from "./quotePosition"; 14 15 import { PollData } from "./fetchPollData"; ··· 80 81 preferences={preferences} 81 82 /> 82 83 )} 83 - <PostContent 84 - pollData={pollData} 85 - pages={pages as PubLeafletPagesLinearDocument.Main[]} 86 - pageId={pageId} 87 - bskyPostData={bskyPostData} 88 - standardSitePostData={standardSitePostData} 89 - blocks={blocks} 90 - did={did} 91 - prerenderedCodeBlocks={prerenderedCodeBlocks} 92 - footnoteIndexMap={footnoteIndexMap} 93 - /> 84 + <DrawerThreadPageProvider document_uri={document_uri} pageId={pageId}> 85 + <PostContent 86 + pollData={pollData} 87 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 88 + pageId={pageId} 89 + bskyPostData={bskyPostData} 90 + standardSitePostData={standardSitePostData} 91 + blocks={blocks} 92 + did={did} 93 + prerenderedCodeBlocks={prerenderedCodeBlocks} 94 + footnoteIndexMap={footnoteIndexMap} 95 + /> 96 + </DrawerThreadPageProvider> 94 97 <PublishedFootnoteSection footnotes={footnotes} /> 95 98 <PostPrevNextButtons 96 99 showPrevNext={preferences.showPrevNext !== false && !isSubpage}
+6 -3
app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 1 1 "use client"; 2 2 import { AppBskyFeedDefs } from "@atproto/api"; 3 3 import { preload } from "swr"; 4 - import { openPage, OpenPage } from "./postPageState"; 4 + import { OpenPage } from "./postPageState"; 5 + import { useOpenThread } from "./Interactions/drawerThreadContext"; 5 6 6 7 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 7 8 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; ··· 62 63 onClick?: (e: React.MouseEvent) => void; 63 64 }) { 64 65 const { postUri, parent, children, className, onClick } = props; 66 + const openThread = useOpenThread(); 65 67 66 68 const handleClick = (e: React.MouseEvent) => { 67 69 e.stopPropagation(); 68 70 onClick?.(e); 69 71 if (e.defaultPrevented) return; 70 - openPage(parent, { type: "thread", uri: postUri }); 72 + openThread(parent, { type: "thread", uri: postUri }); 71 73 }; 72 74 73 75 const handlePrefetch = () => { ··· 95 97 onClick?: (e: React.MouseEvent) => void; 96 98 }) { 97 99 const { postUri, parent, children, className, onClick } = props; 100 + const openThread = useOpenThread(); 98 101 99 102 const handleClick = (e: React.MouseEvent) => { 100 103 e.stopPropagation(); 101 104 onClick?.(e); 102 105 if (e.defaultPrevented) return; 103 - openPage(parent, { type: "quotes", uri: postUri }); 106 + openThread(parent, { type: "quotes", uri: postUri }); 104 107 }; 105 108 106 109 const handlePrefetch = () => {
+6 -46
app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 10 10 import { PostPageData } from "./getPostPageData"; 11 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 12 import { AppBskyFeedDefs } from "@atproto/api"; 13 - import { 14 - InteractionDrawer, 15 - useDrawerOpen, 16 - } from "./Interactions/InteractionDrawer"; 13 + import { InteractionDrawer } from "./Interactions/InteractionDrawer"; 14 + import { useDrawerOpen } from "./Interactions/useDrawerOpen"; 17 15 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 16 import { PageOptionButton } from "components/Pages/PageOptions"; 19 17 import { CloseTiny } from "components/Icons/CloseTiny"; ··· 22 20 import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 23 21 import { LinearDocumentPage } from "./LinearDocumentPage"; 24 22 import { CanvasPage } from "./CanvasPage"; 25 - import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 26 - import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 27 23 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 28 24 import { 29 25 type OpenPage, ··· 203 199 {openPageIds.map((openPage, openPageIndex) => { 204 200 const pageKey = getPageKey(openPage); 205 201 206 - // Handle thread pages 207 - if (openPage.type === "thread") { 208 - return ( 209 - <Fragment key={pageKey}> 210 - <SandwichSpacer /> 211 - <ThreadPageComponent 212 - parentUri={openPage.uri} 213 - pageId={pageKey} 214 - hasPageBackground={hasPageBackground} 215 - pageOptions={ 216 - <PageOptions 217 - onClick={() => closePage(openPage)} 218 - hasPageBackground={hasPageBackground} 219 - /> 220 - } 221 - /> 222 - </Fragment> 223 - ); 224 - } 225 - 226 - // Handle quotes pages 227 - if (openPage.type === "quotes") { 228 - return ( 229 - <Fragment key={pageKey}> 230 - <SandwichSpacer /> 231 - <BlueskyQuotesPage 232 - postUri={openPage.uri} 233 - pageId={pageKey} 234 - hasPageBackground={hasPageBackground} 235 - pageOptions={ 236 - <PageOptions 237 - onClick={() => closePage(openPage)} 238 - hasPageBackground={hasPageBackground} 239 - /> 240 - } 241 - /> 242 - </Fragment> 243 - ); 244 - } 245 - 246 202 // Handle iframe pages 247 203 if (openPage.type === "iframe") { 248 204 return ( ··· 263 219 </Fragment> 264 220 ); 265 221 } 222 + 223 + // Only document pages can be opened now; thread/quotes views render in 224 + // the interaction drawer rather than as their own pages. 225 + if (openPage.type !== "doc") return null; 266 226 267 227 // Handle document pages 268 228 let page = pages.find(
+40 -45
app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 - import { Fragment, useEffect, useMemo, useRef, useState } from "react"; 2 + import { useContext, useEffect, useMemo, useRef, useState } from "react"; 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, ··· 8 8 } from "@atproto/api"; 9 9 import { AtUri } from "@atproto/syntax"; 10 10 import useSWR from "swr"; 11 - import { PageWrapper } from "components/Pages/Page"; 12 - import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 11 + import { DrawerThreadContext } from "./Interactions/drawerThreadContext"; 13 12 import { DotLoader } from "components/utils/DotLoader"; 14 13 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 15 14 import { useThreadState } from "src/useThreadState"; 16 - import { 17 - BskyPostContent, 18 - CompactBskyPostContent, 19 - ClientDate, 20 - } from "./BskyPostContent"; 15 + import { BskyPostContent, CompactBskyPostContent } from "./BskyPostContent"; 21 16 import { 22 17 ThreadLink, 23 18 getThreadKey, 24 19 fetchThread, 25 - prefetchThread, 26 20 getQuotesKey, 27 21 fetchQuotes, 28 22 } from "./PostLinks"; ··· 36 30 matchDocumentUrl, 37 31 type QuotePosition, 38 32 } from "./quotePosition"; 39 - 40 - // Re-export for backwards compatibility 41 - export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; 42 33 43 34 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 44 35 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; ··· 108 99 return null; 109 100 } 110 101 111 - export function ThreadPage(props: { 102 + // Fetches a thread and renders its content (loading/error states included). 103 + // Used both as a standalone page and inside the interaction drawer. `initialTab` 104 + // selects whether replies or quote posts are shown first (defaults to replies). 105 + export function ThreadView(props: { 112 106 parentUri: string; 113 - pageId: string; 114 - pageOptions?: React.ReactNode; 115 - hasPageBackground: boolean; 107 + initialTab?: "replies" | "quotes"; 116 108 }) { 117 - const { parentUri, pageId, pageOptions } = props; 118 - const drawer = useDrawerOpen(parentUri); 119 - 109 + const { parentUri, initialTab } = props; 120 110 const { 121 111 data: thread, 122 112 isLoading, ··· 125 115 fetchThread(parentUri), 126 116 ); 127 117 118 + if (isLoading) { 119 + return ( 120 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 121 + <span>loading thread</span> 122 + <DotLoader /> 123 + </div> 124 + ); 125 + } 126 + if (error) { 127 + return ( 128 + <div className="text-tertiary italic text-sm text-center py-8"> 129 + Failed to load thread 130 + </div> 131 + ); 132 + } 133 + if (!thread) return null; 128 134 return ( 129 - <PageWrapper 130 - pageType="doc" 131 - fullPageScroll={false} 132 - id={`post-page-${pageId}`} 133 - drawerOpen={false} 134 - pageOptions={pageOptions} 135 - fixedWidth 136 - > 137 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 138 - {isLoading ? ( 139 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 140 - <span>loading thread</span> 141 - <DotLoader /> 142 - </div> 143 - ) : error ? ( 144 - <div className="text-tertiary italic text-sm text-center py-8"> 145 - Failed to load thread 146 - </div> 147 - ) : thread ? ( 148 - <ThreadContent post={thread} parentUri={parentUri} /> 149 - ) : null} 150 - </div> 151 - </PageWrapper> 135 + <ThreadContent post={thread} parentUri={parentUri} initialTab={initialTab} /> 152 136 ); 153 137 } 154 138 155 - function ThreadContent(props: { post: ThreadType; parentUri: string }) { 139 + function ThreadContent(props: { 140 + post: ThreadType; 141 + parentUri: string; 142 + initialTab?: "replies" | "quotes"; 143 + }) { 156 144 const { post, parentUri } = props; 157 145 const mainPostRef = useRef<HTMLDivElement>(null); 146 + // Inside the interaction drawer the header (back/close) shares the scroll 147 + // container, so we let the drawer handle scroll position instead of pulling 148 + // the main post to the top (which would hide the header). 149 + const inDrawer = useContext(DrawerThreadContext) !== null; 158 150 159 151 // Compute document URLs for leaflet link detection 160 152 const { ··· 172 164 173 165 // Scroll the main post into view when the thread loads 174 166 useEffect(() => { 167 + if (inDrawer) return; 175 168 if (mainPostRef.current) { 176 169 mainPostRef.current.scrollIntoView({ 177 170 behavior: "instant", 178 171 block: "start", 179 172 }); 180 173 } 181 - }, []); 174 + }, [inDrawer]); 182 175 183 176 if (AppBskyFeedDefs.isNotFoundPost(post)) { 184 177 return <PostNotAvailable />; ··· 231 224 rootAuthorDid={rootAuthorDid} 232 225 documentUrls={documentUrls} 233 226 docDid={docDid} 227 + initialTab={props.initialTab} 234 228 /> 235 229 </div> 236 230 ); ··· 243 237 rootAuthorDid: string; 244 238 documentUrls: string[]; 245 239 docDid: string; 240 + initialTab?: "replies" | "quotes"; 246 241 }) { 247 242 const { post, rootAuthorDid, documentUrls, docDid } = props; 248 243 ··· 254 249 const showTabs = hasReplies && hasQuotes; 255 250 256 251 const [activeTab, setActiveTab] = useState<"replies" | "quotes">( 257 - hasReplies ? "replies" : "quotes", 252 + props.initialTab ?? (hasReplies ? "replies" : "quotes"), 258 253 ); 259 254 260 255 if (!hasReplies && !hasQuotes) return null;
+6 -1
app/globals.css
··· 37 37 rgb(var(--primary)), 38 38 rgb(var(--bg-page)) 85% 39 39 ); 40 + --color-bg-light: color-mix( 41 + in oklab, 42 + rgb(var(--primary)), 43 + rgb(var(--bg-page)) 95% 44 + ); 40 45 --color-white: #ffffff; 41 46 --color-accent-1: rgb(var(--accent-1)); 42 47 --color-accent-2: rgb(var(--accent-2)); ··· 497 502 } 498 503 499 504 .light-container { 500 - background: color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%); 505 + background: var(--color-bg-light); 501 506 @apply border; 502 507 @apply border-border-light; 503 508 @apply rounded-md;
+4 -5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 11 11 AppBskyLabelerDefs, 12 12 } from "@atproto/api"; 13 13 import { Avatar } from "components/Avatar"; 14 - import { 15 - OpenPage, 16 - openPage, 17 - } from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; 14 + import { OpenPage } from "app/(app)/lish/[did]/[publication]/[rkey]/PostPages"; 15 + import { useOpenThread } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext"; 18 16 import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; 19 17 20 18 export const BlueskyEmbed = (props: { ··· 24 22 compact?: boolean; 25 23 parent?: OpenPage; 26 24 }) => { 25 + const openThread = useOpenThread(); 27 26 // check this file from bluesky for ref 28 27 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx 29 28 switch (true) { ··· 161 160 e.preventDefault(); 162 161 e.stopPropagation(); 163 162 164 - openPage(props.parent, { type: "thread", uri: record.uri }); 163 + openThread(props.parent, { type: "thread", uri: record.uri }); 165 164 }} 166 165 > 167 166 <Avatar
+27
components/Icons/DoubleArrowRightTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DoubleArrowRightTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M9.05487 2.30299L13.8044 7.31194C14.1702 7.69772 14.1702 8.30228 13.8044 8.68806L9.05487 13.697C8.6718 14.101 8.05073 14.101 7.66767 13.697C7.2846 13.293 7.2846 12.638 7.66767 12.234L11.6824 8L7.66767 3.76597C7.2846 3.36198 7.2846 2.70698 7.66767 2.30299C8.05073 1.899 8.6718 1.899 9.05487 2.30299Z" 17 + fill="currentColor" 18 + /> 19 + <path 20 + fillRule="evenodd" 21 + clipRule="evenodd" 22 + d="M3.6745 2.30299L8.42399 7.31194C8.78978 7.69772 8.78978 8.30228 8.42399 8.68806L3.6745 13.697C3.29143 14.101 2.67036 14.101 2.2873 13.697C1.90423 13.293 1.90423 12.638 2.2873 12.234L6.30201 8L2.2873 3.76597C1.90423 3.36198 1.90423 2.70698 2.2873 2.30299C2.67036 1.899 3.29143 1.899 3.6745 2.30299Z" 23 + fill="currentColor" 24 + /> 25 + </svg> 26 + ); 27 + };
+1 -1
components/Pages/Page.tsx
··· 16 16 import { focusPage } from "src/utils/focusPage"; 17 17 import { PageOptions } from "./PageOptions"; 18 18 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 19 - import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + import { useDrawerOpen } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen"; 20 20 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 21 21 import { usePageFootnotes } from "components/Footnotes/usePageFootnotes"; 22 22 import { FootnoteContext } from "components/Footnotes/FootnoteContext";