a tool for shared writing and social publishing

show quotes on bsky posts

+525 -227
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import useSWR from "swr"; 4 + import { PageWrapper } from "components/Pages/Page"; 5 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 + import { DotLoader } from "components/utils/DotLoader"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { openPage } from "./PostPages"; 9 + import { BskyPostContent } from "./BskyPostContent"; 10 + import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 11 + 12 + // Re-export for backwards compatibility 13 + export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; 14 + 15 + type PostView = AppBskyFeedDefs.PostView; 16 + 17 + export function BlueskyQuotesPage(props: { 18 + postUri: string; 19 + pageId: string; 20 + pageOptions?: React.ReactNode; 21 + hasPageBackground: boolean; 22 + }) { 23 + const { postUri, pageId, pageOptions } = props; 24 + const drawer = useDrawerOpen(postUri); 25 + 26 + const { 27 + data: quotesData, 28 + isLoading, 29 + error, 30 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 31 + 32 + return ( 33 + <PageWrapper 34 + pageType="doc" 35 + fullPageScroll={false} 36 + id={`post-page-${pageId}`} 37 + drawerOpen={!!drawer} 38 + pageOptions={pageOptions} 39 + > 40 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 41 + <div className="text-secondary font-bold mb-3 flex items-center gap-2"> 42 + <QuoteTiny /> 43 + Bluesky Quotes 44 + </div> 45 + {isLoading ? ( 46 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 47 + <span>loading quotes</span> 48 + <DotLoader /> 49 + </div> 50 + ) : error ? ( 51 + <div className="text-tertiary italic text-sm text-center py-8"> 52 + Failed to load quotes 53 + </div> 54 + ) : quotesData && quotesData.posts.length > 0 ? ( 55 + <QuotesContent posts={quotesData.posts} postUri={postUri} /> 56 + ) : ( 57 + <div className="text-tertiary italic text-sm text-center py-8"> 58 + No quotes yet 59 + </div> 60 + )} 61 + </div> 62 + </PageWrapper> 63 + ); 64 + } 65 + 66 + function QuotesContent(props: { posts: PostView[]; postUri: string }) { 67 + const { posts, postUri } = props; 68 + 69 + return ( 70 + <div className="flex flex-col gap-0"> 71 + {posts.map((post) => ( 72 + <QuotePost 73 + key={post.uri} 74 + post={post} 75 + quotesUri={postUri} 76 + /> 77 + ))} 78 + </div> 79 + ); 80 + } 81 + 82 + function QuotePost(props: { 83 + post: PostView; 84 + quotesUri: string; 85 + }) { 86 + const { post, quotesUri } = props; 87 + const parent = { type: "quotes" as const, uri: quotesUri }; 88 + 89 + return ( 90 + <div 91 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 92 + onClick={() => openPage(parent, { type: "thread", uri: post.uri })} 93 + > 94 + <BskyPostContent 95 + post={post} 96 + parent={parent} 97 + linksEnabled={true} 98 + showEmbed={true} 99 + showBlueskyLink={true} 100 + onLinkClick={(e) => e.stopPropagation()} 101 + onEmbedClick={(e) => e.stopPropagation()} 102 + /> 103 + </div> 104 + ); 105 + }
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import { 4 + BlueskyEmbed, 5 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 6 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 7 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 + import { CommentTiny } from "components/Icons/CommentTiny"; 9 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 10 + import { Separator } from "components/Layout"; 11 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 + import { OpenPage } from "./PostPages"; 14 + import { ThreadLink, QuotesLink } from "./PostLinks"; 15 + 16 + type PostView = AppBskyFeedDefs.PostView; 17 + 18 + export function BskyPostContent(props: { 19 + post: PostView; 20 + parent?: OpenPage; 21 + linksEnabled?: boolean; 22 + avatarSize?: "sm" | "md"; 23 + showEmbed?: boolean; 24 + showBlueskyLink?: boolean; 25 + onEmbedClick?: (e: React.MouseEvent) => void; 26 + onLinkClick?: (e: React.MouseEvent) => void; 27 + }) { 28 + const { 29 + post, 30 + parent, 31 + linksEnabled = true, 32 + avatarSize = "md", 33 + showEmbed = true, 34 + showBlueskyLink = true, 35 + onEmbedClick, 36 + onLinkClick, 37 + } = props; 38 + 39 + const record = post.record as AppBskyFeedPost.Record; 40 + const postId = post.uri.split("/")[4]; 41 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 + 43 + const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 44 + 45 + return ( 46 + <> 47 + <div className="flex flex-col items-center shrink-0"> 48 + {post.author.avatar ? ( 49 + <img 50 + src={post.author.avatar} 51 + alt={`${post.author.displayName}'s avatar`} 52 + className={`${avatarClass} rounded-full border border-border-light`} 53 + /> 54 + ) : ( 55 + <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 + )} 57 + </div> 58 + 59 + <div className="flex flex-col grow min-w-0"> 60 + <div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}> 61 + <div className="font-bold text-secondary"> 62 + {post.author.displayName} 63 + </div> 64 + <a 65 + className="text-xs text-tertiary hover:underline" 66 + target="_blank" 67 + href={`https://bsky.app/profile/${post.author.handle}`} 68 + onClick={onLinkClick} 69 + > 70 + @{post.author.handle} 71 + </a> 72 + </div> 73 + 74 + <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 75 + <div className="text-sm text-secondary"> 76 + <BlueskyRichText record={record} /> 77 + </div> 78 + {showEmbed && post.embed && ( 79 + <div onClick={onEmbedClick}> 80 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 81 + </div> 82 + )} 83 + </div> 84 + 85 + <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 + <ClientDate date={record.createdAt} /> 87 + <PostCounts 88 + post={post} 89 + parent={parent} 90 + linksEnabled={linksEnabled} 91 + showBlueskyLink={showBlueskyLink} 92 + url={url} 93 + onLinkClick={onLinkClick} 94 + /> 95 + </div> 96 + </div> 97 + </> 98 + ); 99 + } 100 + 101 + function PostCounts(props: { 102 + post: PostView; 103 + parent?: OpenPage; 104 + linksEnabled: boolean; 105 + showBlueskyLink: boolean; 106 + url: string; 107 + onLinkClick?: (e: React.MouseEvent) => void; 108 + }) { 109 + const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 110 + 111 + return ( 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <Separator classname="h-3" /> 116 + {linksEnabled ? ( 117 + <ThreadLink 118 + threadUri={post.uri} 119 + parent={parent} 120 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 + onClick={onLinkClick} 122 + > 123 + {post.replyCount} 124 + <CommentTiny /> 125 + </ThreadLink> 126 + ) : ( 127 + <div className="flex items-center gap-1 text-tertiary text-xs"> 128 + {post.replyCount} 129 + <CommentTiny /> 130 + </div> 131 + )} 132 + </> 133 + )} 134 + {post.quoteCount != null && post.quoteCount > 0 && ( 135 + <> 136 + <Separator classname="h-3" /> 137 + <QuotesLink 138 + postUri={post.uri} 139 + parent={parent} 140 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 141 + onClick={onLinkClick} 142 + > 143 + {post.quoteCount} 144 + <QuoteTiny /> 145 + </QuotesLink> 146 + </> 147 + )} 148 + {showBlueskyLink && ( 149 + <> 150 + <Separator classname="h-3" /> 151 + <a 152 + className="text-tertiary" 153 + target="_blank" 154 + href={url} 155 + onClick={onLinkClick} 156 + > 157 + <BlueskyTiny /> 158 + </a> 159 + </> 160 + )} 161 + </div> 162 + ); 163 + } 164 + 165 + export const ClientDate = (props: { date?: string }) => { 166 + const pageLoaded = useHasPageLoaded(); 167 + const formattedDate = useLocalizedDate( 168 + props.date || new Date().toISOString(), 169 + { 170 + month: "short", 171 + day: "numeric", 172 + year: "numeric", 173 + hour: "numeric", 174 + minute: "numeric", 175 + hour12: true, 176 + }, 177 + ); 178 + 179 + if (!pageLoaded) return null; 180 + 181 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 182 + };
+27 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 23 23 import useSWR, { mutate } from "swr"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 25 import { CommentTiny } from "components/Icons/CommentTiny"; 26 - import { ThreadLink } from "../ThreadPage"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { ThreadLink, QuotesLink } from "../PostLinks"; 27 28 28 29 // Helper to get SWR key for quotes 29 30 export function getQuotesSWRKey(uris: string[]) { ··· 138 139 profile={pv.author} 139 140 handle={pv.author.handle} 140 141 replyCount={pv.replyCount} 142 + quoteCount={pv.quoteCount} 141 143 /> 142 144 </div> 143 145 ); ··· 161 163 profile={pv.author} 162 164 handle={pv.author.handle} 163 165 replyCount={pv.replyCount} 166 + quoteCount={pv.quoteCount} 164 167 /> 165 168 ); 166 169 })} ··· 252 255 handle: string; 253 256 profile: ProfileViewBasic; 254 257 replyCount?: number; 258 + quoteCount?: number; 255 259 }) => { 256 260 const handleOpenThread = () => { 257 261 openPage(undefined, { type: "thread", uri: props.uri }); ··· 282 286 </a> 283 287 </div> 284 288 <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 - )} 289 + <div className="flex gap-2 items-center mt-1"> 290 + {props.replyCount != null && props.replyCount > 0 && ( 291 + <ThreadLink 292 + threadUri={props.uri} 293 + onClick={(e) => e.stopPropagation()} 294 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 295 + > 296 + <CommentTiny /> 297 + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 298 + </ThreadLink> 299 + )} 300 + {props.quoteCount != null && props.quoteCount > 0 && ( 301 + <QuotesLink 302 + postUri={props.uri} 303 + onClick={(e) => e.stopPropagation()} 304 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 305 + > 306 + <QuoteTiny /> 307 + {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 308 + </QuotesLink> 309 + )} 310 + </div> 295 311 </div> 296 312 </div> 297 313 );
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import { preload } from "swr"; 4 + import { openPage, OpenPage } from "./PostPages"; 5 + 6 + type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 7 + type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 8 + type BlockedPost = AppBskyFeedDefs.BlockedPost; 9 + type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 10 + 11 + type PostView = AppBskyFeedDefs.PostView; 12 + 13 + export interface QuotesResponse { 14 + uri: string; 15 + cid?: string; 16 + cursor?: string; 17 + posts: PostView[]; 18 + } 19 + 20 + // Thread fetching 21 + export const getThreadKey = (uri: string) => `thread:${uri}`; 22 + 23 + export async function fetchThread(uri: string): Promise<ThreadType> { 24 + const params = new URLSearchParams({ uri }); 25 + const response = await fetch(`/api/bsky/thread?${params.toString()}`); 26 + 27 + if (!response.ok) { 28 + throw new Error("Failed to fetch thread"); 29 + } 30 + 31 + return response.json(); 32 + } 33 + 34 + export const prefetchThread = (uri: string) => { 35 + preload(getThreadKey(uri), () => fetchThread(uri)); 36 + }; 37 + 38 + // Quotes fetching 39 + export const getQuotesKey = (uri: string) => `quotes:${uri}`; 40 + 41 + export async function fetchQuotes(uri: string): Promise<QuotesResponse> { 42 + const params = new URLSearchParams({ uri }); 43 + const response = await fetch(`/api/bsky/quotes?${params.toString()}`); 44 + 45 + if (!response.ok) { 46 + throw new Error("Failed to fetch quotes"); 47 + } 48 + 49 + return response.json(); 50 + } 51 + 52 + export const prefetchQuotes = (uri: string) => { 53 + preload(getQuotesKey(uri), () => fetchQuotes(uri)); 54 + }; 55 + 56 + // Link component for opening thread pages with prefetching 57 + export function ThreadLink(props: { 58 + threadUri: string; 59 + parent?: OpenPage; 60 + children: React.ReactNode; 61 + className?: string; 62 + onClick?: (e: React.MouseEvent) => void; 63 + }) { 64 + const { threadUri, parent, children, className, onClick } = props; 65 + 66 + const handleClick = (e: React.MouseEvent) => { 67 + onClick?.(e); 68 + if (e.defaultPrevented) return; 69 + openPage(parent, { type: "thread", uri: threadUri }); 70 + }; 71 + 72 + const handlePrefetch = () => { 73 + prefetchThread(threadUri); 74 + }; 75 + 76 + return ( 77 + <button 78 + className={className} 79 + onClick={handleClick} 80 + onMouseEnter={handlePrefetch} 81 + onPointerDown={handlePrefetch} 82 + > 83 + {children} 84 + </button> 85 + ); 86 + } 87 + 88 + // Link component for opening quotes pages with prefetching 89 + export function QuotesLink(props: { 90 + postUri: string; 91 + parent?: OpenPage; 92 + children: React.ReactNode; 93 + className?: string; 94 + onClick?: (e: React.MouseEvent) => void; 95 + }) { 96 + const { postUri, parent, children, className, onClick } = props; 97 + 98 + const handleClick = (e: React.MouseEvent) => { 99 + onClick?.(e); 100 + if (e.defaultPrevented) return; 101 + openPage(parent, { type: "quotes", uri: postUri }); 102 + }; 103 + 104 + const handlePrefetch = () => { 105 + prefetchQuotes(postUri); 106 + }; 107 + 108 + return ( 109 + <button 110 + className={className} 111 + onClick={handleClick} 112 + onMouseEnter={handlePrefetch} 113 + onPointerDown={handlePrefetch} 114 + > 115 + {children} 116 + </button> 117 + ); 118 + }
+24 -1
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 25 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 26 import { CanvasPage } from "./CanvasPage"; 27 27 import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 + import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 28 29 29 30 // Page types 30 31 export type DocPage = { type: "doc"; id: string }; 31 32 export type ThreadPage = { type: "thread"; uri: string }; 32 - export type OpenPage = DocPage | ThreadPage; 33 + export type QuotesPage = { type: "quotes"; uri: string }; 34 + export type OpenPage = DocPage | ThreadPage | QuotesPage; 33 35 34 36 // Get a stable key for a page 35 37 const getPageKey = (page: OpenPage): string => { 36 38 if (page.type === "doc") return page.id; 39 + if (page.type === "quotes") return `quotes:${page.uri}`; 37 40 return `thread:${page.uri}`; 38 41 }; 39 42 ··· 279 282 <SandwichSpacer /> 280 283 <ThreadPageComponent 281 284 threadUri={openPage.uri} 285 + pageId={pageKey} 286 + hasPageBackground={hasPageBackground} 287 + pageOptions={ 288 + <PageOptions 289 + onClick={() => closePage(openPage)} 290 + hasPageBackground={hasPageBackground} 291 + /> 292 + } 293 + /> 294 + </Fragment> 295 + ); 296 + } 297 + 298 + // Handle quotes pages 299 + if (openPage.type === "quotes") { 300 + return ( 301 + <Fragment key={pageKey}> 302 + <SandwichSpacer /> 303 + <BlueskyQuotesPage 304 + postUri={openPage.uri} 282 305 pageId={pageKey} 283 306 hasPageBackground={hasPageBackground} 284 307 pageOptions={
+16 -1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 4 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "./PostLinks"; 7 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 8 10 import { 9 11 BlueskyEmbed, ··· 11 13 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 12 14 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 13 15 import { openPage } from "./PostPages"; 14 - import { ThreadLink } from "./ThreadPage"; 15 16 16 17 export const PubBlueskyPostBlock = (props: { 17 18 post: PostView; ··· 118 119 {post.replyCount} 119 120 <CommentTiny /> 120 121 </ThreadLink> 122 + <Separator classname="h-4" /> 123 + </> 124 + )} 125 + {post.quoteCount != null && post.quoteCount > 0 && ( 126 + <> 127 + <QuotesLink 128 + postUri={post.uri} 129 + parent={parent} 130 + className="flex items-center gap-1 hover:text-accent-contrast" 131 + onClick={(e) => e.stopPropagation()} 132 + > 133 + {post.quoteCount} 134 + <QuoteTiny /> 135 + </QuotesLink> 121 136 <Separator classname="h-4" /> 122 137 </> 123 138 )}
+53 -214
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 2 import { useEffect, useRef } from "react"; 3 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 4 - import useSWR, { preload } from "swr"; 3 + import { AppBskyFeedDefs } from "@atproto/api"; 4 + import useSWR from "swr"; 5 5 import { PageWrapper } from "components/Pages/Page"; 6 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 7 7 import { DotLoader } from "components/utils/DotLoader"; 8 - import { 9 - BlueskyEmbed, 10 - PostNotAvailable, 11 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 12 - import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 13 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 - import { CommentTiny } from "components/Icons/CommentTiny"; 15 - import { Separator } from "components/Layout"; 16 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 17 - import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 18 - import { openPage, OpenPage } from "./PostPages"; 19 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 + import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 + import { openPage } from "./PostPages"; 20 10 import { useThreadState } from "src/useThreadState"; 21 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 11 + import { BskyPostContent, ClientDate } from "./BskyPostContent"; 12 + import { 13 + ThreadLink, 14 + getThreadKey, 15 + fetchThread, 16 + prefetchThread, 17 + } from "./PostLinks"; 18 + 19 + // Re-export for backwards compatibility 20 + export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; 22 21 23 22 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 24 23 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 25 24 type BlockedPost = AppBskyFeedDefs.BlockedPost; 26 25 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 27 26 28 - // SWR key for thread data 29 - export const getThreadKey = (uri: string) => `thread:${uri}`; 30 - 31 - // Fetch thread from API route 32 - export async function fetchThread(uri: string): Promise<ThreadType> { 33 - const params = new URLSearchParams({ uri }); 34 - const response = await fetch(`/api/bsky/thread?${params.toString()}`); 35 - 36 - if (!response.ok) { 37 - throw new Error("Failed to fetch thread"); 38 - } 39 - 40 - return response.json(); 41 - } 42 - 43 - // Prefetch thread data 44 - export const prefetchThread = (uri: string) => { 45 - preload(getThreadKey(uri), () => fetchThread(uri)); 46 - }; 47 - 48 - // Link component for opening thread pages with prefetching 49 - export function ThreadLink(props: { 50 - threadUri: string; 51 - parent?: OpenPage; 52 - children: React.ReactNode; 53 - className?: string; 54 - onClick?: (e: React.MouseEvent) => void; 55 - }) { 56 - const { threadUri, parent, children, className, onClick } = props; 57 - 58 - const handleClick = (e: React.MouseEvent) => { 59 - onClick?.(e); 60 - if (e.defaultPrevented) return; 61 - openPage(parent, { type: "thread", uri: threadUri }); 62 - }; 63 - 64 - const handlePrefetch = () => { 65 - prefetchThread(threadUri); 66 - }; 67 - 68 - return ( 69 - <button 70 - className={className} 71 - onClick={handleClick} 72 - onMouseEnter={handlePrefetch} 73 - onPointerDown={handlePrefetch} 74 - > 75 - {children} 76 - </button> 77 - ); 78 - } 79 - 80 27 export function ThreadPage(props: { 81 28 threadUri: string; 82 29 pageId: string; ··· 93 40 } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 94 41 fetchThread(threadUri), 95 42 ); 96 - let cardBorderHidden = useCardBorderHidden(null); 97 43 98 44 return ( 99 45 <PageWrapper ··· 193 139 replies={thread.replies as any[]} 194 140 threadUri={threadUri} 195 141 depth={0} 142 + parentAuthorDid={thread.post.author.did} 196 143 /> 197 144 </div> 198 145 )} ··· 208 155 }) { 209 156 const { post, isMainPost, showReplyLine, threadUri } = props; 210 157 const postView = post.post; 211 - const record = postView.record as AppBskyFeedPost.Record; 212 - 213 - const postId = postView.uri.split("/")[4]; 214 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 158 + const parent = { type: "thread" as const, uri: threadUri }; 215 159 216 160 return ( 217 161 <div className="flex gap-2 relative"> ··· 220 164 <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 221 165 )} 222 166 223 - <div className="flex flex-col items-center shrink-0"> 224 - {postView.author.avatar ? ( 225 - <img 226 - src={postView.author.avatar} 227 - alt={`${postView.author.displayName}'s avatar`} 228 - className="w-10 h-10 rounded-full border border-border-light" 229 - /> 230 - ) : ( 231 - <div className="w-10 h-10 rounded-full border border-border-light bg-border" /> 232 - )} 233 - </div> 234 - 235 - <div 236 - className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`} 237 - > 238 - <div className="flex items-center gap-2 leading-tight"> 239 - <div className="font-bold text-secondary"> 240 - {postView.author.displayName} 241 - </div> 242 - <a 243 - className="text-xs text-tertiary hover:underline" 244 - target="_blank" 245 - href={`https://bsky.app/profile/${postView.author.handle}`} 246 - > 247 - @{postView.author.handle} 248 - </a> 249 - </div> 250 - 251 - <div className="flex flex-col gap-2 mt-1"> 252 - <div className="text-sm text-secondary"> 253 - <BlueskyRichText record={record} /> 254 - </div> 255 - {postView.embed && ( 256 - <BlueskyEmbed embed={postView.embed} postUrl={url} /> 257 - )} 258 - </div> 259 - 260 - <div className="flex gap-2 items-center justify-between mt-2"> 261 - <ClientDate date={record.createdAt} /> 262 - <div className="flex gap-2 items-center"> 263 - {postView.replyCount != null && postView.replyCount > 0 && ( 264 - <> 265 - {isMainPost ? ( 266 - <div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs"> 267 - {postView.replyCount} 268 - <CommentTiny /> 269 - </div> 270 - ) : ( 271 - <ThreadLink 272 - threadUri={postView.uri} 273 - parent={{ type: "thread", uri: threadUri }} 274 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 275 - > 276 - {postView.replyCount} 277 - <CommentTiny /> 278 - </ThreadLink> 279 - )} 280 - <Separator classname="h-4" /> 281 - </> 282 - )} 283 - <a className="text-tertiary" target="_blank" href={url}> 284 - <BlueskyTiny /> 285 - </a> 286 - </div> 287 - </div> 288 - </div> 167 + <BskyPostContent 168 + post={postView} 169 + parent={parent} 170 + linksEnabled={!isMainPost} 171 + showBlueskyLink={true} 172 + showEmbed={true} 173 + /> 289 174 </div> 290 175 ); 291 176 } ··· 294 179 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 295 180 threadUri: string; 296 181 depth: number; 182 + parentAuthorDid?: string; 297 183 }) { 298 - const { replies, threadUri, depth } = props; 184 + const { replies, threadUri, depth, parentAuthorDid } = props; 299 185 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 300 186 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 301 187 188 + // Sort replies so that replies from the parent author come first 189 + const sortedReplies = parentAuthorDid 190 + ? [...replies].sort((a, b) => { 191 + const aIsAuthor = 192 + AppBskyFeedDefs.isThreadViewPost(a) && 193 + a.post.author.did === parentAuthorDid; 194 + const bIsAuthor = 195 + AppBskyFeedDefs.isThreadViewPost(b) && 196 + b.post.author.did === parentAuthorDid; 197 + if (aIsAuthor && !bIsAuthor) return -1; 198 + if (!aIsAuthor && bIsAuthor) return 1; 199 + return 0; 200 + }) 201 + : replies; 202 + 302 203 return ( 303 204 <div className="flex flex-col gap-0"> 304 - {replies.map((reply, index) => { 205 + {sortedReplies.map((reply, index) => { 305 206 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 306 207 return ( 307 208 <div ··· 371 272 replies={reply.replies as any[]} 372 273 threadUri={threadUri} 373 274 depth={depth + 1} 275 + parentAuthorDid={reply.post.author.did} 374 276 /> 375 277 </div> 376 278 )} ··· 398 300 isLast: boolean; 399 301 threadUri: string; 400 302 }) { 401 - const { post, showReplyLine, isLast, threadUri } = props; 303 + const { post, threadUri } = props; 402 304 const postView = post.post; 403 - const record = postView.record as AppBskyFeedPost.Record; 404 - 405 - const postId = postView.uri.split("/")[4]; 406 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 407 - 408 305 const parent = { type: "thread" as const, uri: threadUri }; 409 306 410 307 return ( ··· 412 309 className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 413 310 onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 414 311 > 415 - <div className="flex flex-col items-center shrink-0"> 416 - {postView.author.avatar ? ( 417 - <img 418 - src={postView.author.avatar} 419 - alt={`${postView.author.displayName}'s avatar`} 420 - className="w-8 h-8 rounded-full border border-border-light" 421 - /> 422 - ) : ( 423 - <div className="w-8 h-8 rounded-full border border-border-light bg-border" /> 424 - )} 425 - </div> 426 - 427 - <div className="flex flex-col grow min-w-0"> 428 - <div className="flex items-center gap-2 leading-tight text-sm"> 429 - <div className="font-bold text-secondary"> 430 - {postView.author.displayName} 431 - </div> 432 - <a 433 - className="text-xs text-tertiary hover:underline" 434 - target="_blank" 435 - href={`https://bsky.app/profile/${postView.author.handle}`} 436 - onClick={(e) => e.stopPropagation()} 437 - > 438 - @{postView.author.handle} 439 - </a> 440 - </div> 441 - 442 - <div className="text-sm text-secondary mt-0.5"> 443 - <BlueskyRichText record={record} /> 444 - </div> 445 - 446 - <div className="flex gap-2 items-center mt-1"> 447 - <ClientDate date={record.createdAt} /> 448 - {postView.replyCount != null && postView.replyCount > 0 && ( 449 - <> 450 - <Separator classname="h-3" /> 451 - <ThreadLink 452 - threadUri={postView.uri} 453 - parent={parent} 454 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 455 - onClick={(e) => e.stopPropagation()} 456 - > 457 - {postView.replyCount} 458 - <CommentTiny /> 459 - </ThreadLink> 460 - </> 461 - )} 462 - </div> 463 - </div> 312 + <BskyPostContent 313 + post={postView} 314 + parent={parent} 315 + linksEnabled={true} 316 + avatarSize="sm" 317 + showEmbed={false} 318 + showBlueskyLink={false} 319 + onLinkClick={(e) => e.stopPropagation()} 320 + onEmbedClick={(e) => e.stopPropagation()} 321 + /> 464 322 </div> 465 323 ); 466 324 } 467 - 468 - const ClientDate = (props: { date?: string }) => { 469 - const pageLoaded = useHasPageLoaded(); 470 - const formattedDate = useLocalizedDate( 471 - props.date || new Date().toISOString(), 472 - { 473 - month: "short", 474 - day: "numeric", 475 - year: "numeric", 476 - hour: "numeric", 477 - minute: "numeric", 478 - hour12: true, 479 - }, 480 - ); 481 - 482 - if (!pageLoaded) return null; 483 - 484 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 485 - };