a tool for shared writing and social publishing

Merge branch 'main' into feature/profiles

+964 -109
+6 -6
actions/publishToPublication.ts
··· 305 305 if (!b) return []; 306 306 let block: PubLeafletPagesLinearDocument.Block = { 307 307 $type: "pub.leaflet.pages.linearDocument#block", 308 - alignment, 309 308 block: b, 310 309 }; 310 + if (alignment) block.alignment = alignment; 311 311 return [block]; 312 312 } else { 313 313 let block: PubLeafletPagesLinearDocument.Block = { ··· 405 405 let [stringValue, facets] = getBlockContent(b.value); 406 406 let block: $Typed<PubLeafletBlocksHeader.Main> = { 407 407 $type: "pub.leaflet.blocks.header", 408 - level: headingLevel?.data.value || 1, 408 + level: Math.floor(headingLevel?.data.value || 1), 409 409 plaintext: stringValue, 410 410 facets, 411 411 }; ··· 438 438 let block: $Typed<PubLeafletBlocksIframe.Main> = { 439 439 $type: "pub.leaflet.blocks.iframe", 440 440 url: url.data.value, 441 - height: height?.data.value || 600, 441 + height: Math.floor(height?.data.value || 600), 442 442 }; 443 443 return block; 444 444 } ··· 452 452 $type: "pub.leaflet.blocks.image", 453 453 image: blobref, 454 454 aspectRatio: { 455 - height: image.data.height, 456 - width: image.data.width, 455 + height: Math.floor(image.data.height), 456 + width: Math.floor(image.data.width), 457 457 }, 458 458 alt: altText ? altText.data.value : undefined, 459 459 }; ··· 770 770 image: blob.data.blob, 771 771 repeat: backgroundImageRepeat?.data.value ? true : false, 772 772 ...(backgroundImageRepeat?.data.value && { 773 - width: backgroundImageRepeat.data.value, 773 + width: Math.floor(backgroundImageRepeat.data.value), 774 774 }), 775 775 }; 776 776 }
+1 -4
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 10 10 export async function getDocumentsByTag( 11 11 tag: string, 12 12 ): Promise<{ posts: Post[] }> { 13 - // Normalize tag to lowercase for case-insensitive search 14 - const normalizedTag = tag.toLowerCase(); 15 - 16 13 // Query documents that have this tag 17 14 const { data: documents, error } = await supabaseServerClient 18 15 .from("documents") ··· 22 19 document_mentions_in_bsky(count), 23 20 documents_in_publications(publications(*))`, 24 21 ) 25 - .contains("data->tags", `["${normalizedTag}"]`) 22 + .contains("data->tags", `["${tag}"]`) 26 23 .order("indexed_at", { ascending: false }) 27 24 .limit(50); 28 25
+78
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 { cookies } from "next/headers"; 4 + import { NextRequest } from "next/server"; 5 + import { createOauthClient } from "src/atproto-oauth"; 6 + import { supabaseServerClient } from "supabase/serverClient"; 7 + 8 + export const runtime = "nodejs"; 9 + 10 + async function getAuthenticatedAgent(): Promise<Agent | null> { 11 + try { 12 + const cookieStore = await cookies(); 13 + const authToken = 14 + cookieStore.get("auth_token")?.value || 15 + cookieStore.get("external_auth_token")?.value; 16 + 17 + if (!authToken || authToken === "null") return null; 18 + 19 + const { data } = await supabaseServerClient 20 + .from("email_auth_tokens") 21 + .select("identities(atp_did)") 22 + .eq("id", authToken) 23 + .eq("confirmed", true) 24 + .single(); 25 + 26 + const did = data?.identities?.atp_did; 27 + if (!did) return null; 28 + 29 + const oauthClient = await createOauthClient(); 30 + const session = await oauthClient.restore(did); 31 + return new Agent(session); 32 + } catch (error) { 33 + console.error("Failed to get authenticated agent:", error); 34 + return null; 35 + } 36 + } 37 + 38 + export async function GET(req: NextRequest) { 39 + try { 40 + const searchParams = req.nextUrl.searchParams; 41 + const uri = searchParams.get("uri"); 42 + const depth = searchParams.get("depth"); 43 + const parentHeight = searchParams.get("parentHeight"); 44 + 45 + if (!uri) { 46 + return Response.json( 47 + { error: "uri parameter is required" }, 48 + { status: 400 }, 49 + ); 50 + } 51 + 52 + // Try to use authenticated agent if user is logged in, otherwise fall back to public API 53 + let agent = await getAuthenticatedAgent(); 54 + if (!agent) { 55 + agent = new Agent({ 56 + service: "https://public.api.bsky.app", 57 + }); 58 + } 59 + 60 + const response = await agent.getPostThread({ 61 + uri, 62 + depth: depth ? parseInt(depth, 10) : 6, 63 + parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80, 64 + }); 65 + 66 + const thread = lexToJson(response.data.thread); 67 + 68 + return Response.json(thread, { 69 + headers: { 70 + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating 71 + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", 72 + }, 73 + }); 74 + } catch (error) { 75 + console.error("Error fetching Bluesky thread:", error); 76 + return Response.json({ error: "Failed to fetch thread" }, { status: 500 }); 77 + } 78 + }
+215
app/api/unstable_validate/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtpAgent, AtUri } from "@atproto/api"; 3 + import { DidResolver } from "@atproto/identity"; 4 + import { 5 + PubLeafletDocument, 6 + PubLeafletPublication, 7 + PubLeafletPagesLinearDocument, 8 + PubLeafletPagesCanvas, 9 + } from "lexicons/api"; 10 + 11 + const didResolver = new DidResolver({}); 12 + 13 + export async function GET(request: NextRequest) { 14 + try { 15 + const atUriString = request.nextUrl.searchParams.get("uri"); 16 + 17 + if (!atUriString) { 18 + return NextResponse.json( 19 + { 20 + success: false, 21 + error: "Missing uri parameter", 22 + }, 23 + { status: 400 }, 24 + ); 25 + } 26 + 27 + const uri = new AtUri(atUriString); 28 + 29 + // Only allow document and publication collections 30 + if ( 31 + uri.collection !== "pub.leaflet.document" && 32 + uri.collection !== "pub.leaflet.publication" 33 + ) { 34 + return NextResponse.json( 35 + { 36 + success: false, 37 + error: 38 + "Unsupported collection type. Must be pub.leaflet.document or pub.leaflet.publication", 39 + }, 40 + { status: 400 }, 41 + ); 42 + } 43 + 44 + // Resolve DID to get service endpoint 45 + const did = await didResolver.resolve(uri.host); 46 + const service = did?.service?.[0]; 47 + 48 + if (!service) { 49 + return NextResponse.json( 50 + { 51 + success: false, 52 + error: "Could not resolve DID service endpoint", 53 + }, 54 + { status: 404 }, 55 + ); 56 + } 57 + 58 + // Fetch the record from AT Protocol 59 + const agent = new AtpAgent({ service: service.serviceEndpoint as string }); 60 + 61 + let recordResponse; 62 + try { 63 + recordResponse = await agent.com.atproto.repo.getRecord({ 64 + repo: uri.host, 65 + collection: uri.collection, 66 + rkey: uri.rkey, 67 + }); 68 + } catch (e) { 69 + return NextResponse.json( 70 + { 71 + success: false, 72 + error: "Record not found", 73 + }, 74 + { status: 404 }, 75 + ); 76 + } 77 + 78 + // Validate based on collection type 79 + if (uri.collection === "pub.leaflet.document") { 80 + const result = PubLeafletDocument.validateRecord( 81 + recordResponse.data.value, 82 + ); 83 + if (result.success) { 84 + return NextResponse.json({ 85 + success: true, 86 + collection: uri.collection, 87 + record: result.value, 88 + }); 89 + } else { 90 + // Detailed validation: validate pages and blocks individually 91 + const record = recordResponse.data.value as { 92 + pages?: Array<{ $type?: string; blocks?: Array<{ block?: unknown }> }>; 93 + }; 94 + const pageErrors: Array<{ 95 + pageIndex: number; 96 + pageType: string; 97 + error?: unknown; 98 + blockErrors?: Array<{ 99 + blockIndex: number; 100 + blockType: string; 101 + error: unknown; 102 + block: unknown; 103 + }>; 104 + }> = []; 105 + 106 + if (record.pages && Array.isArray(record.pages)) { 107 + for (let pageIndex = 0; pageIndex < record.pages.length; pageIndex++) { 108 + const page = record.pages[pageIndex]; 109 + const pageType = page?.$type || "unknown"; 110 + 111 + // Validate page based on type 112 + let pageResult; 113 + if (pageType === "pub.leaflet.pages.linearDocument") { 114 + pageResult = PubLeafletPagesLinearDocument.validateMain(page); 115 + } else if (pageType === "pub.leaflet.pages.canvas") { 116 + pageResult = PubLeafletPagesCanvas.validateMain(page); 117 + } else { 118 + pageErrors.push({ 119 + pageIndex, 120 + pageType, 121 + error: `Unknown page type: ${pageType}`, 122 + }); 123 + continue; 124 + } 125 + 126 + if (!pageResult.success) { 127 + // Page has errors, validate individual blocks 128 + const blockErrors: Array<{ 129 + blockIndex: number; 130 + blockType: string; 131 + error: unknown; 132 + block: unknown; 133 + }> = []; 134 + 135 + if (page.blocks && Array.isArray(page.blocks)) { 136 + for ( 137 + let blockIndex = 0; 138 + blockIndex < page.blocks.length; 139 + blockIndex++ 140 + ) { 141 + const blockWrapper = page.blocks[blockIndex]; 142 + const blockType = 143 + (blockWrapper?.block as { $type?: string })?.$type || 144 + "unknown"; 145 + 146 + // Validate block wrapper based on page type 147 + let blockResult; 148 + if (pageType === "pub.leaflet.pages.linearDocument") { 149 + blockResult = 150 + PubLeafletPagesLinearDocument.validateBlock(blockWrapper); 151 + } else { 152 + blockResult = 153 + PubLeafletPagesCanvas.validateBlock(blockWrapper); 154 + } 155 + 156 + if (!blockResult.success) { 157 + blockErrors.push({ 158 + blockIndex, 159 + blockType, 160 + error: blockResult.error, 161 + block: blockWrapper, 162 + }); 163 + } 164 + } 165 + } 166 + 167 + pageErrors.push({ 168 + pageIndex, 169 + pageType, 170 + error: pageResult.error, 171 + blockErrors: blockErrors.length > 0 ? blockErrors : undefined, 172 + }); 173 + } 174 + } 175 + } 176 + 177 + return NextResponse.json({ 178 + success: false, 179 + collection: uri.collection, 180 + error: result.error, 181 + pageErrors: pageErrors.length > 0 ? pageErrors : undefined, 182 + record: recordResponse.data.value, 183 + }); 184 + } 185 + } 186 + 187 + if (uri.collection === "pub.leaflet.publication") { 188 + const result = PubLeafletPublication.validateRecord( 189 + recordResponse.data.value, 190 + ); 191 + if (result.success) { 192 + return NextResponse.json({ 193 + success: true, 194 + collection: uri.collection, 195 + record: result.value, 196 + }); 197 + } else { 198 + return NextResponse.json({ 199 + success: false, 200 + collection: uri.collection, 201 + error: result.error, 202 + }); 203 + } 204 + } 205 + } catch (error) { 206 + console.error("Error validating AT URI:", error); 207 + return NextResponse.json( 208 + { 209 + success: false, 210 + error: "Invalid URI or internal error", 211 + }, 212 + { status: 400 }, 213 + ); 214 + } 215 + }
+1 -1
app/lish/Subscribe.tsx
··· 105 105 )} 106 106 107 107 <a 108 - href={`${props.base_url}/rss`} 108 + href={`https://${props.base_url}/rss`} 109 109 className="flex" 110 110 target="_blank" 111 111 aria-label="Subscribe to RSS"
+2 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 216 216 <SubscribeWithBluesky 217 217 pubName={publication.name} 218 218 pub_uri={publication.uri} 219 - base_url={ 220 - (publication.record as PubLeafletPublication.Record) 221 - .base_path || "" 222 - } 219 + base_url={getPublicationURL(publication)} 223 220 subscribers={publication?.publication_subscriptions} 224 221 /> 225 222 </div> ··· 250 247 <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 248 <span 252 249 aria-hidden 253 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 250 + >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 254 251 </button> 255 252 )} 256 253 {props.showComments === false ? null : (
+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
+2 -2
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 ( ··· 324 324 return ( 325 325 // 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. 326 326 <blockquote 327 - className={`blockquoteBlock py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 327 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 328 328 {...blockProps} 329 329 > 330 330 <TextBlock
+61 -17
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 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(); 47 + const state = usePostPageUIState((s) => s); 35 48 const searchParams = useSearchParams(); 36 49 const pageParam = searchParams.get("page"); 37 - const state = usePostPageUIState((s) => s); 38 50 39 51 if (!state.initialized) { 40 52 // Check for page search param first (for comment links) 41 53 if (pageParam) { 42 - return [pageParam]; 54 + return [{ type: "doc", id: pageParam }]; 43 55 } 44 56 // Then check for quote param 45 57 if (quote) { 46 58 const decodedQuote = decodeQuotePosition(quote as string); 47 59 if (decodedQuote?.pageId) { 48 - return [decodedQuote.pageId]; 60 + return [{ type: "doc", id: decodedQuote.pageId }]; 49 61 } 50 62 } 51 63 } ··· 64 76 // Check for page search param first (for comment links) 65 77 if (pageParam) { 66 78 usePostPageUIState.setState({ 67 - pages: [pageParam], 79 + pages: [{ type: "doc", id: pageParam }], 68 80 initialized: true, 69 81 }); 70 82 return; ··· 74 86 const decodedQuote = decodeQuotePosition(quote as string); 75 87 if (decodedQuote?.pageId) { 76 88 usePostPageUIState.setState({ 77 - pages: [decodedQuote.pageId], 89 + pages: [{ type: "doc", id: decodedQuote.pageId }], 78 90 initialized: true, 79 91 }); 80 92 return; ··· 87 99 }; 88 100 89 101 export const openPage = ( 90 - parent: string | undefined, 91 - page: string, 102 + parent: OpenPage | undefined, 103 + page: OpenPage, 92 104 options?: { scrollIntoView?: boolean }, 93 105 ) => { 106 + const pageKey = getPageKey(page); 107 + const parentKey = parent ? getPageKey(parent) : undefined; 108 + 94 109 flushSync(() => { 95 110 usePostPageUIState.setState((state) => { 96 - let parentPosition = state.pages.findIndex((s) => s == parent); 111 + let parentPosition = state.pages.findIndex( 112 + (s) => getPageKey(s) === parentKey, 113 + ); 97 114 return { 98 115 pages: 99 116 parentPosition === -1 ··· 105 122 }); 106 123 107 124 if (options?.scrollIntoView !== false) { 108 - scrollIntoView(`post-page-${page}`); 125 + scrollIntoView(`post-page-${pageKey}`); 109 126 } 110 127 }; 111 128 112 - export const closePage = (page: string) => 129 + export const closePage = (page: OpenPage) => { 130 + const pageKey = getPageKey(page); 113 131 usePostPageUIState.setState((state) => { 114 - let parentPosition = state.pages.findIndex((s) => s == page); 132 + let parentPosition = state.pages.findIndex( 133 + (s) => getPageKey(s) === pageKey, 134 + ); 115 135 return { 116 136 pages: state.pages.slice(0, parentPosition), 117 137 initialized: true, 118 138 }; 119 139 }); 140 + }; 120 141 121 142 // Shared props type for both page components 122 143 export type SharedPageProps = { ··· 248 269 /> 249 270 )} 250 271 251 - {openPageIds.map((pageId) => { 272 + {openPageIds.map((openPage) => { 273 + const pageKey = getPageKey(openPage); 274 + 275 + // Handle thread pages 276 + if (openPage.type === "thread") { 277 + return ( 278 + <Fragment key={pageKey}> 279 + <SandwichSpacer /> 280 + <ThreadPageComponent 281 + threadUri={openPage.uri} 282 + pageId={pageKey} 283 + hasPageBackground={hasPageBackground} 284 + pageOptions={ 285 + <PageOptions 286 + onClick={() => closePage(openPage)} 287 + hasPageBackground={hasPageBackground} 288 + /> 289 + } 290 + /> 291 + </Fragment> 292 + ); 293 + } 294 + 295 + // Handle document pages 252 296 let page = record.pages.find( 253 297 (p) => 254 298 ( 255 299 p as 256 300 | PubLeafletPagesLinearDocument.Main 257 301 | PubLeafletPagesCanvas.Main 258 - ).id === pageId, 302 + ).id === openPage.id, 259 303 ) as 260 304 | PubLeafletPagesLinearDocument.Main 261 305 | PubLeafletPagesCanvas.Main ··· 264 308 if (!page) return null; 265 309 266 310 return ( 267 - <Fragment key={pageId}> 311 + <Fragment key={pageKey}> 268 312 <SandwichSpacer /> 269 313 <PageRenderer 270 314 page={page} ··· 273 317 pageId={page.id} 274 318 pageOptions={ 275 319 <PageOptions 276 - onClick={() => closePage(page.id!)} 320 + onClick={() => closePage(openPage)} 277 321 hasPageBackground={hasPageBackground} 278 322 /> 279 323 }
+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 });
+438
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 + pageType="doc" 98 + fullPageScroll={false} 99 + id={`post-page-${pageId}`} 100 + drawerOpen={!!drawer} 101 + pageOptions={pageOptions} 102 + > 103 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 104 + {isLoading ? ( 105 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 106 + <span>loading thread</span> 107 + <DotLoader /> 108 + </div> 109 + ) : error ? ( 110 + <div className="text-tertiary italic text-sm text-center py-8"> 111 + Failed to load thread 112 + </div> 113 + ) : thread ? ( 114 + <ThreadContent thread={thread} threadUri={threadUri} /> 115 + ) : null} 116 + </div> 117 + </PageWrapper> 118 + ); 119 + } 120 + 121 + function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 122 + const { thread, threadUri } = props; 123 + 124 + if (AppBskyFeedDefs.isNotFoundPost(thread)) { 125 + return <PostNotAvailable />; 126 + } 127 + 128 + if (AppBskyFeedDefs.isBlockedPost(thread)) { 129 + return ( 130 + <div className="text-tertiary italic text-sm text-center py-8"> 131 + This post is blocked 132 + </div> 133 + ); 134 + } 135 + 136 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 137 + return <PostNotAvailable />; 138 + } 139 + 140 + // Collect all parent posts in order (oldest first) 141 + const parents: ThreadViewPost[] = []; 142 + let currentParent = thread.parent; 143 + while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 144 + parents.unshift(currentParent); 145 + currentParent = currentParent.parent; 146 + } 147 + 148 + return ( 149 + <div className="flex flex-col gap-0"> 150 + {/* Parent posts */} 151 + {parents.map((parent, index) => ( 152 + <div key={parent.post.uri} className="flex flex-col"> 153 + <ThreadPost 154 + post={parent} 155 + isMainPost={false} 156 + showReplyLine={index < parents.length - 1 || true} 157 + threadUri={threadUri} 158 + /> 159 + </div> 160 + ))} 161 + 162 + {/* Main post */} 163 + <ThreadPost 164 + post={thread} 165 + isMainPost={true} 166 + showReplyLine={false} 167 + threadUri={threadUri} 168 + /> 169 + 170 + {/* Replies */} 171 + {thread.replies && thread.replies.length > 0 && ( 172 + <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 173 + <div className="text-tertiary text-xs font-bold mb-2 px-2"> 174 + Replies 175 + </div> 176 + <Replies 177 + replies={thread.replies as any[]} 178 + threadUri={threadUri} 179 + depth={0} 180 + /> 181 + </div> 182 + )} 183 + </div> 184 + ); 185 + } 186 + 187 + function ThreadPost(props: { 188 + post: ThreadViewPost; 189 + isMainPost: boolean; 190 + showReplyLine: boolean; 191 + threadUri: string; 192 + }) { 193 + const { post, isMainPost, showReplyLine, threadUri } = props; 194 + const postView = post.post; 195 + const record = postView.record as AppBskyFeedPost.Record; 196 + 197 + const postId = postView.uri.split("/")[4]; 198 + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 199 + 200 + return ( 201 + <div className="flex gap-2 relative"> 202 + {/* Reply line connector */} 203 + {showReplyLine && ( 204 + <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 205 + )} 206 + 207 + <div className="flex flex-col items-center shrink-0"> 208 + {postView.author.avatar ? ( 209 + <img 210 + src={postView.author.avatar} 211 + alt={`${postView.author.displayName}'s avatar`} 212 + className="w-10 h-10 rounded-full border border-border-light" 213 + /> 214 + ) : ( 215 + <div className="w-10 h-10 rounded-full border border-border-light bg-border" /> 216 + )} 217 + </div> 218 + 219 + <div 220 + className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`} 221 + > 222 + <div className="flex items-center gap-2 leading-tight"> 223 + <div className="font-bold text-secondary"> 224 + {postView.author.displayName} 225 + </div> 226 + <a 227 + className="text-xs text-tertiary hover:underline" 228 + target="_blank" 229 + href={`https://bsky.app/profile/${postView.author.handle}`} 230 + > 231 + @{postView.author.handle} 232 + </a> 233 + </div> 234 + 235 + <div className="flex flex-col gap-2 mt-1"> 236 + <div className="text-sm text-secondary"> 237 + <BlueskyRichText record={record} /> 238 + </div> 239 + {postView.embed && ( 240 + <BlueskyEmbed embed={postView.embed} postUrl={url} /> 241 + )} 242 + </div> 243 + 244 + <div className="flex gap-2 items-center justify-between mt-2"> 245 + <ClientDate date={record.createdAt} /> 246 + <div className="flex gap-2 items-center"> 247 + {postView.replyCount != null && postView.replyCount > 0 && ( 248 + <> 249 + {isMainPost ? ( 250 + <div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs"> 251 + {postView.replyCount} 252 + <CommentTiny /> 253 + </div> 254 + ) : ( 255 + <ThreadLink 256 + threadUri={postView.uri} 257 + parent={{ type: "thread", uri: threadUri }} 258 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 259 + > 260 + {postView.replyCount} 261 + <CommentTiny /> 262 + </ThreadLink> 263 + )} 264 + <Separator classname="h-4" /> 265 + </> 266 + )} 267 + <a className="text-tertiary" target="_blank" href={url}> 268 + <BlueskyTiny /> 269 + </a> 270 + </div> 271 + </div> 272 + </div> 273 + </div> 274 + ); 275 + } 276 + 277 + function Replies(props: { 278 + replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 279 + threadUri: string; 280 + depth: number; 281 + }) { 282 + const { replies, threadUri, depth } = props; 283 + 284 + return ( 285 + <div className="flex flex-col gap-0"> 286 + {replies.map((reply, index) => { 287 + if (AppBskyFeedDefs.isNotFoundPost(reply)) { 288 + return ( 289 + <div 290 + key={`not-found-${index}`} 291 + className="text-tertiary italic text-xs py-2 px-2" 292 + > 293 + Post not found 294 + </div> 295 + ); 296 + } 297 + 298 + if (AppBskyFeedDefs.isBlockedPost(reply)) { 299 + return ( 300 + <div 301 + key={`blocked-${index}`} 302 + className="text-tertiary italic text-xs py-2 px-2" 303 + > 304 + Post blocked 305 + </div> 306 + ); 307 + } 308 + 309 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) { 310 + return null; 311 + } 312 + 313 + const hasReplies = reply.replies && reply.replies.length > 0; 314 + 315 + return ( 316 + <div key={reply.post.uri} className="flex flex-col"> 317 + <ReplyPost 318 + post={reply} 319 + showReplyLine={hasReplies || index < replies.length - 1} 320 + isLast={index === replies.length - 1 && !hasReplies} 321 + threadUri={threadUri} 322 + /> 323 + {hasReplies && depth < 3 && ( 324 + <div className="ml-5 pl-5 border-l border-border-light"> 325 + <Replies 326 + replies={reply.replies as any[]} 327 + threadUri={threadUri} 328 + depth={depth + 1} 329 + /> 330 + </div> 331 + )} 332 + {hasReplies && depth >= 3 && ( 333 + <ThreadLink 334 + threadUri={reply.post.uri} 335 + parent={{ type: "thread", uri: threadUri }} 336 + className="ml-12 text-xs text-accent-contrast hover:underline py-1" 337 + > 338 + View more replies 339 + </ThreadLink> 340 + )} 341 + </div> 342 + ); 343 + })} 344 + </div> 345 + ); 346 + } 347 + 348 + function ReplyPost(props: { 349 + post: ThreadViewPost; 350 + showReplyLine: boolean; 351 + isLast: boolean; 352 + threadUri: string; 353 + }) { 354 + const { post, showReplyLine, isLast, threadUri } = props; 355 + const postView = post.post; 356 + const record = postView.record as AppBskyFeedPost.Record; 357 + 358 + const postId = postView.uri.split("/")[4]; 359 + const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 360 + 361 + const parent = { type: "thread" as const, uri: threadUri }; 362 + 363 + return ( 364 + <div 365 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 366 + onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 367 + > 368 + <div className="flex flex-col items-center shrink-0"> 369 + {postView.author.avatar ? ( 370 + <img 371 + src={postView.author.avatar} 372 + alt={`${postView.author.displayName}'s avatar`} 373 + className="w-8 h-8 rounded-full border border-border-light" 374 + /> 375 + ) : ( 376 + <div className="w-8 h-8 rounded-full border border-border-light bg-border" /> 377 + )} 378 + </div> 379 + 380 + <div className="flex flex-col grow min-w-0"> 381 + <div className="flex items-center gap-2 leading-tight text-sm"> 382 + <div className="font-bold text-secondary"> 383 + {postView.author.displayName} 384 + </div> 385 + <a 386 + className="text-xs text-tertiary hover:underline" 387 + target="_blank" 388 + href={`https://bsky.app/profile/${postView.author.handle}`} 389 + onClick={(e) => e.stopPropagation()} 390 + > 391 + @{postView.author.handle} 392 + </a> 393 + </div> 394 + 395 + <div className="text-sm text-secondary mt-0.5"> 396 + <BlueskyRichText record={record} /> 397 + </div> 398 + 399 + <div className="flex gap-2 items-center mt-1"> 400 + <ClientDate date={record.createdAt} /> 401 + {postView.replyCount != null && postView.replyCount > 0 && ( 402 + <> 403 + <Separator classname="h-3" /> 404 + <ThreadLink 405 + threadUri={postView.uri} 406 + parent={parent} 407 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 408 + onClick={(e) => e.stopPropagation()} 409 + > 410 + {postView.replyCount} 411 + <CommentTiny /> 412 + </ThreadLink> 413 + </> 414 + )} 415 + </div> 416 + </div> 417 + </div> 418 + ); 419 + } 420 + 421 + const ClientDate = (props: { date?: string }) => { 422 + const pageLoaded = useHasPageLoaded(); 423 + const formattedDate = useLocalizedDate( 424 + props.date || new Date().toISOString(), 425 + { 426 + month: "short", 427 + day: "numeric", 428 + year: "numeric", 429 + hour: "numeric", 430 + minute: "numeric", 431 + hour12: true, 432 + }, 433 + ); 434 + 435 + if (!pageLoaded) return null; 436 + 437 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 438 + };
+1 -1
app/lish/[did]/[publication]/page.tsx
··· 165 165 quotesCount={quotes} 166 166 commentsCount={comments} 167 167 tags={tags} 168 - postUrl="" 168 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 169 showComments={record?.preferences?.showComments} 170 170 /> 171 171 </div>
+14 -6
app/lish/uri/[uri]/route.ts
··· 9 9 */ 10 10 export async function GET( 11 11 request: NextRequest, 12 - { params }: { params: Promise<{ uri: string }> } 12 + { params }: { params: Promise<{ uri: string }> }, 13 13 ) { 14 14 try { 15 15 const { uri: uriParam } = await params; ··· 32 32 const basePath = record.base_path; 33 33 34 34 if (!basePath) { 35 - return new NextResponse("Publication has no base_path", { status: 404 }); 35 + return new NextResponse("Publication has no base_path", { 36 + status: 404, 37 + }); 36 38 } 37 39 38 40 // Redirect to the publication's hosted domain (temporary redirect since base_path can change) ··· 47 49 48 50 if (docInPub?.publication && docInPub.publications) { 49 51 // Document is in a publication - redirect to domain/rkey 50 - const record = docInPub.publications.record as PubLeafletPublication.Record; 52 + const record = docInPub.publications 53 + .record as PubLeafletPublication.Record; 51 54 const basePath = record.base_path; 52 55 53 56 if (!basePath) { 54 - return new NextResponse("Publication has no base_path", { status: 404 }); 57 + return new NextResponse("Publication has no base_path", { 58 + status: 404, 59 + }); 55 60 } 56 61 57 62 // Ensure basePath ends without trailing slash ··· 60 65 : basePath; 61 66 62 67 // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 63 - return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); 68 + return NextResponse.redirect( 69 + `https://${cleanBasePath}/${uri.rkey}`, 70 + 307, 71 + ); 64 72 } 65 73 66 74 // If not in a publication, check if it's a standalone document ··· 74 82 // Standalone document - redirect to /p/did/rkey (temporary redirect) 75 83 return NextResponse.redirect( 76 84 new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 77 - 307 85 + 307, 78 86 ); 79 87 } 80 88
+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"
+3 -3
components/InteractionsPreview.tsx
··· 40 40 <SpeedyLink 41 41 aria-label="Post quotes" 42 42 href={`${props.postUrl}?interactionDrawer=quotes`} 43 - className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 43 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 44 44 > 45 45 <QuoteTiny /> {props.quotesCount} 46 46 </SpeedyLink> ··· 49 49 <SpeedyLink 50 50 aria-label="Post comments" 51 51 href={`${props.postUrl}?interactionDrawer=comments`} 52 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 53 > 54 54 <CommentTiny /> {props.commentsCount} 55 55 </SpeedyLink> ··· 93 93 <Popover 94 94 className="p-2! max-w-xs" 95 95 trigger={ 96 - <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast"> 97 97 <TagTiny /> {props.tags.length} 98 98 </div> 99 99 }