a tool for shared writing and social publishing

add basic thread viewer component

Changed files
+709 -83
app
components
Blocks
BlueskyPostBlock
+44
app/api/bsky/thread/route.ts
··· 1 + import { Agent, lexToJson } from "@atproto/api"; 2 + import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 3 + import { NextRequest } from "next/server"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + export async function GET(req: NextRequest) { 8 + try { 9 + const searchParams = req.nextUrl.searchParams; 10 + const uri = searchParams.get("uri"); 11 + const depth = searchParams.get("depth"); 12 + const parentHeight = searchParams.get("parentHeight"); 13 + 14 + if (!uri) { 15 + return Response.json( 16 + { error: "uri parameter is required" }, 17 + { status: 400 }, 18 + ); 19 + } 20 + 21 + // Fetch thread from Bluesky 22 + let agent = new Agent({ 23 + service: "https://public.api.bsky.app", 24 + }); 25 + 26 + const response = await agent.getPostThread({ 27 + uri, 28 + depth: depth ? parseInt(depth, 10) : 6, 29 + parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80, 30 + }); 31 + 32 + const thread = lexToJson(response.data.thread); 33 + 34 + return Response.json(thread, { 35 + headers: { 36 + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating 37 + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", 38 + }, 39 + }); 40 + } catch (error) { 41 + console.error("Error fetching Bluesky thread:", error); 42 + return Response.json({ error: "Failed to fetch thread" }, { status: 500 }); 43 + } 44 + }
+39 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 4 4 import { useIsMobile } from "src/hooks/isMobile"; 5 5 import { setInteractionState } from "./Interactions"; 6 6 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 - import { AtUri } from "@atproto/api"; 7 + import { AtUri, AppBskyFeedPost } from "@atproto/api"; 8 8 import { PostPageContext } from "../PostPageContext"; 9 9 import { 10 10 PubLeafletBlocksText, ··· 22 22 import { openPage } from "../PostPages"; 23 23 import useSWR, { mutate } from "swr"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 + import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { ThreadLink } from "../ThreadPage"; 25 27 26 28 // Helper to get SWR key for quotes 27 29 export function getQuotesSWRKey(uris: string[]) { ··· 129 131 130 132 <div className="h-5 w-1 ml-5 border-l border-border-light" /> 131 133 <BskyPost 134 + uri={pv.uri} 132 135 rkey={new AtUri(pv.uri).rkey} 133 136 content={pv.record.text as string} 134 137 user={pv.author.displayName || pv.author.handle} 135 138 profile={pv.author} 136 139 handle={pv.author.handle} 140 + replyCount={pv.replyCount} 137 141 /> 138 142 </div> 139 143 ); ··· 150 154 return ( 151 155 <BskyPost 152 156 key={`mention-${index}`} 157 + uri={pv.uri} 153 158 rkey={new AtUri(pv.uri).rkey} 154 159 content={pv.record.text as string} 155 160 user={pv.author.displayName || pv.author.handle} 156 161 profile={pv.author} 157 162 handle={pv.author.handle} 163 + replyCount={pv.replyCount} 158 164 /> 159 165 ); 160 166 })} ··· 201 207 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 202 208 onClick={(e) => { 203 209 if (props.position.pageId) 204 - flushSync(() => openPage(undefined, props.position.pageId!)); 210 + flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); 205 211 let scrollMargin = isMobile 206 212 ? 16 207 213 : e.currentTarget.getBoundingClientRect().top; ··· 239 245 }; 240 246 241 247 export const BskyPost = (props: { 248 + uri: string; 242 249 rkey: string; 243 250 content: string; 244 251 user: string; 245 252 handle: string; 246 253 profile: ProfileViewBasic; 254 + replyCount?: number; 247 255 }) => { 256 + const handleOpenThread = () => { 257 + openPage(undefined, { type: "thread", uri: props.uri }); 258 + }; 259 + 248 260 return ( 249 - <a 250 - target="_blank" 251 - href={`https://bsky.app/profile/${props.handle}/post/${props.rkey}`} 252 - className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal" 261 + <div 262 + onClick={handleOpenThread} 263 + className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded" 253 264 > 254 265 {props.profile.avatar && ( 255 266 <img 256 - className="rounded-full w-6 h-6" 267 + className="rounded-full w-6 h-6 shrink-0" 257 268 src={props.profile.avatar} 258 269 alt={props.profile.displayName} 259 270 /> 260 271 )} 261 - <div className="flex flex-col"> 262 - <div className="flex items-center gap-2"> 272 + <div className="flex flex-col min-w-0"> 273 + <div className="flex items-center gap-2 flex-wrap"> 263 274 <div className="font-bold">{props.user}</div> 264 - <div className="text-tertiary">@{props.handle}</div> 275 + <a 276 + className="text-tertiary hover:underline" 277 + href={`https://bsky.app/profile/${props.handle}`} 278 + target="_blank" 279 + onClick={(e) => e.stopPropagation()} 280 + > 281 + @{props.handle} 282 + </a> 265 283 </div> 266 284 <div className="text-primary">{props.content}</div> 285 + {props.replyCount != null && props.replyCount > 0 && ( 286 + <ThreadLink 287 + threadUri={props.uri} 288 + onClick={(e) => e.stopPropagation()} 289 + className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast" 290 + > 291 + <CommentTiny /> 292 + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 293 + </ThreadLink> 294 + )} 267 295 </div> 268 - </a> 296 + </div> 269 297 ); 270 298 }; 271 299
+1 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 173 173 let uri = b.block.postRef.uri; 174 174 let post = bskyPostData.find((p) => p.uri === uri); 175 175 if (!post) return <div>no prefetched post rip</div>; 176 - return <PubBlueskyPostBlock post={post} className={className} />; 176 + return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />; 177 177 } 178 178 case PubLeafletBlocksIframe.isMain(b.block): { 179 179 return (
+83 -18
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 19 19 import { Fragment, useEffect } from "react"; 20 20 import { flushSync } from "react-dom"; 21 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 - import { useParams } from "next/navigation"; 22 + import { useParams, useSearchParams } from "next/navigation"; 23 23 import { decodeQuotePosition } from "./quotePosition"; 24 24 import { PollData } from "./fetchPollData"; 25 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 26 import { CanvasPage } from "./CanvasPage"; 27 + import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 + 29 + // Page types 30 + export type DocPage = { type: "doc"; id: string }; 31 + export type ThreadPage = { type: "thread"; uri: string }; 32 + export type OpenPage = DocPage | ThreadPage; 33 + 34 + // Get a stable key for a page 35 + const getPageKey = (page: OpenPage): string => { 36 + if (page.type === "doc") return page.id; 37 + return `thread:${page.uri}`; 38 + }; 27 39 28 40 const usePostPageUIState = create(() => ({ 29 - pages: [] as string[], 41 + pages: [] as OpenPage[], 30 42 initialized: false, 31 43 })); 32 44 33 - export const useOpenPages = () => { 45 + export const useOpenPages = (): OpenPage[] => { 34 46 const { quote } = useParams(); 35 47 const state = usePostPageUIState((s) => s); 48 + const searchParams = useSearchParams(); 49 + const pageParam = searchParams.get("page"); 36 50 37 - if (!state.initialized && quote) { 38 - const decodedQuote = decodeQuotePosition(quote as string); 39 - if (decodedQuote?.pageId) { 40 - return [decodedQuote.pageId]; 51 + if (!state.initialized) { 52 + // Check for page search param first (for comment links) 53 + if (pageParam) { 54 + return [{ type: "doc", id: pageParam }]; 55 + } 56 + // Then check for quote param 57 + if (quote) { 58 + const decodedQuote = decodeQuotePosition(quote as string); 59 + if (decodedQuote?.pageId) { 60 + return [{ type: "doc", id: decodedQuote.pageId }]; 61 + } 41 62 } 42 63 } 43 64 ··· 46 67 47 68 export const useInitializeOpenPages = () => { 48 69 const { quote } = useParams(); 70 + const searchParams = useSearchParams(); 71 + const pageParam = searchParams.get("page"); 49 72 50 73 useEffect(() => { 51 74 const state = usePostPageUIState.getState(); 52 75 if (!state.initialized) { 76 + // Check for page search param first (for comment links) 77 + if (pageParam) { 78 + usePostPageUIState.setState({ 79 + pages: [{ type: "doc", id: pageParam }], 80 + initialized: true, 81 + }); 82 + return; 83 + } 84 + 85 + // Then check for quote param 53 86 if (quote) { 54 87 const decodedQuote = decodeQuotePosition(quote as string); 55 88 if (decodedQuote?.pageId) { 56 89 usePostPageUIState.setState({ 57 - pages: [decodedQuote.pageId], 90 + pages: [{ type: "doc", id: decodedQuote.pageId }], 58 91 initialized: true, 59 92 }); 60 93 return; ··· 67 100 }; 68 101 69 102 export const openPage = ( 70 - parent: string | undefined, 71 - page: string, 103 + parent: OpenPage | undefined, 104 + page: OpenPage, 72 105 options?: { scrollIntoView?: boolean }, 73 106 ) => { 107 + const pageKey = getPageKey(page); 108 + const parentKey = parent ? getPageKey(parent) : undefined; 109 + 74 110 flushSync(() => { 75 111 usePostPageUIState.setState((state) => { 76 - let parentPosition = state.pages.findIndex((s) => s == parent); 112 + let parentPosition = state.pages.findIndex( 113 + (s) => getPageKey(s) === parentKey, 114 + ); 77 115 return { 78 116 pages: 79 117 parentPosition === -1 ··· 85 123 }); 86 124 87 125 if (options?.scrollIntoView !== false) { 88 - scrollIntoView(`post-page-${page}`); 126 + scrollIntoView(`post-page-${pageKey}`); 89 127 } 90 128 }; 91 129 92 - export const closePage = (page: string) => 130 + export const closePage = (page: OpenPage) => { 131 + const pageKey = getPageKey(page); 93 132 usePostPageUIState.setState((state) => { 94 - let parentPosition = state.pages.findIndex((s) => s == page); 133 + let parentPosition = state.pages.findIndex( 134 + (s) => getPageKey(s) === pageKey, 135 + ); 95 136 return { 96 137 pages: state.pages.slice(0, parentPosition), 97 138 initialized: true, 98 139 }; 99 140 }); 141 + }; 100 142 101 143 // Shared props type for both page components 102 144 export type SharedPageProps = { ··· 228 270 /> 229 271 )} 230 272 231 - {openPageIds.map((pageId) => { 273 + {openPageIds.map((openPage) => { 274 + const pageKey = getPageKey(openPage); 275 + 276 + // Handle thread pages 277 + if (openPage.type === "thread") { 278 + return ( 279 + <Fragment key={pageKey}> 280 + <SandwichSpacer /> 281 + <ThreadPageComponent 282 + threadUri={openPage.uri} 283 + pageId={pageKey} 284 + hasPageBackground={hasPageBackground} 285 + pageOptions={ 286 + <PageOptions 287 + onClick={() => closePage(openPage)} 288 + hasPageBackground={hasPageBackground} 289 + /> 290 + } 291 + /> 292 + </Fragment> 293 + ); 294 + } 295 + 296 + // Handle document pages 232 297 let page = record.pages.find( 233 298 (p) => 234 299 ( 235 300 p as 236 301 | PubLeafletPagesLinearDocument.Main 237 302 | PubLeafletPagesCanvas.Main 238 - ).id === pageId, 303 + ).id === openPage.id, 239 304 ) as 240 305 | PubLeafletPagesLinearDocument.Main 241 306 | PubLeafletPagesCanvas.Main ··· 244 309 if (!page) return null; 245 310 246 311 return ( 247 - <Fragment key={pageId}> 312 + <Fragment key={pageKey}> 248 313 <SandwichSpacer /> 249 314 <PageRenderer 250 315 page={page} ··· 253 318 pageId={page.id} 254 319 pageOptions={ 255 320 <PageOptions 256 - onClick={() => closePage(page.id!)} 321 + onClick={() => closePage(openPage)} 257 322 hasPageBackground={hasPageBackground} 258 323 /> 259 324 }
+33 -18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 1 1 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 - import { useEntitySetContext } from "components/EntitySetProvider"; 3 - import { useEffect, useState } from "react"; 4 - import { useEntity } from "src/replicache"; 5 - import { useUIState } from "src/useUIState"; 6 - import { elementId } from "src/utils/elementId"; 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 9 3 import { Separator } from "components/Layout"; 10 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; ··· 16 10 PostNotAvailable, 17 11 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 18 12 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 13 + import { openPage } from "./PostPages"; 14 + import { ThreadLink } from "./ThreadPage"; 19 15 20 16 export const PubBlueskyPostBlock = (props: { 21 17 post: PostView; 22 18 className: string; 19 + pageId?: string; 23 20 }) => { 24 21 let post = props.post; 22 + 23 + const handleOpenThread = () => { 24 + openPage( 25 + props.pageId ? { type: "doc", id: props.pageId } : undefined, 26 + { type: "thread", uri: post.uri }, 27 + ); 28 + }; 29 + 25 30 switch (true) { 26 31 case AppBskyFeedDefs.isBlockedPost(post) || 27 32 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 34 39 35 40 case AppBskyFeedDefs.validatePostView(post).success: 36 41 let record = post.record as AppBskyFeedDefs.PostView["record"]; 37 - let facets = record.facets; 38 42 39 43 // silliness to get the text and timestamp from the record with proper types 40 - let text: string | null = null; 41 44 let timestamp: string | undefined = undefined; 42 45 if (AppBskyFeedPost.isRecord(record)) { 43 - text = (record as AppBskyFeedPost.Record).text; 44 46 timestamp = (record as AppBskyFeedPost.Record).createdAt; 45 47 } 46 48 ··· 48 50 let postId = post.uri.split("/")[4]; 49 51 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 50 52 53 + const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; 54 + 51 55 return ( 52 56 <div 57 + onClick={handleOpenThread} 53 58 className={` 54 59 ${props.className} 55 60 block-border 56 61 mb-2 57 62 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 63 + cursor-pointer hover:border-accent-contrast 58 64 `} 59 65 > 60 66 {post.author && record && ( ··· 75 81 className="text-xs text-tertiary hover:underline" 76 82 target="_blank" 77 83 href={`https://bsky.app/profile/${post.author?.handle}`} 84 + onClick={(e) => e.stopPropagation()} 78 85 > 79 86 @{post.author?.handle} 80 87 </a> ··· 90 97 </pre> 91 98 </div> 92 99 {post.embed && ( 93 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 100 + <div onClick={(e) => e.stopPropagation()}> 101 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 102 + </div> 94 103 )} 95 104 </div> 96 105 </> ··· 98 107 <div className="w-full flex gap-2 items-center justify-between"> 99 108 <ClientDate date={timestamp} /> 100 109 <div className="flex gap-2 items-center"> 101 - {post.replyCount && post.replyCount > 0 && ( 110 + {post.replyCount != null && post.replyCount > 0 && ( 102 111 <> 103 - <a 104 - className="flex items-center gap-1 hover:no-underline" 105 - target="_blank" 106 - href={url} 112 + <ThreadLink 113 + threadUri={post.uri} 114 + parent={parent} 115 + className="flex items-center gap-1 hover:text-accent-contrast" 116 + onClick={(e) => e.stopPropagation()} 107 117 > 108 118 {post.replyCount} 109 119 <CommentTiny /> 110 - </a> 120 + </ThreadLink> 111 121 <Separator classname="h-4" /> 112 122 </> 113 123 )} 114 124 115 - <a className="" target="_blank" href={url}> 125 + <a 126 + className="" 127 + target="_blank" 128 + href={url} 129 + onClick={(e) => e.stopPropagation()} 130 + > 116 131 <BlueskyTiny /> 117 132 </a> 118 133 </div>
+17 -8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 40 40 }) { 41 41 //switch to use actually state 42 42 let openPages = useOpenPages(); 43 - let isOpen = openPages.includes(props.pageId); 43 + let isOpen = openPages.some( 44 + (p) => p.type === "doc" && p.id === props.pageId, 45 + ); 44 46 return ( 45 47 <div 46 48 className={`w-full cursor-pointer ··· 57 59 e.preventDefault(); 58 60 e.stopPropagation(); 59 61 60 - openPage(props.parentPageId, props.pageId); 62 + openPage( 63 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 64 + { type: "doc", id: props.pageId }, 65 + ); 61 66 }} 62 67 > 63 68 {props.isCanvas ? ( ··· 213 218 onClick={(e) => { 214 219 e.preventDefault(); 215 220 e.stopPropagation(); 216 - openPage(props.parentPageId, props.pageId, { 217 - scrollIntoView: false, 218 - }); 221 + openPage( 222 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 223 + { type: "doc", id: props.pageId }, 224 + { scrollIntoView: false }, 225 + ); 219 226 if (!drawerOpen || drawer !== "quotes") 220 227 openInteractionDrawer("quotes", document_uri, props.pageId); 221 228 else setInteractionState(document_uri, { drawerOpen: false }); ··· 231 238 onClick={(e) => { 232 239 e.preventDefault(); 233 240 e.stopPropagation(); 234 - openPage(props.parentPageId, props.pageId, { 235 - scrollIntoView: false, 236 - }); 241 + openPage( 242 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 243 + { type: "doc", id: props.pageId }, 244 + { scrollIntoView: false }, 245 + ); 237 246 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 238 247 openInteractionDrawer("comments", document_uri, props.pageId); 239 248 else setInteractionState(document_uri, { drawerOpen: false });
+439
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import useSWR, { preload } from "swr"; 4 + import { PageWrapper } from "components/Pages/Page"; 5 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 + import { DotLoader } from "components/utils/DotLoader"; 7 + import { 8 + BlueskyEmbed, 9 + PostNotAvailable, 10 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 11 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 12 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 13 + import { CommentTiny } from "components/Icons/CommentTiny"; 14 + import { Separator } from "components/Layout"; 15 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 17 + import { openPage, OpenPage } from "./PostPages"; 18 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 19 + 20 + type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 21 + type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 22 + type BlockedPost = AppBskyFeedDefs.BlockedPost; 23 + type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 24 + 25 + // SWR key for thread data 26 + export const getThreadKey = (uri: string) => `thread:${uri}`; 27 + 28 + // Fetch thread from API route 29 + export async function fetchThread(uri: string): Promise<ThreadType> { 30 + const params = new URLSearchParams({ uri }); 31 + const response = await fetch(`/api/bsky/thread?${params.toString()}`); 32 + 33 + if (!response.ok) { 34 + throw new Error("Failed to fetch thread"); 35 + } 36 + 37 + return response.json(); 38 + } 39 + 40 + // Prefetch thread data 41 + export const prefetchThread = (uri: string) => { 42 + preload(getThreadKey(uri), () => fetchThread(uri)); 43 + }; 44 + 45 + // Link component for opening thread pages with prefetching 46 + export function ThreadLink(props: { 47 + threadUri: string; 48 + parent?: OpenPage; 49 + children: React.ReactNode; 50 + className?: string; 51 + onClick?: (e: React.MouseEvent) => void; 52 + }) { 53 + const { threadUri, parent, children, className, onClick } = props; 54 + 55 + const handleClick = (e: React.MouseEvent) => { 56 + onClick?.(e); 57 + if (e.defaultPrevented) return; 58 + openPage(parent, { type: "thread", uri: threadUri }); 59 + }; 60 + 61 + const handlePrefetch = () => { 62 + prefetchThread(threadUri); 63 + }; 64 + 65 + return ( 66 + <button 67 + className={className} 68 + onClick={handleClick} 69 + onMouseEnter={handlePrefetch} 70 + onPointerDown={handlePrefetch} 71 + > 72 + {children} 73 + </button> 74 + ); 75 + } 76 + 77 + export function ThreadPage(props: { 78 + threadUri: string; 79 + pageId: string; 80 + pageOptions?: React.ReactNode; 81 + hasPageBackground: boolean; 82 + }) { 83 + const { threadUri, pageId, pageOptions } = props; 84 + const drawer = useDrawerOpen(threadUri); 85 + 86 + const { 87 + data: thread, 88 + isLoading, 89 + error, 90 + } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 91 + fetchThread(threadUri), 92 + ); 93 + let cardBorderHidden = useCardBorderHidden(null); 94 + 95 + return ( 96 + <PageWrapper 97 + cardBorderHidden={!!cardBorderHidden} 98 + pageType="doc" 99 + fullPageScroll={false} 100 + id={`post-page-${pageId}`} 101 + drawerOpen={!!drawer} 102 + pageOptions={pageOptions} 103 + > 104 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 105 + {isLoading ? ( 106 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 107 + <span>loading thread</span> 108 + <DotLoader /> 109 + </div> 110 + ) : error ? ( 111 + <div className="text-tertiary italic text-sm text-center py-8"> 112 + Failed to load thread 113 + </div> 114 + ) : thread ? ( 115 + <ThreadContent thread={thread} threadUri={threadUri} /> 116 + ) : null} 117 + </div> 118 + </PageWrapper> 119 + ); 120 + } 121 + 122 + function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 123 + const { thread, threadUri } = props; 124 + 125 + if (AppBskyFeedDefs.isNotFoundPost(thread)) { 126 + return <PostNotAvailable />; 127 + } 128 + 129 + if (AppBskyFeedDefs.isBlockedPost(thread)) { 130 + return ( 131 + <div className="text-tertiary italic text-sm text-center py-8"> 132 + This post is blocked 133 + </div> 134 + ); 135 + } 136 + 137 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 138 + return <PostNotAvailable />; 139 + } 140 + 141 + // Collect all parent posts in order (oldest first) 142 + const parents: ThreadViewPost[] = []; 143 + let currentParent = thread.parent; 144 + while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 145 + parents.unshift(currentParent); 146 + currentParent = currentParent.parent; 147 + } 148 + 149 + return ( 150 + <div className="flex flex-col gap-0"> 151 + {/* Parent posts */} 152 + {parents.map((parent, index) => ( 153 + <div key={parent.post.uri} className="flex flex-col"> 154 + <ThreadPost 155 + post={parent} 156 + isMainPost={false} 157 + showReplyLine={index < parents.length - 1 || true} 158 + threadUri={threadUri} 159 + /> 160 + </div> 161 + ))} 162 + 163 + {/* Main post */} 164 + <ThreadPost 165 + post={thread} 166 + isMainPost={true} 167 + showReplyLine={false} 168 + threadUri={threadUri} 169 + /> 170 + 171 + {/* Replies */} 172 + {thread.replies && thread.replies.length > 0 && ( 173 + <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 174 + <div className="text-tertiary text-xs font-bold mb-2 px-2"> 175 + Replies 176 + </div> 177 + <Replies 178 + replies={thread.replies as any[]} 179 + threadUri={threadUri} 180 + depth={0} 181 + /> 182 + </div> 183 + )} 184 + </div> 185 + ); 186 + } 187 + 188 + function ThreadPost(props: { 189 + post: ThreadViewPost; 190 + isMainPost: boolean; 191 + showReplyLine: boolean; 192 + threadUri: string; 193 + }) { 194 + const { post, isMainPost, showReplyLine, threadUri } = props; 195 + const postView = post.post; 196 + const record = postView.record as AppBskyFeedPost.Record; 197 + 198 + const postId = postView.uri.split("/")[4]; 199 + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 200 + 201 + return ( 202 + <div className="flex gap-2 relative"> 203 + {/* Reply line connector */} 204 + {showReplyLine && ( 205 + <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 206 + )} 207 + 208 + <div className="flex flex-col items-center shrink-0"> 209 + {postView.author.avatar ? ( 210 + <img 211 + src={postView.author.avatar} 212 + alt={`${postView.author.displayName}'s avatar`} 213 + className="w-10 h-10 rounded-full border border-border-light" 214 + /> 215 + ) : ( 216 + <div className="w-10 h-10 rounded-full border border-border-light bg-border" /> 217 + )} 218 + </div> 219 + 220 + <div 221 + className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`} 222 + > 223 + <div className="flex items-center gap-2 leading-tight"> 224 + <div className="font-bold text-secondary"> 225 + {postView.author.displayName} 226 + </div> 227 + <a 228 + className="text-xs text-tertiary hover:underline" 229 + target="_blank" 230 + href={`https://bsky.app/profile/${postView.author.handle}`} 231 + > 232 + @{postView.author.handle} 233 + </a> 234 + </div> 235 + 236 + <div className="flex flex-col gap-2 mt-1"> 237 + <div className="text-sm text-secondary"> 238 + <BlueskyRichText record={record} /> 239 + </div> 240 + {postView.embed && ( 241 + <BlueskyEmbed embed={postView.embed} postUrl={url} /> 242 + )} 243 + </div> 244 + 245 + <div className="flex gap-2 items-center justify-between mt-2"> 246 + <ClientDate date={record.createdAt} /> 247 + <div className="flex gap-2 items-center"> 248 + {postView.replyCount != null && postView.replyCount > 0 && ( 249 + <> 250 + {isMainPost ? ( 251 + <div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs"> 252 + {postView.replyCount} 253 + <CommentTiny /> 254 + </div> 255 + ) : ( 256 + <ThreadLink 257 + threadUri={postView.uri} 258 + parent={{ type: "thread", uri: threadUri }} 259 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 260 + > 261 + {postView.replyCount} 262 + <CommentTiny /> 263 + </ThreadLink> 264 + )} 265 + <Separator classname="h-4" /> 266 + </> 267 + )} 268 + <a className="text-tertiary" target="_blank" href={url}> 269 + <BlueskyTiny /> 270 + </a> 271 + </div> 272 + </div> 273 + </div> 274 + </div> 275 + ); 276 + } 277 + 278 + function Replies(props: { 279 + replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 280 + threadUri: string; 281 + depth: number; 282 + }) { 283 + const { replies, threadUri, depth } = props; 284 + 285 + return ( 286 + <div className="flex flex-col gap-0"> 287 + {replies.map((reply, index) => { 288 + if (AppBskyFeedDefs.isNotFoundPost(reply)) { 289 + return ( 290 + <div 291 + key={`not-found-${index}`} 292 + className="text-tertiary italic text-xs py-2 px-2" 293 + > 294 + Post not found 295 + </div> 296 + ); 297 + } 298 + 299 + if (AppBskyFeedDefs.isBlockedPost(reply)) { 300 + return ( 301 + <div 302 + key={`blocked-${index}`} 303 + className="text-tertiary italic text-xs py-2 px-2" 304 + > 305 + Post blocked 306 + </div> 307 + ); 308 + } 309 + 310 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) { 311 + return null; 312 + } 313 + 314 + const hasReplies = reply.replies && reply.replies.length > 0; 315 + 316 + return ( 317 + <div key={reply.post.uri} className="flex flex-col"> 318 + <ReplyPost 319 + post={reply} 320 + showReplyLine={hasReplies || index < replies.length - 1} 321 + isLast={index === replies.length - 1 && !hasReplies} 322 + threadUri={threadUri} 323 + /> 324 + {hasReplies && depth < 3 && ( 325 + <div className="ml-5 pl-5 border-l border-border-light"> 326 + <Replies 327 + replies={reply.replies as any[]} 328 + threadUri={threadUri} 329 + depth={depth + 1} 330 + /> 331 + </div> 332 + )} 333 + {hasReplies && depth >= 3 && ( 334 + <ThreadLink 335 + threadUri={reply.post.uri} 336 + parent={{ type: "thread", uri: threadUri }} 337 + className="ml-12 text-xs text-accent-contrast hover:underline py-1" 338 + > 339 + View more replies 340 + </ThreadLink> 341 + )} 342 + </div> 343 + ); 344 + })} 345 + </div> 346 + ); 347 + } 348 + 349 + function ReplyPost(props: { 350 + post: ThreadViewPost; 351 + showReplyLine: boolean; 352 + isLast: boolean; 353 + threadUri: string; 354 + }) { 355 + const { post, showReplyLine, isLast, threadUri } = props; 356 + const postView = post.post; 357 + const record = postView.record as AppBskyFeedPost.Record; 358 + 359 + const postId = postView.uri.split("/")[4]; 360 + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 361 + 362 + const parent = { type: "thread" as const, uri: threadUri }; 363 + 364 + return ( 365 + <div 366 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 367 + onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 368 + > 369 + <div className="flex flex-col items-center shrink-0"> 370 + {postView.author.avatar ? ( 371 + <img 372 + src={postView.author.avatar} 373 + alt={`${postView.author.displayName}'s avatar`} 374 + className="w-8 h-8 rounded-full border border-border-light" 375 + /> 376 + ) : ( 377 + <div className="w-8 h-8 rounded-full border border-border-light bg-border" /> 378 + )} 379 + </div> 380 + 381 + <div className="flex flex-col grow min-w-0"> 382 + <div className="flex items-center gap-2 leading-tight text-sm"> 383 + <div className="font-bold text-secondary"> 384 + {postView.author.displayName} 385 + </div> 386 + <a 387 + className="text-xs text-tertiary hover:underline" 388 + target="_blank" 389 + href={`https://bsky.app/profile/${postView.author.handle}`} 390 + onClick={(e) => e.stopPropagation()} 391 + > 392 + @{postView.author.handle} 393 + </a> 394 + </div> 395 + 396 + <div className="text-sm text-secondary mt-0.5 line-clamp-3"> 397 + <BlueskyRichText record={record} /> 398 + </div> 399 + 400 + <div className="flex gap-2 items-center mt-1"> 401 + <ClientDate date={record.createdAt} /> 402 + {postView.replyCount != null && postView.replyCount > 0 && ( 403 + <> 404 + <Separator classname="h-3" /> 405 + <ThreadLink 406 + threadUri={postView.uri} 407 + parent={parent} 408 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 409 + onClick={(e) => e.stopPropagation()} 410 + > 411 + {postView.replyCount} 412 + <CommentTiny /> 413 + </ThreadLink> 414 + </> 415 + )} 416 + </div> 417 + </div> 418 + </div> 419 + ); 420 + } 421 + 422 + const ClientDate = (props: { date?: string }) => { 423 + const pageLoaded = useHasPageLoaded(); 424 + const formattedDate = useLocalizedDate( 425 + props.date || new Date().toISOString(), 426 + { 427 + month: "short", 428 + day: "numeric", 429 + year: "numeric", 430 + hour: "numeric", 431 + minute: "numeric", 432 + hour12: true, 433 + }, 434 + ); 435 + 436 + if (!pageLoaded) return null; 437 + 438 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 439 + };
+52 -26
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 23 23 return ( 24 24 <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 25 25 {imageEmbed.images.map( 26 - (image: { fullsize: string; alt?: string }, i: number) => ( 27 - <img 28 - key={i} 29 - src={image.fullsize} 30 - alt={image.alt || "Post image"} 31 - className={` 32 - overflow-hidden w-full object-cover 33 - ${imageEmbed.images.length === 1 && "h-auto max-h-[800px]"} 34 - ${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"} 35 - ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 26 + ( 27 + image: { 28 + fullsize: string; 29 + alt?: string; 30 + aspectRatio?: { width: number; height: number }; 31 + }, 32 + i: number, 33 + ) => { 34 + const isSingle = imageEmbed.images.length === 1; 35 + const aspectRatio = image.aspectRatio 36 + ? image.aspectRatio.width / image.aspectRatio.height 37 + : undefined; 38 + 39 + return ( 40 + <img 41 + key={i} 42 + src={image.fullsize} 43 + alt={image.alt || "Post image"} 44 + style={ 45 + isSingle && aspectRatio 46 + ? { aspectRatio: String(aspectRatio) } 47 + : undefined 48 + } 49 + className={` 50 + overflow-hidden w-full object-cover 51 + ${isSingle && "max-h-[800px]"} 52 + ${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"} 53 + ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 36 54 ${ 37 55 imageEmbed.images.length === 4 38 56 ? "basis-1/2 aspect-3/2" 39 - : `basis-1/${imageEmbed.images.length} ` 57 + : `basis-1/${imageEmbed.images.length}` 40 58 } 41 - `} 42 - /> 43 - ), 59 + `} 60 + /> 61 + ); 62 + }, 44 63 )} 45 64 </div> 46 65 ); ··· 49 68 let isGif = externalEmbed.external.uri.includes(".gif"); 50 69 if (isGif) { 51 70 return ( 52 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden"> 71 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 53 72 <img 54 73 src={externalEmbed.external.uri} 55 74 alt={externalEmbed.external.title} 56 - className="object-cover" 75 + className="w-full h-full object-cover" 57 76 /> 58 77 </div> 59 78 ); ··· 66 85 > 67 86 {externalEmbed.external.thumb === undefined ? null : ( 68 87 <> 69 - <img 70 - src={externalEmbed.external.thumb} 71 - alt={externalEmbed.external.title} 72 - className="object-cover" 73 - /> 74 - 75 - <hr className="border-border-light " /> 88 + <div className="w-full aspect-[1.91/1] overflow-hidden"> 89 + <img 90 + src={externalEmbed.external.thumb} 91 + alt={externalEmbed.external.title} 92 + className="w-full h-full object-cover" 93 + /> 94 + </div> 95 + <hr className="border-border-light" /> 76 96 </> 77 97 )} 78 98 <div className="p-2 flex flex-col gap-1"> ··· 91 111 ); 92 112 case AppBskyEmbedVideo.isView(props.embed): 93 113 let videoEmbed = props.embed; 114 + const videoAspectRatio = videoEmbed.aspectRatio 115 + ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height 116 + : 16 / 9; 94 117 return ( 95 - <div className="rounded-md overflow-hidden relative"> 118 + <div 119 + className="rounded-md overflow-hidden relative w-full" 120 + style={{ aspectRatio: String(videoAspectRatio) }} 121 + > 96 122 <img 97 123 src={videoEmbed.thumbnail} 98 124 alt={ 99 125 "Thumbnail from embedded video. Go to Bluesky to see the full post." 100 126 } 101 - className={`overflow-hidden w-full object-cover`} 127 + className="absolute inset-0 w-full h-full object-cover" 102 128 /> 103 - <div className="overlay absolute top-0 right-0 left-0 bottom-0 bg-primary opacity-65" /> 129 + <div className="overlay absolute inset-0 bg-primary opacity-65" /> 104 130 <div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md"> 105 131 <SeePostOnBluesky postUrl={props.postUrl} /> 106 132 </div>
+1 -1
components/Blocks/BlueskyPostBlock/index.tsx
··· 130 130 <div className="w-full flex gap-2 items-center justify-between"> 131 131 {timestamp && <PostDate timestamp={timestamp} />} 132 132 <div className="flex gap-2 items-center"> 133 - {post.post.replyCount && post.post.replyCount > 0 && ( 133 + {post.post.replyCount != null && post.post.replyCount > 0 && ( 134 134 <> 135 135 <a 136 136 className="flex items-center gap-1 hover:no-underline"