From 78cf7d436d825274649142dfede38466fbc1994d Mon Sep 17 00:00:00 2001 From: celine Date: Fri, 12 Dec 2025 14:29:41 -0500 Subject: [PATCH 01/15] fixed case insentivity issue in tagPage --- app/(home-pages)/tag/[tag]/getDocumentsByTag.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts index cdd9d7c2..56ac8663 100644 --- a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -10,9 +10,6 @@ import type { Post } from "app/(home-pages)/reader/getReaderFeed"; export async function getDocumentsByTag( tag: string, ): Promise<{ posts: Post[] }> { - // Normalize tag to lowercase for case-insensitive search - const normalizedTag = tag.toLowerCase(); - // Query documents that have this tag const { data: documents, error } = await supabaseServerClient .from("documents") @@ -22,7 +19,7 @@ export async function getDocumentsByTag( document_mentions_in_bsky(count), documents_in_publications(publications(*))`, ) - .contains("data->tags", `["${normalizedTag}"]`) + .contains("data->tags", `["${tag}"]`) .order("indexed_at", { ascending: false }) .limit(50); -- 2.51.2 From ff558a1ed177e63848f5bcb99bab9949e4b526e3 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 14 Dec 2025 10:46:29 +0400 Subject: [PATCH 02/15] don't put undefined alignements in record --- actions/publishToPublication.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 4e51580c..4dc7ef99 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -305,9 +305,9 @@ async function processBlocksToPages( if (!b) return []; let block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", - alignment, block: b, }; + if (alignment) block.alignment = alignment; return [block]; } else { let block: PubLeafletPagesLinearDocument.Block = { -- 2.51.2 From 3457cfb1bfc6d8c95535a3d9977214191b3258c8 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 14 Dec 2025 11:01:16 +0400 Subject: [PATCH 03/15] fix blockquote styling --- app/lish/[did]/[publication]/[rkey]/PostContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 8944e53b..e9ce69cf 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -324,7 +324,7 @@ export let Block = ({ return ( // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
Date: Sun, 14 Dec 2025 12:46:35 +0400 Subject: [PATCH 04/15] ensure various numbers are integers in records --- actions/publishToPublication.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 4dc7ef99..84dec3c0 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -405,7 +405,7 @@ async function processBlocksToPages( let [stringValue, facets] = getBlockContent(b.value); let block: $Typed = { $type: "pub.leaflet.blocks.header", - level: headingLevel?.data.value || 1, + level: Math.floor(headingLevel?.data.value || 1), plaintext: stringValue, facets, }; @@ -438,7 +438,7 @@ async function processBlocksToPages( let block: $Typed = { $type: "pub.leaflet.blocks.iframe", url: url.data.value, - height: height?.data.value || 600, + height: Math.floor(height?.data.value || 600), }; return block; } @@ -452,8 +452,8 @@ async function processBlocksToPages( $type: "pub.leaflet.blocks.image", image: blobref, aspectRatio: { - height: image.data.height, - width: image.data.width, + height: Math.floor(image.data.height), + width: Math.floor(image.data.width), }, alt: altText ? altText.data.value : undefined, }; @@ -770,7 +770,7 @@ async function extractThemeFromFacts( image: blob.data.blob, repeat: backgroundImageRepeat?.data.value ? true : false, ...(backgroundImageRepeat?.data.value && { - width: backgroundImageRepeat.data.value, + width: Math.floor(backgroundImageRepeat.data.value), }), }; } -- 2.51.2 From 665be1346fdc9bdb630fb2b2669d016898b06f8e Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 16 Dec 2025 05:05:18 +0400 Subject: [PATCH 05/15] fix uri redirect route --- app/lish/uri/[uri]/route.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/lish/uri/[uri]/route.ts b/app/lish/uri/[uri]/route.ts index 4101d9b7..f4e0d0ed 100644 --- a/app/lish/uri/[uri]/route.ts +++ b/app/lish/uri/[uri]/route.ts @@ -9,7 +9,7 @@ import { PubLeafletPublication } from "lexicons/api"; */ export async function GET( request: NextRequest, - { params }: { params: Promise<{ uri: string }> } + { params }: { params: Promise<{ uri: string }> }, ) { try { const { uri: uriParam } = await params; @@ -32,7 +32,9 @@ export async function GET( const basePath = record.base_path; if (!basePath) { - return new NextResponse("Publication has no base_path", { status: 404 }); + return new NextResponse("Publication has no base_path", { + status: 404, + }); } // Redirect to the publication's hosted domain (temporary redirect since base_path can change) @@ -47,11 +49,14 @@ export async function GET( if (docInPub?.publication && docInPub.publications) { // Document is in a publication - redirect to domain/rkey - const record = docInPub.publications.record as PubLeafletPublication.Record; + const record = docInPub.publications + .record as PubLeafletPublication.Record; const basePath = record.base_path; if (!basePath) { - return new NextResponse("Publication has no base_path", { status: 404 }); + return new NextResponse("Publication has no base_path", { + status: 404, + }); } // Ensure basePath ends without trailing slash @@ -60,7 +65,10 @@ export async function GET( : basePath; // Redirect to the document on the publication's domain (temporary redirect since base_path can change) - return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); + return NextResponse.redirect( + `https://${cleanBasePath}/${uri.rkey}`, + 307, + ); } // If not in a publication, check if it's a standalone document @@ -74,7 +82,7 @@ export async function GET( // Standalone document - redirect to /p/did/rkey (temporary redirect) return NextResponse.redirect( new URL(`/p/${uri.host}/${uri.rkey}`, request.url), - 307 + 307, ); } -- 2.51.2 From c592bae0847985b43c399cb6d0ea3016947f770a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 16 Dec 2025 19:33:38 +0400 Subject: [PATCH 06/15] fix publication url --- .../[publication]/[rkey]/Interactions/Interactions.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index f0eeb41a..0225941a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -216,10 +216,9 @@ export const ExpandedInteractions = (props: { -- 2.51.2 From 96c5a7c224ef9ed522c2781af15836ce76c36964 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 16 Dec 2025 20:27:13 +0400 Subject: [PATCH 07/15] add debug validate route for records --- app/api/unstable_validate/route.ts | 215 +++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 app/api/unstable_validate/route.ts diff --git a/app/api/unstable_validate/route.ts b/app/api/unstable_validate/route.ts new file mode 100644 index 00000000..51e2900c --- /dev/null +++ b/app/api/unstable_validate/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AtpAgent, AtUri } from "@atproto/api"; +import { DidResolver } from "@atproto/identity"; +import { + PubLeafletDocument, + PubLeafletPublication, + PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, +} from "lexicons/api"; + +const didResolver = new DidResolver({}); + +export async function GET(request: NextRequest) { + try { + const atUriString = request.nextUrl.searchParams.get("uri"); + + if (!atUriString) { + return NextResponse.json( + { + success: false, + error: "Missing uri parameter", + }, + { status: 400 }, + ); + } + + const uri = new AtUri(atUriString); + + // Only allow document and publication collections + if ( + uri.collection !== "pub.leaflet.document" && + uri.collection !== "pub.leaflet.publication" + ) { + return NextResponse.json( + { + success: false, + error: + "Unsupported collection type. Must be pub.leaflet.document or pub.leaflet.publication", + }, + { status: 400 }, + ); + } + + // Resolve DID to get service endpoint + const did = await didResolver.resolve(uri.host); + const service = did?.service?.[0]; + + if (!service) { + return NextResponse.json( + { + success: false, + error: "Could not resolve DID service endpoint", + }, + { status: 404 }, + ); + } + + // Fetch the record from AT Protocol + const agent = new AtpAgent({ service: service.serviceEndpoint as string }); + + let recordResponse; + try { + recordResponse = await agent.com.atproto.repo.getRecord({ + repo: uri.host, + collection: uri.collection, + rkey: uri.rkey, + }); + } catch (e) { + return NextResponse.json( + { + success: false, + error: "Record not found", + }, + { status: 404 }, + ); + } + + // Validate based on collection type + if (uri.collection === "pub.leaflet.document") { + const result = PubLeafletDocument.validateRecord( + recordResponse.data.value, + ); + if (result.success) { + return NextResponse.json({ + success: true, + collection: uri.collection, + record: result.value, + }); + } else { + // Detailed validation: validate pages and blocks individually + const record = recordResponse.data.value as { + pages?: Array<{ $type?: string; blocks?: Array<{ block?: unknown }> }>; + }; + const pageErrors: Array<{ + pageIndex: number; + pageType: string; + error?: unknown; + blockErrors?: Array<{ + blockIndex: number; + blockType: string; + error: unknown; + block: unknown; + }>; + }> = []; + + if (record.pages && Array.isArray(record.pages)) { + for (let pageIndex = 0; pageIndex < record.pages.length; pageIndex++) { + const page = record.pages[pageIndex]; + const pageType = page?.$type || "unknown"; + + // Validate page based on type + let pageResult; + if (pageType === "pub.leaflet.pages.linearDocument") { + pageResult = PubLeafletPagesLinearDocument.validateMain(page); + } else if (pageType === "pub.leaflet.pages.canvas") { + pageResult = PubLeafletPagesCanvas.validateMain(page); + } else { + pageErrors.push({ + pageIndex, + pageType, + error: `Unknown page type: ${pageType}`, + }); + continue; + } + + if (!pageResult.success) { + // Page has errors, validate individual blocks + const blockErrors: Array<{ + blockIndex: number; + blockType: string; + error: unknown; + block: unknown; + }> = []; + + if (page.blocks && Array.isArray(page.blocks)) { + for ( + let blockIndex = 0; + blockIndex < page.blocks.length; + blockIndex++ + ) { + const blockWrapper = page.blocks[blockIndex]; + const blockType = + (blockWrapper?.block as { $type?: string })?.$type || + "unknown"; + + // Validate block wrapper based on page type + let blockResult; + if (pageType === "pub.leaflet.pages.linearDocument") { + blockResult = + PubLeafletPagesLinearDocument.validateBlock(blockWrapper); + } else { + blockResult = + PubLeafletPagesCanvas.validateBlock(blockWrapper); + } + + if (!blockResult.success) { + blockErrors.push({ + blockIndex, + blockType, + error: blockResult.error, + block: blockWrapper, + }); + } + } + } + + pageErrors.push({ + pageIndex, + pageType, + error: pageResult.error, + blockErrors: blockErrors.length > 0 ? blockErrors : undefined, + }); + } + } + } + + return NextResponse.json({ + success: false, + collection: uri.collection, + error: result.error, + pageErrors: pageErrors.length > 0 ? pageErrors : undefined, + record: recordResponse.data.value, + }); + } + } + + if (uri.collection === "pub.leaflet.publication") { + const result = PubLeafletPublication.validateRecord( + recordResponse.data.value, + ); + if (result.success) { + return NextResponse.json({ + success: true, + collection: uri.collection, + record: result.value, + }); + } else { + return NextResponse.json({ + success: false, + collection: uri.collection, + error: result.error, + }); + } + } + } catch (error) { + console.error("Error validating AT URI:", error); + return NextResponse.json( + { + success: false, + error: "Invalid URI or internal error", + }, + { status: 400 }, + ); + } +} -- 2.51.2 From 2944c18b524bdc294d1a509a120ecd08e35fb0f4 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 16 Dec 2025 20:30:09 +0400 Subject: [PATCH 08/15] fix type error --- .../[did]/[publication]/[rkey]/Interactions/Interactions.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 0225941a..94bf6c36 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -216,9 +216,7 @@ export const ExpandedInteractions = (props: { -- 2.51.2 From 89477f22902e13218b168b4bb4c738eccd98cfcd Mon Sep 17 00:00:00 2001 From: Brendan Schlagel Date: Tue, 16 Dec 2025 11:33:54 -0500 Subject: [PATCH 09/15] add https prefix to make RSS link absolute --- app/lish/Subscribe.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 695bd6da..883e967d 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -105,7 +105,7 @@ export const ManageSubscription = (props: { )} Date: Tue, 16 Dec 2025 15:44:52 -0500 Subject: [PATCH 10/15] fix misising postUrl in InteractionPreview on publication page --- app/lish/[did]/[publication]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 6754053a..897a4e65 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -168,7 +168,7 @@ export default async function Publication(props: { quotesCount={quotes} commentsCount={comments} tags={tags} - postUrl="" + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} showComments={record?.preferences?.showComments} /> -- 2.51.2 From 81bdafb6001e4c91ba8678924dc994ae759f768f Mon Sep 17 00:00:00 2001 From: Brendan Schlagel Date: Tue, 16 Dec 2025 15:48:56 -0500 Subject: [PATCH 11/15] fix hover styles for quotes in InteractionPreview --- components/InteractionsPreview.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx index 511bce28..a0cd1476 100644 --- a/components/InteractionsPreview.tsx +++ b/components/InteractionsPreview.tsx @@ -40,7 +40,7 @@ export const InteractionPreview = (props: { {props.quotesCount} @@ -49,7 +49,7 @@ export const InteractionPreview = (props: { {props.commentsCount} @@ -93,7 +93,7 @@ const TagPopover = (props: { tags: string[] }) => { +
{props.tags.length}
} -- 2.51.2 From 2672d2c4575eb8c15e625180c87d60a9d7c6a20b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 17 Dec 2025 21:31:58 +0400 Subject: [PATCH 12/15] add basic thread viewer component --- app/api/bsky/thread/route.ts | 44 ++ .../[rkey]/Interactions/Quotes.tsx | 50 +- .../[publication]/[rkey]/PostContent.tsx | 2 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 101 +++- .../[rkey]/PublishBskyPostBlock.tsx | 51 +- .../[rkey]/PublishedPageBlock.tsx | 25 +- .../[did]/[publication]/[rkey]/ThreadPage.tsx | 439 ++++++++++++++++++ .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 78 ++-- components/Blocks/BlueskyPostBlock/index.tsx | 2 +- 9 files changed, 709 insertions(+), 83 deletions(-) create mode 100644 app/api/bsky/thread/route.ts create mode 100644 app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx diff --git a/app/api/bsky/thread/route.ts b/app/api/bsky/thread/route.ts new file mode 100644 index 00000000..fafbcf42 --- /dev/null +++ b/app/api/bsky/thread/route.ts @@ -0,0 +1,44 @@ +import { Agent, lexToJson } from "@atproto/api"; +import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams; + const uri = searchParams.get("uri"); + const depth = searchParams.get("depth"); + const parentHeight = searchParams.get("parentHeight"); + + if (!uri) { + return Response.json( + { error: "uri parameter is required" }, + { status: 400 }, + ); + } + + // Fetch thread from Bluesky + let agent = new Agent({ + service: "https://public.api.bsky.app", + }); + + const response = await agent.getPostThread({ + uri, + depth: depth ? parseInt(depth, 10) : 6, + parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80, + }); + + const thread = lexToJson(response.data.thread); + + return Response.json(thread, { + headers: { + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", + }, + }); + } catch (error) { + console.error("Error fetching Bluesky thread:", error); + return Response.json({ error: "Failed to fetch thread" }, { status: 500 }); + } +} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 65cb79d2..1d30e991 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -4,7 +4,7 @@ import { useContext } from "react"; import { useIsMobile } from "src/hooks/isMobile"; import { setInteractionState } from "./Interactions"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { AtUri } from "@atproto/api"; +import { AtUri, AppBskyFeedPost } from "@atproto/api"; import { PostPageContext } from "../PostPageContext"; import { PubLeafletBlocksText, @@ -22,6 +22,8 @@ import { flushSync } from "react-dom"; import { openPage } from "../PostPages"; import useSWR, { mutate } from "swr"; import { DotLoader } from "components/utils/DotLoader"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { ThreadLink } from "../ThreadPage"; // Helper to get SWR key for quotes export function getQuotesSWRKey(uris: string[]) { @@ -129,11 +131,13 @@ export const Quotes = (props: {
); @@ -150,11 +154,13 @@ export const Quotes = (props: { return ( ); })} @@ -201,7 +207,7 @@ export const QuoteContent = (props: { className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" onClick={(e) => { if (props.position.pageId) - flushSync(() => openPage(undefined, props.position.pageId!)); + flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); let scrollMargin = isMobile ? 16 : e.currentTarget.getBoundingClientRect().top; @@ -239,33 +245,55 @@ export const QuoteContent = (props: { }; export const BskyPost = (props: { + uri: string; rkey: string; content: string; user: string; handle: string; profile: ProfileViewBasic; + replyCount?: number; }) => { + const handleOpenThread = () => { + openPage(undefined, { type: "thread", uri: props.uri }); + }; + return ( -
{props.profile.avatar && ( {props.profile.displayName} )} -
-
+
+
{props.content}
+ {props.replyCount != null && props.replyCount > 0 && ( + e.stopPropagation()} + className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast" + > + + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} + + )}
- +
); }; diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index e9ce69cf..40a341c8 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -173,7 +173,7 @@ export let Block = ({ let uri = b.block.postRef.uri; let post = bskyPostData.find((p) => p.uri === uri); if (!post) return
no prefetched post rip
; - return ; + return ; } case PubLeafletBlocksIframe.isMain(b.block): { return ( diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 05576804..420c201f 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -19,25 +19,46 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { Fragment, useEffect } from "react"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { decodeQuotePosition } from "./quotePosition"; import { PollData } from "./fetchPollData"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; +import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; + +// Page types +export type DocPage = { type: "doc"; id: string }; +export type ThreadPage = { type: "thread"; uri: string }; +export type OpenPage = DocPage | ThreadPage; + +// Get a stable key for a page +const getPageKey = (page: OpenPage): string => { + if (page.type === "doc") return page.id; + return `thread:${page.uri}`; +}; const usePostPageUIState = create(() => ({ - pages: [] as string[], + pages: [] as OpenPage[], initialized: false, })); -export const useOpenPages = () => { +export const useOpenPages = (): OpenPage[] => { const { quote } = useParams(); const state = usePostPageUIState((s) => s); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); - if (!state.initialized && quote) { - const decodedQuote = decodeQuotePosition(quote as string); - if (decodedQuote?.pageId) { - return [decodedQuote.pageId]; + if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + return [{ type: "doc", id: pageParam }]; + } + // Then check for quote param + if (quote) { + const decodedQuote = decodeQuotePosition(quote as string); + if (decodedQuote?.pageId) { + return [{ type: "doc", id: decodedQuote.pageId }]; + } } } @@ -46,15 +67,27 @@ export const useOpenPages = () => { export const useInitializeOpenPages = () => { const { quote } = useParams(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); useEffect(() => { const state = usePostPageUIState.getState(); if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + usePostPageUIState.setState({ + pages: [{ type: "doc", id: pageParam }], + initialized: true, + }); + return; + } + + // Then check for quote param if (quote) { const decodedQuote = decodeQuotePosition(quote as string); if (decodedQuote?.pageId) { usePostPageUIState.setState({ - pages: [decodedQuote.pageId], + pages: [{ type: "doc", id: decodedQuote.pageId }], initialized: true, }); return; @@ -67,13 +100,18 @@ export const useInitializeOpenPages = () => { }; export const openPage = ( - parent: string | undefined, - page: string, + parent: OpenPage | undefined, + page: OpenPage, options?: { scrollIntoView?: boolean }, ) => { + const pageKey = getPageKey(page); + const parentKey = parent ? getPageKey(parent) : undefined; + flushSync(() => { usePostPageUIState.setState((state) => { - let parentPosition = state.pages.findIndex((s) => s == parent); + let parentPosition = state.pages.findIndex( + (s) => getPageKey(s) === parentKey, + ); return { pages: parentPosition === -1 @@ -85,18 +123,22 @@ export const openPage = ( }); if (options?.scrollIntoView !== false) { - scrollIntoView(`post-page-${page}`); + scrollIntoView(`post-page-${pageKey}`); } }; -export const closePage = (page: string) => +export const closePage = (page: OpenPage) => { + const pageKey = getPageKey(page); usePostPageUIState.setState((state) => { - let parentPosition = state.pages.findIndex((s) => s == page); + let parentPosition = state.pages.findIndex( + (s) => getPageKey(s) === pageKey, + ); return { pages: state.pages.slice(0, parentPosition), initialized: true, }; }); +}; // Shared props type for both page components export type SharedPageProps = { @@ -228,14 +270,37 @@ export function PostPages({ /> )} - {openPageIds.map((pageId) => { + {openPageIds.map((openPage) => { + const pageKey = getPageKey(openPage); + + // Handle thread pages + if (openPage.type === "thread") { + return ( + + + closePage(openPage)} + hasPageBackground={hasPageBackground} + /> + } + /> + + ); + } + + // Handle document pages let page = record.pages.find( (p) => ( p as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main - ).id === pageId, + ).id === openPage.id, ) as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main @@ -244,7 +309,7 @@ export function PostPages({ if (!page) return null; return ( - + closePage(page.id!)} + onClick={() => closePage(openPage)} hasPageBackground={hasPageBackground} /> } diff --git a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx index 596e12e2..db63ac6b 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx @@ -1,11 +1,5 @@ import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { useEntitySetContext } from "components/EntitySetProvider"; -import { useEffect, useState } from "react"; -import { useEntity } from "src/replicache"; -import { useUIState } from "src/useUIState"; -import { elementId } from "src/utils/elementId"; -import { focusBlock } from "src/utils/focusBlock"; -import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; +import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; import { Separator } from "components/Layout"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; @@ -16,12 +10,23 @@ import { PostNotAvailable, } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; +import { openPage } from "./PostPages"; +import { ThreadLink } from "./ThreadPage"; export const PubBlueskyPostBlock = (props: { post: PostView; className: string; + pageId?: string; }) => { let post = props.post; + + const handleOpenThread = () => { + openPage( + props.pageId ? { type: "doc", id: props.pageId } : undefined, + { type: "thread", uri: post.uri }, + ); + }; + switch (true) { case AppBskyFeedDefs.isBlockedPost(post) || AppBskyFeedDefs.isBlockedAuthor(post) || @@ -34,13 +39,10 @@ export const PubBlueskyPostBlock = (props: { case AppBskyFeedDefs.validatePostView(post).success: let record = post.record as AppBskyFeedDefs.PostView["record"]; - let facets = record.facets; // silliness to get the text and timestamp from the record with proper types - let text: string | null = null; let timestamp: string | undefined = undefined; if (AppBskyFeedPost.isRecord(record)) { - text = (record as AppBskyFeedPost.Record).text; timestamp = (record as AppBskyFeedPost.Record).createdAt; } @@ -48,13 +50,17 @@ export const PubBlueskyPostBlock = (props: { let postId = post.uri.split("/")[4]; let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; + const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; + return (
{post.author && record && ( @@ -75,6 +81,7 @@ export const PubBlueskyPostBlock = (props: { className="text-xs text-tertiary hover:underline" target="_blank" href={`https://bsky.app/profile/${post.author?.handle}`} + onClick={(e) => e.stopPropagation()} > @{post.author?.handle} @@ -90,7 +97,9 @@ export const PubBlueskyPostBlock = (props: {
{post.embed && ( - +
e.stopPropagation()}> + +
)}
@@ -98,21 +107,27 @@ export const PubBlueskyPostBlock = (props: {
- {post.replyCount && post.replyCount > 0 && ( + {post.replyCount != null && post.replyCount > 0 && ( <> - e.stopPropagation()} > {post.replyCount} - + )} - + e.stopPropagation()} + >
diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index a671285c..8a3b0965 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -40,7 +40,9 @@ export function PublishedPageLinkBlock(props: { }) { //switch to use actually state let openPages = useOpenPages(); - let isOpen = openPages.includes(props.pageId); + let isOpen = openPages.some( + (p) => p.type === "doc" && p.id === props.pageId, + ); return (
{props.isCanvas ? ( @@ -213,9 +218,11 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - openPage(props.parentPageId, props.pageId, { - scrollIntoView: false, - }); + openPage( + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, + { type: "doc", id: props.pageId }, + { scrollIntoView: false }, + ); if (!drawerOpen || drawer !== "quotes") openInteractionDrawer("quotes", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); @@ -231,9 +238,11 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - openPage(props.parentPageId, props.pageId, { - scrollIntoView: false, - }); + openPage( + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, + { type: "doc", id: props.pageId }, + { scrollIntoView: false }, + ); if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) openInteractionDrawer("comments", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); diff --git a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx new file mode 100644 index 00000000..73175688 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -0,0 +1,439 @@ +"use client"; +import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; +import useSWR, { preload } from "swr"; +import { PageWrapper } from "components/Pages/Page"; +import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { DotLoader } from "components/utils/DotLoader"; +import { + BlueskyEmbed, + PostNotAvailable, +} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; +import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { Separator } from "components/Layout"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { useHasPageLoaded } from "components/InitialPageLoadProvider"; +import { openPage, OpenPage } from "./PostPages"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; + +type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; +type NotFoundPost = AppBskyFeedDefs.NotFoundPost; +type BlockedPost = AppBskyFeedDefs.BlockedPost; +type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; + +// SWR key for thread data +export const getThreadKey = (uri: string) => `thread:${uri}`; + +// Fetch thread from API route +export async function fetchThread(uri: string): Promise { + const params = new URLSearchParams({ uri }); + const response = await fetch(`/api/bsky/thread?${params.toString()}`); + + if (!response.ok) { + throw new Error("Failed to fetch thread"); + } + + return response.json(); +} + +// Prefetch thread data +export const prefetchThread = (uri: string) => { + preload(getThreadKey(uri), () => fetchThread(uri)); +}; + +// Link component for opening thread pages with prefetching +export function ThreadLink(props: { + threadUri: string; + parent?: OpenPage; + children: React.ReactNode; + className?: string; + onClick?: (e: React.MouseEvent) => void; +}) { + const { threadUri, parent, children, className, onClick } = props; + + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + openPage(parent, { type: "thread", uri: threadUri }); + }; + + const handlePrefetch = () => { + prefetchThread(threadUri); + }; + + return ( + + ); +} + +export function ThreadPage(props: { + threadUri: string; + pageId: string; + pageOptions?: React.ReactNode; + hasPageBackground: boolean; +}) { + const { threadUri, pageId, pageOptions } = props; + const drawer = useDrawerOpen(threadUri); + + const { + data: thread, + isLoading, + error, + } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => + fetchThread(threadUri), + ); + let cardBorderHidden = useCardBorderHidden(null); + + return ( + +
+ {isLoading ? ( +
+ loading thread + +
+ ) : error ? ( +
+ Failed to load thread +
+ ) : thread ? ( + + ) : null} +
+
+ ); +} + +function ThreadContent(props: { thread: ThreadType; threadUri: string }) { + const { thread, threadUri } = props; + + if (AppBskyFeedDefs.isNotFoundPost(thread)) { + return ; + } + + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return ( +
+ This post is blocked +
+ ); + } + + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { + return ; + } + + // Collect all parent posts in order (oldest first) + const parents: ThreadViewPost[] = []; + let currentParent = thread.parent; + while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { + parents.unshift(currentParent); + currentParent = currentParent.parent; + } + + return ( +
+ {/* Parent posts */} + {parents.map((parent, index) => ( +
+ +
+ ))} + + {/* Main post */} + + + {/* Replies */} + {thread.replies && thread.replies.length > 0 && ( +
+
+ Replies +
+ +
+ )} +
+ ); +} + +function ThreadPost(props: { + post: ThreadViewPost; + isMainPost: boolean; + showReplyLine: boolean; + threadUri: string; +}) { + const { post, isMainPost, showReplyLine, threadUri } = props; + const postView = post.post; + const record = postView.record as AppBskyFeedPost.Record; + + const postId = postView.uri.split("/")[4]; + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; + + return ( +
+ {/* Reply line connector */} + {showReplyLine && ( +
+ )} + +
+ {postView.author.avatar ? ( + {`${postView.author.displayName}'s + ) : ( +
+ )} +
+ +
+
+
+ {postView.author.displayName} +
+ + @{postView.author.handle} + +
+ +
+
+ +
+ {postView.embed && ( + + )} +
+ +
+ +
+ {postView.replyCount != null && postView.replyCount > 0 && ( + <> + {isMainPost ? ( +
+ {postView.replyCount} + +
+ ) : ( + + {postView.replyCount} + + + )} + + + )} + + + +
+
+
+
+ ); +} + +function Replies(props: { + replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; + threadUri: string; + depth: number; +}) { + const { replies, threadUri, depth } = props; + + return ( +
+ {replies.map((reply, index) => { + if (AppBskyFeedDefs.isNotFoundPost(reply)) { + return ( +
+ Post not found +
+ ); + } + + if (AppBskyFeedDefs.isBlockedPost(reply)) { + return ( +
+ Post blocked +
+ ); + } + + if (!AppBskyFeedDefs.isThreadViewPost(reply)) { + return null; + } + + const hasReplies = reply.replies && reply.replies.length > 0; + + return ( +
+ + {hasReplies && depth < 3 && ( +
+ +
+ )} + {hasReplies && depth >= 3 && ( + + View more replies + + )} +
+ ); + })} +
+ ); +} + +function ReplyPost(props: { + post: ThreadViewPost; + showReplyLine: boolean; + isLast: boolean; + threadUri: string; +}) { + const { post, showReplyLine, isLast, threadUri } = props; + const postView = post.post; + const record = postView.record as AppBskyFeedPost.Record; + + const postId = postView.uri.split("/")[4]; + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; + + const parent = { type: "thread" as const, uri: threadUri }; + + return ( +
openPage(parent, { type: "thread", uri: postView.uri })} + > +
+ {postView.author.avatar ? ( + {`${postView.author.displayName}'s + ) : ( +
+ )} +
+ +
+
+
+ {postView.author.displayName} +
+ e.stopPropagation()} + > + @{postView.author.handle} + +
+ +
+ +
+ +
+ + {postView.replyCount != null && postView.replyCount > 0 && ( + <> + + e.stopPropagation()} + > + {postView.replyCount} + + + + )} +
+
+
+ ); +} + +const ClientDate = (props: { date?: string }) => { + const pageLoaded = useHasPageLoaded(); + const formattedDate = useLocalizedDate( + props.date || new Date().toISOString(), + { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }, + ); + + if (!pageLoaded) return null; + + return
{formattedDate}
; +}; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index 68873ded..4f961b43 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -23,24 +23,43 @@ export const BlueskyEmbed = (props: { return (
{imageEmbed.images.map( - (image: { fullsize: string; alt?: string }, i: number) => ( - {image.alt { + const isSingle = imageEmbed.images.length === 1; + const aspectRatio = image.aspectRatio + ? image.aspectRatio.width / image.aspectRatio.height + : undefined; + + return ( + {image.alt - ), + `} + /> + ); + }, )}
); @@ -49,11 +68,11 @@ export const BlueskyEmbed = (props: { let isGif = externalEmbed.external.uri.includes(".gif"); if (isGif) { return ( -
+
{externalEmbed.external.title}
); @@ -66,13 +85,14 @@ export const BlueskyEmbed = (props: { > {externalEmbed.external.thumb === undefined ? null : ( <> - {externalEmbed.external.title} - -
+
+ {externalEmbed.external.title} +
+
)}
@@ -91,16 +111,22 @@ export const BlueskyEmbed = (props: { ); case AppBskyEmbedVideo.isView(props.embed): let videoEmbed = props.embed; + const videoAspectRatio = videoEmbed.aspectRatio + ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height + : 16 / 9; return ( -
+
{ -
+
diff --git a/components/Blocks/BlueskyPostBlock/index.tsx b/components/Blocks/BlueskyPostBlock/index.tsx index bb04a7e3..a19a0775 100644 --- a/components/Blocks/BlueskyPostBlock/index.tsx +++ b/components/Blocks/BlueskyPostBlock/index.tsx @@ -130,7 +130,7 @@ export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => {
{timestamp && } -
+
-- 2.51.2 From 0661999d0d11d5ea093754b6aad7589cd3478899 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 17 Dec 2025 21:38:49 +0400 Subject: [PATCH 14/15] use authed bsky agent for threads if available --- app/api/bsky/thread/route.ts | 42 ++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/api/bsky/thread/route.ts b/app/api/bsky/thread/route.ts index fafbcf42..4247b49f 100644 --- a/app/api/bsky/thread/route.ts +++ b/app/api/bsky/thread/route.ts @@ -1,9 +1,40 @@ import { Agent, lexToJson } from "@atproto/api"; import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { cookies } from "next/headers"; import { NextRequest } from "next/server"; +import { createOauthClient } from "src/atproto-oauth"; +import { supabaseServerClient } from "supabase/serverClient"; export const runtime = "nodejs"; +async function getAuthenticatedAgent(): Promise { + try { + const cookieStore = await cookies(); + const authToken = + cookieStore.get("auth_token")?.value || + cookieStore.get("external_auth_token")?.value; + + if (!authToken || authToken === "null") return null; + + const { data } = await supabaseServerClient + .from("email_auth_tokens") + .select("identities(atp_did)") + .eq("id", authToken) + .eq("confirmed", true) + .single(); + + const did = data?.identities?.atp_did; + if (!did) return null; + + const oauthClient = await createOauthClient(); + const session = await oauthClient.restore(did); + return new Agent(session); + } catch (error) { + console.error("Failed to get authenticated agent:", error); + return null; + } +} + export async function GET(req: NextRequest) { try { const searchParams = req.nextUrl.searchParams; @@ -18,10 +49,13 @@ export async function GET(req: NextRequest) { ); } - // Fetch thread from Bluesky - let agent = new Agent({ - service: "https://public.api.bsky.app", - }); + // Try to use authenticated agent if user is logged in, otherwise fall back to public API + let agent = await getAuthenticatedAgent(); + if (!agent) { + agent = new Agent({ + service: "https://public.api.bsky.app", + }); + } const response = await agent.getPostThread({ uri, -- 2.51.2 From 95dc81d1dec347f651625fe014c5dc65fde2e672 Mon Sep 17 00:00:00 2001 From: Brendan Schlagel Date: Thu, 18 Dec 2025 12:24:07 -0500 Subject: [PATCH 15/15] rename Quotes button to Mentions in post footer --- .../[did]/[publication]/[rkey]/Interactions/Interactions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 94bf6c36..1f545f88 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -247,7 +247,7 @@ export const ExpandedInteractions = (props: { {props.quotesCount}{" "} {`Quote${props.quotesCount === 1 ? "" : "s"}`} + >{`Mention${props.quotesCount === 1 ? "" : "s"}`} )} {props.showComments === false ? null : ( -- 2.51.2