a tool for shared writing and social publishing

Update/thread viewer (#261)

* just like a bunch of stuff sorry

* cleaned up naming

* refactor the bskypostcontent to use booleans for reply and quote rather
than functions, include its own reply line, some renaming of variables
to be more consistant

* styling the grandparents in the threadviewer

* styling the quotepage

* adjustments to make everything more consistent

* use BskyPostContent in the leaflet as well

* fixing the width of thread and quote pages so they don't get too wide

* make the reply line emcompass it's replies as well

* little tweaks to the embed block

* fixed a bunch of small things

authored by cozylittle.house and committed by

GitHub 5e9d0466 17f80efe

+740 -691
+1 -1
app/(home-pages)/notifications/FollowNotification.tsx
··· 23 23 <Notification 24 24 timestamp={props.created_at} 25 25 href={pubRecord ? pubRecord.url : "#"} 26 - icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 26 + icon={<Avatar src={avatarSrc} displayName={displayName} size="tiny" />} 27 27 actionText={ 28 28 <> 29 29 {displayName} subscribed to {pubRecord?.name}!
+5 -2
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 23 23 src={profileRecord.avatar} 24 24 displayName={profileRecord.displayName} 25 25 className="profileAvatar mx-auto mt-3 sm:mt-4" 26 - giant 26 + size="giant" 27 27 /> 28 28 ); 29 29 ··· 100 100 </div> 101 101 ); 102 102 }; 103 - const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { 103 + const PublicationCard = (props: { 104 + record: NormalizedPublication; 105 + uri: string; 106 + }) => { 104 107 const { record, uri } = props; 105 108 const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 106 109
+14 -133
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 1 1 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 2 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { Separator } from "components/Layout"; 4 - import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 - import { ThreadLink, QuotesLink } from "../PostLinks"; 9 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { 11 - BlueskyEmbed, 12 - PostNotAvailable, 13 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 - import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 - import { openPage } from "../PostPages"; 3 + import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 4 + import { BskyPostContent } from "../BskyPostContent"; 16 5 17 6 export const PubBlueskyPostBlock = (props: { 18 7 post: PostView; ··· 21 10 }) => { 22 11 let post = props.post; 23 12 24 - const handleOpenThread = () => { 25 - openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, { 26 - type: "thread", 27 - uri: post.uri, 28 - }); 29 - }; 30 - 31 13 switch (true) { 32 14 case AppBskyFeedDefs.isBlockedPost(post) || 33 15 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 49 31 50 32 //getting the url to the post 51 33 let postId = post.uri.split("/")[4]; 34 + let postView = post as PostView; 35 + 52 36 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 37 54 38 const parent = props.pageId ··· 56 40 : undefined; 57 41 58 42 return ( 59 - <div 60 - onClick={handleOpenThread} 61 - className={` 62 - ${props.className} 63 - block-border 64 - mb-2 65 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 66 - cursor-pointer hover:border-accent-contrast 67 - `} 68 - > 69 - {post.author && record && ( 70 - <> 71 - <div className="bskyAuthor w-full flex items-center gap-2"> 72 - {post.author.avatar && ( 73 - <img 74 - src={post.author?.avatar} 75 - alt={`${post.author?.displayName}'s avatar`} 76 - className="shink-0 w-8 h-8 rounded-full border border-border-light" 77 - /> 78 - )} 79 - <div className="grow flex flex-col gap-0.5 leading-tight"> 80 - <div className=" font-bold text-secondary"> 81 - {post.author?.displayName} 82 - </div> 83 - <a 84 - className="text-xs text-tertiary hover:underline" 85 - target="_blank" 86 - href={`https://bsky.app/profile/${post.author?.handle}`} 87 - onClick={(e) => e.stopPropagation()} 88 - > 89 - @{post.author?.handle} 90 - </a> 91 - </div> 92 - </div> 93 - 94 - <div className="flex flex-col gap-2 "> 95 - <div> 96 - <pre className="whitespace-pre-wrap"> 97 - {BlueskyRichText({ 98 - record: record as AppBskyFeedPost.Record | null, 99 - })} 100 - </pre> 101 - </div> 102 - {post.embed && ( 103 - <div onClick={(e) => e.stopPropagation()}> 104 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 105 - </div> 106 - )} 107 - </div> 108 - </> 109 - )} 110 - <div className="w-full flex gap-2 items-center justify-between"> 111 - <ClientDate date={timestamp} /> 112 - <div className="flex gap-2 items-center"> 113 - {post.replyCount != null && post.replyCount > 0 && ( 114 - <> 115 - <ThreadLink 116 - threadUri={post.uri} 117 - parent={parent} 118 - className="flex items-center gap-1 hover:text-accent-contrast" 119 - onClick={(e) => e.stopPropagation()} 120 - > 121 - {post.replyCount} 122 - <CommentTiny /> 123 - </ThreadLink> 124 - <Separator classname="h-4" /> 125 - </> 126 - )} 127 - {post.quoteCount != null && post.quoteCount > 0 && ( 128 - <> 129 - <QuotesLink 130 - postUri={post.uri} 131 - parent={parent} 132 - className="flex items-center gap-1 hover:text-accent-contrast" 133 - onClick={(e) => e.stopPropagation()} 134 - > 135 - {post.quoteCount} 136 - <QuoteTiny /> 137 - </QuotesLink> 138 - <Separator classname="h-4" /> 139 - </> 140 - )} 141 - 142 - <a 143 - className="" 144 - target="_blank" 145 - href={url} 146 - onClick={(e) => e.stopPropagation()} 147 - > 148 - <BlueskyTiny /> 149 - </a> 150 - </div> 151 - </div> 152 - </div> 43 + <BskyPostContent 44 + post={postView} 45 + parent={undefined} 46 + showBlueskyLink={true} 47 + showEmbed={true} 48 + avatarSize="large" 49 + quoteEnabled 50 + replyEnabled 51 + className="text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 52 + /> 153 53 ); 154 54 } 155 55 }; 156 - 157 - const ClientDate = (props: { date?: string }) => { 158 - let pageLoaded = useHasPageLoaded(); 159 - const formattedDate = useLocalizedDate( 160 - props.date || new Date().toISOString(), 161 - { 162 - month: "short", 163 - day: "numeric", 164 - year: "numeric", 165 - hour: "numeric", 166 - minute: "numeric", 167 - hour12: true, 168 - }, 169 - ); 170 - 171 - if (!pageLoaded) return null; 172 - 173 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 174 - };
+30 -31
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 7 7 import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 8 import { openPage } from "./PostPages"; 9 9 import { BskyPostContent } from "./BskyPostContent"; 10 - import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 10 + import { 11 + QuotesLink, 12 + getQuotesKey, 13 + fetchQuotes, 14 + prefetchQuotes, 15 + } from "./PostLinks"; 11 16 12 17 // Re-export for backwards compatibility 13 18 export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; ··· 27 32 data: quotesData, 28 33 isLoading, 29 34 error, 30 - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 35 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => 36 + fetchQuotes(postUri), 37 + ); 31 38 32 39 return ( 33 40 <PageWrapper 34 41 pageType="doc" 35 42 fullPageScroll={false} 36 43 id={`post-page-${pageId}`} 37 - drawerOpen={!!drawer} 44 + drawerOpen={false} 38 45 pageOptions={pageOptions} 46 + fixedWidth 39 47 > 40 48 <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> 49 + <h4 className="text-secondary font-bold mb-2">Bluesky Quotes</h4> 45 50 {isLoading ? ( 46 51 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 47 52 <span>loading quotes</span> ··· 68 73 69 74 return ( 70 75 <div className="flex flex-col gap-0"> 71 - {posts.map((post) => ( 72 - <QuotePost 73 - key={post.uri} 74 - post={post} 75 - quotesUri={postUri} 76 - /> 76 + {posts.map((post, index) => ( 77 + <> 78 + <QuotePost key={post.uri} post={post} quotesUri={postUri} /> 79 + {posts.length !== index + 1 && ( 80 + <hr className="border-border-light my-4" /> 81 + )} 82 + </> 77 83 ))} 78 84 </div> 79 85 ); 80 86 } 81 87 82 - function QuotePost(props: { 83 - post: PostView; 84 - quotesUri: string; 85 - }) { 88 + function QuotePost(props: { post: PostView; quotesUri: string }) { 86 89 const { post, quotesUri } = props; 87 90 const parent = { type: "quotes" as const, uri: quotesUri }; 88 91 89 92 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> 93 + <BskyPostContent 94 + post={post} 95 + parent={parent} 96 + showEmbed={true} 97 + compactEmbed 98 + showBlueskyLink={true} 99 + quoteEnabled 100 + replyEnabled 101 + className="relative rounded text-sm" 102 + /> 104 103 ); 105 104 }
+234 -104
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 1 "use client"; 2 2 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { 4 - BlueskyEmbed, 5 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 3 + import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 6 4 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 7 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 6 import { CommentTiny } from "components/Icons/CommentTiny"; ··· 10 8 import { Separator } from "components/Layout"; 11 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 - import { OpenPage } from "./PostPages"; 11 + import { OpenPage, openPage } from "./PostPages"; 14 12 import { ThreadLink, QuotesLink } from "./PostLinks"; 13 + import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 14 + import { Avatar } from "components/Avatar"; 15 + import { timeAgo } from "src/utils/timeAgo"; 16 + import { ProfilePopover } from "components/ProfilePopover"; 15 17 16 18 type PostView = AppBskyFeedDefs.PostView; 17 19 18 20 export function BskyPostContent(props: { 19 21 post: PostView; 20 - parent?: OpenPage; 21 - linksEnabled?: boolean; 22 - avatarSize?: "sm" | "md"; 22 + parent: OpenPage | undefined; 23 + avatarSize?: "tiny" | "small" | "medium" | "large" | "giant"; 24 + className?: string; 23 25 showEmbed?: boolean; 26 + compactEmbed?: boolean; 24 27 showBlueskyLink?: boolean; 25 - onEmbedClick?: (e: React.MouseEvent) => void; 26 - onLinkClick?: (e: React.MouseEvent) => void; 28 + quoteEnabled?: boolean; 29 + replyEnabled?: boolean; 30 + replyOnClick?: (e: React.MouseEvent) => void; 27 31 }) { 28 32 const { 29 33 post, 30 34 parent, 31 - linksEnabled = true, 32 - avatarSize = "md", 35 + avatarSize = "medium", 33 36 showEmbed = true, 37 + compactEmbed = false, 34 38 showBlueskyLink = true, 35 - onEmbedClick, 36 - onLinkClick, 39 + quoteEnabled, 40 + replyEnabled, 41 + replyOnClick, 37 42 } = props; 38 43 39 44 const record = post.record as AppBskyFeedPost.Record; 40 45 const postId = post.uri.split("/")[4]; 41 46 const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 47 43 - const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 48 + return ( 49 + <div className={`bskyPost relative flex flex-col w-full `}> 50 + <button 51 + className="absolute inset-0" 52 + onClick={() => { 53 + openPage(parent, { type: "thread", uri: post.uri }); 54 + }} 55 + /> 44 56 45 - return ( 46 - <> 47 - <div className="flex flex-col items-center shrink-0"> 48 - {post.author.avatar ? ( 49 - <img 57 + <div 58 + className={`flex gap-2 text-left w-full pointer-events-none ${props.className}`} 59 + > 60 + <div className="flex flex-col items-start shrink-0 w-fit pointer-events-auto"> 61 + <Avatar 50 62 src={post.author.avatar} 51 - alt={`${post.author.displayName}'s avatar`} 52 - className={`${avatarClass} rounded-full border border-border-light`} 63 + displayName={post.author.displayName} 64 + size={avatarSize ? avatarSize : "medium"} 53 65 /> 54 - ) : ( 55 - <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 - )} 57 - </div> 66 + </div> 67 + <div className={`flex flex-col min-w-0 w-full mb-2`}> 68 + <div 69 + className={`bskyPostTextContent flex flex-col grow text-left w-full ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 70 + > 71 + <PostInfo 72 + displayName={post.author.displayName} 73 + handle={post.author.handle} 74 + createdAt={record.createdAt} 75 + /> 58 76 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} 77 + <div className={`postContent flex flex-col gap-2 mt-0.5`}> 78 + <div className="text-secondary"> 79 + <BlueskyRichText record={record} /> 80 + </div> 81 + {showEmbed && post.embed && ( 82 + <div 83 + className="pointer-events-auto relative" 84 + onClick={(e) => e.stopPropagation()} 85 + > 86 + <BlueskyEmbed 87 + parent={parent} 88 + embed={post.embed} 89 + compact={compactEmbed} 90 + postUrl={url} 91 + className="text-sm" 92 + /> 93 + </div> 94 + )} 95 + </div> 63 96 </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> 97 + {props.showBlueskyLink || 98 + (props.post.quoteCount && props.post.quoteCount > 0) || 99 + (props.post.replyCount && props.post.replyCount > 0) ? ( 100 + <div 101 + className={`postCountsAndLink flex gap-2 items-center justify-between mt-2 pointer-events-auto`} 102 + > 103 + <PostCounts 104 + post={post} 105 + parent={parent} 106 + replyEnabled={replyEnabled} 107 + replyOnClick={replyOnClick} 108 + quoteEnabled={quoteEnabled} 109 + showBlueskyLink={showBlueskyLink} 110 + url={url} 111 + /> 112 + 113 + <div className="flex gap-3 items-center"> 114 + {showBlueskyLink && ( 115 + <> 116 + <a 117 + className="text-tertiary hover:text-accent-contrast" 118 + target="_blank" 119 + href={url} 120 + > 121 + <BlueskyLinkTiny /> 122 + </a> 123 + </> 124 + )} 125 + </div> 126 + </div> 127 + ) : null} 72 128 </div> 129 + </div> 130 + </div> 131 + ); 132 + } 73 133 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} /> 134 + export function CompactBskyPostContent(props: { 135 + post: PostView; 136 + parent: OpenPage; 137 + className?: string; 138 + quoteEnabled?: boolean; 139 + replyEnabled?: boolean; 140 + replyOnClick?: (e: React.MouseEvent) => void; 141 + }) { 142 + const { post, parent, quoteEnabled, replyEnabled, replyOnClick } = props; 143 + 144 + const record = post.record as AppBskyFeedPost.Record; 145 + const postId = post.uri.split("/")[4]; 146 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 147 + 148 + return ( 149 + <div className="bskyPost relative flex flex-col w-full"> 150 + <button 151 + className="absolute inset-0 " 152 + onClick={() => { 153 + openPage(parent, { type: "thread", uri: post.uri }); 154 + }} 155 + /> 156 + <div className={`flex gap-2 text-left w-full ${props.className}`}> 157 + <Avatar 158 + src={post.author.avatar} 159 + displayName={post.author.displayName} 160 + size="small" 161 + /> 162 + <div className={`flex flex-col min-w-0 w-full`}> 163 + <button 164 + className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 165 + onClick={() => { 166 + openPage(parent, { type: "thread", uri: post.uri }); 167 + }} 168 + > 169 + <PostInfo 170 + displayName={post.author.displayName} 171 + handle={post.author.handle} 172 + createdAt={record.createdAt} 173 + compact 174 + /> 175 + 176 + <div className="postContent flex flex-col gap-2 mt-0.5"> 177 + <div className="line-clamp-3 text-tertiary text-xs"> 178 + <BlueskyRichText record={record} /> 179 + </div> 81 180 </div> 82 - )} 181 + </button> 182 + {(post.quoteCount && post.quoteCount > 0) || 183 + (post.replyCount && post.replyCount > 0) ? ( 184 + <div className="postCountsAndLink flex gap-2 items-center justify-between mt-2"> 185 + <PostCounts 186 + post={post} 187 + parent={parent} 188 + replyEnabled={replyEnabled} 189 + replyOnClick={replyOnClick} 190 + quoteEnabled={quoteEnabled} 191 + showBlueskyLink={false} 192 + url={url} 193 + /> 194 + </div> 195 + ) : null} 83 196 </div> 197 + </div> 198 + </div> 199 + ); 200 + } 84 201 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} 202 + function PostInfo(props: { 203 + displayName?: string; 204 + handle: string; 205 + createdAt: string; 206 + compact?: boolean; 207 + }) { 208 + const { displayName, handle, createdAt, compact = false } = props; 209 + 210 + return ( 211 + <div className="postInfo flex items-center gap-2 leading-tight w-full"> 212 + <div className="flex gap-2 items-center min-w-0"> 213 + <div className={`font-bold text-secondary truncate`}> 214 + {displayName} 215 + </div> 216 + <div className="truncate items-end flex pointer-events-auto"> 217 + <ProfilePopover 218 + trigger={ 219 + <div 220 + className={`${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline w-full truncate `} 221 + > 222 + @{handle} 223 + </div> 224 + } 225 + didOrHandle={handle} 94 226 /> 95 227 </div> 96 228 </div> 97 - </> 229 + <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 230 + <div 231 + className={`${compact ? "text-xs" : "text-sm"} text-tertiary shrink-0`} 232 + > 233 + {timeAgo(createdAt, { compact: true })} 234 + </div> 235 + </div> 98 236 ); 99 237 } 100 238 101 239 function PostCounts(props: { 102 240 post: PostView; 103 241 parent?: OpenPage; 104 - linksEnabled: boolean; 242 + quoteEnabled?: boolean; 243 + replyEnabled?: boolean; 244 + replyOnClick?: (e: React.MouseEvent) => void; 105 245 showBlueskyLink: boolean; 106 246 url: string; 107 - onLinkClick?: (e: React.MouseEvent) => void; 108 247 }) { 109 - const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 248 + const replyContent = props.post.replyCount != null && 249 + props.post.replyCount > 0 && ( 250 + <div className="postRepliesCount flex items-center gap-1 text-xs"> 251 + <CommentTiny /> 252 + {props.post.replyCount} 253 + </div> 254 + ); 255 + 256 + const quoteContent = props.post.quoteCount != null && 257 + props.post.quoteCount > 0 && ( 258 + <div className="postQuoteCount flex items-center gap-1 text-xs"> 259 + <QuoteTiny /> 260 + {props.post.quoteCount} 261 + </div> 262 + ); 110 263 111 264 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" /> 265 + <div className="postCounts flex gap-2 items-center w-full text-tertiary"> 266 + {replyContent && 267 + (props.replyEnabled ? ( 268 + <ThreadLink 269 + postUri={props.post.uri} 270 + parent={props.parent} 271 + className="relative postRepliesLink hover:text-accent-contrast" 272 + onClick={props.replyOnClick} 273 + > 274 + {replyContent} 275 + </ThreadLink> 276 + ) : ( 277 + replyContent 278 + ))} 279 + {quoteContent && 280 + (props.quoteEnabled ? ( 137 281 <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} 282 + postUri={props.post.uri} 283 + parent={props.parent} 284 + className="relative hover:text-accent-contrast" 142 285 > 143 - {post.quoteCount} 144 - <QuoteTiny /> 286 + {quoteContent} 145 287 </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 - )} 288 + ) : ( 289 + quoteContent 290 + ))} 161 291 </div> 162 292 ); 163 293 }
+14 -4
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 15 15 import { extractCodeBlocks } from "./extractCodeBlocks"; 16 16 import { LeafletLayout } from "components/LeafletLayout"; 17 17 import { fetchPollData } from "./fetchPollData"; 18 - import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; 18 + import { 19 + getDocumentPages, 20 + hasLeafletContent, 21 + } from "src/utils/normalizeRecords"; 19 22 import { DocumentProvider } from "contexts/DocumentContext"; 20 23 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 21 24 ··· 118 121 return ( 119 122 <DocumentProvider value={document}> 120 123 <LeafletContentProvider value={{ pages }}> 121 - <PublicationThemeProvider theme={document.theme} pub_creator={pub_creator} isStandalone={isStandalone}> 122 - <PublicationBackgroundProvider theme={document.theme} pub_creator={pub_creator}> 124 + <PublicationThemeProvider 125 + theme={document.theme} 126 + pub_creator={pub_creator} 127 + isStandalone={isStandalone} 128 + > 129 + <PublicationBackgroundProvider 130 + theme={document.theme} 131 + pub_creator={pub_creator} 132 + > 123 133 <LeafletLayout> 124 134 <PostPages 125 135 document_uri={document.uri} ··· 127 137 pubRecord={pubRecord} 128 138 profile={JSON.parse(JSON.stringify(profile.data))} 129 139 document={document} 130 - bskyPostData={bskyPostData} 140 + bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} 131 141 did={did} 132 142 prerenderedCodeBlocks={prerenderedCodeBlocks} 133 143 pollData={pollData}
+11 -13
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 25 25 uri: string; 26 26 bsky_profiles: { record: Json; did: string } | null; 27 27 }; 28 - export function Comments(props: { 28 + export function CommentsDrawerContent(props: { 29 29 document_uri: string; 30 30 comments: Comment[]; 31 31 pageId?: string; ··· 55 55 id={"commentsDrawer"} 56 56 className="flex flex-col gap-2 relative text-sm text-secondary" 57 57 > 58 - <div className="w-full flex justify-between text-secondary font-bold"> 59 - Comments 58 + <div className="w-full flex justify-between"> 59 + <h4> Comments</h4> 60 60 <button 61 61 className="text-tertiary" 62 62 onClick={() => ··· 75 75 </div> 76 76 )} 77 77 <hr className="border-border-light" /> 78 - <div className="flex flex-col gap-6 py-2"> 78 + <div className="flex flex-col gap-4 py-2"> 79 79 {comments 80 80 .sort((a, b) => { 81 81 let aRecord = a.record as PubLeafletComment.Record; ··· 119 119 }) => { 120 120 const did = props.comment.bsky_profiles?.did; 121 121 122 - let timeAgoDate = timeAgo(props.record.createdAt); 123 - const formattedDate = useLocalizedDate(props.record.createdAt, { 124 - year: "numeric", 125 - month: "long", 126 - day: "2-digit", 127 - }); 122 + let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 128 123 129 124 return ( 130 125 <div id={props.comment.uri} className="comment"> 131 - <div className="flex gap-2"> 126 + <div className="flex gap-2 items-center"> 132 127 {did ? ( 133 128 <ProfilePopover 134 129 didOrHandle={did} 135 130 trigger={ 136 - <div className="text-sm text-tertiary font-bold hover:underline"> 131 + <div className="text-sm text-secondary font-bold hover:underline"> 137 132 {props.profile.displayName} 138 133 </div> 139 134 } 140 135 /> 141 136 ) : null} 137 + 138 + <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 142 139 <div className="text-sm text-tertiary">{timeAgoDate}</div> 143 140 </div> 144 141 {props.record.attachment && ··· 210 207 setReplyBoxOpen(false); 211 208 }} 212 209 > 213 - <CommentTiny className="text-border" /> {replies.length} 210 + <CommentTiny className="text-border" />{" "} 211 + {replies.length !== 0 && replies.length} 214 212 </button> 215 213 {identity?.atp_did && ( 216 214 <>
+7 -4
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 - import { Quotes } from "./Quotes"; 3 + import { MentionsDrawerContent } from "./Quotes"; 4 4 import { InteractionState, useInteractionState } from "./Interactions"; 5 5 import { Json } from "supabase/database.types"; 6 - import { Comment, Comments } from "./Comments"; 6 + import { Comment, CommentsDrawerContent } from "./Comments"; 7 7 import { useSearchParams } from "next/navigation"; 8 8 import { SandwichSpacer } from "components/LeafletLayout"; 9 9 import { decodeQuotePosition } from "../quotePosition"; ··· 42 42 className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 43 43 > 44 44 {drawer.drawer === "quotes" ? ( 45 - <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> 45 + <MentionsDrawerContent 46 + {...props} 47 + quotesAndMentions={filteredQuotesAndMentions} 48 + /> 46 49 ) : ( 47 - <Comments 50 + <CommentsDrawerContent 48 51 document_uri={props.document_uri} 49 52 comments={filteredComments} 50 53 pageId={props.pageId}
+104 -134
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 24 24 import { CommentTiny } from "components/Icons/CommentTiny"; 25 25 import { QuoteTiny } from "components/Icons/QuoteTiny"; 26 26 import { ThreadLink, QuotesLink } from "../PostLinks"; 27 + import { BskyPostContent } from "../BskyPostContent"; 27 28 28 29 // Helper to get SWR key for quotes 29 30 export function getQuotesSWRKey(uris: string[]) { ··· 61 62 } 62 63 } 63 64 64 - export const Quotes = (props: { 65 + export const MentionsDrawerContent = (props: { 65 66 quotesAndMentions: { uri: string; link?: string }[]; 66 67 did: string; 67 68 }) => { ··· 85 86 }); 86 87 87 88 return ( 88 - <div className="flex flex-col gap-2"> 89 - <div className="w-full flex justify-between text-secondary font-bold"> 90 - Quotes 91 - <button 92 - className="text-tertiary" 93 - onClick={() => 94 - setInteractionState(document_uri, { drawerOpen: false }) 95 - } 96 - > 97 - <CloseTiny /> 98 - </button> 99 - </div> 89 + <div className="relative w-full flex justify-between "> 90 + <button 91 + className="text-tertiary absolute top-0 right-0" 92 + onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 93 + > 94 + <CloseTiny /> 95 + </button> 100 96 {props.quotesAndMentions.length === 0 ? ( 101 97 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 102 98 <div className="font-bold">no quotes yet!</div> ··· 108 104 <DotLoader /> 109 105 </div> 110 106 ) : ( 111 - <div className="quotes flex flex-col gap-8"> 112 - {/* Quotes with links (quoted content) */} 113 - {quotesWithLinks.map((q, index) => { 114 - const pv = postViewMap.get(q.uri); 115 - if (!pv || !q.link) return null; 116 - const url = new URL(q.link); 117 - const quoteParam = url.pathname.split("/l-quote/")[1]; 118 - if (!quoteParam) return null; 119 - const quotePosition = decodeQuotePosition(quoteParam); 120 - if (!quotePosition) return null; 121 - return ( 122 - <div key={`quote-${index}`} className="flex flex-col "> 123 - <QuoteContent 124 - index={index} 125 - did={props.did} 126 - position={quotePosition} 127 - /> 128 - 129 - <div className="h-5 w-1 ml-5 border-l border-border-light" /> 130 - <BskyPost 131 - uri={pv.uri} 132 - rkey={new AtUri(pv.uri).rkey} 133 - content={pv.record.text as string} 134 - user={pv.author.displayName || pv.author.handle} 135 - profile={pv.author} 136 - handle={pv.author.handle} 137 - replyCount={pv.replyCount} 138 - quoteCount={pv.quoteCount} 139 - /> 140 - </div> 141 - ); 142 - })} 143 - 107 + <div className="flex flex-col gap-8 w-full"> 108 + {quotesWithLinks.length > 0 && ( 109 + <div className="flex flex-col w-full"> 110 + <h4 className="mb-2">Quotes on Bluesky</h4> 111 + {/* Quotes with links (quoted content) */} 112 + {quotesWithLinks.map((q, index) => { 113 + return ( 114 + <> 115 + <Quote 116 + key={q.uri} 117 + q={q} 118 + index={index} 119 + did={props.did} 120 + postViewMap={postViewMap} 121 + /> 122 + {quotesWithLinks.length !== index + 1 && ( 123 + <hr className="border-border-light my-4" /> 124 + )} 125 + </> 126 + ); 127 + })} 128 + </div> 129 + )} 144 130 {/* Direct post mentions (without quoted content) */} 145 131 {directMentions.length > 0 && ( 146 - <div className="flex flex-col gap-4"> 147 - <div className="text-secondary font-bold">Post Mentions</div> 148 - <div className="flex flex-col gap-8"> 149 - {directMentions.map((q, index) => { 150 - const pv = postViewMap.get(q.uri); 151 - if (!pv) return null; 152 - return ( 153 - <BskyPost 132 + <div className="flex flex-col"> 133 + <h4 className="mb-2">Mentions on Bluesky</h4> 134 + {directMentions.map((q, index) => { 135 + const post = postViewMap.get(q.uri); 136 + if (!post) return null; 137 + 138 + const parent = { type: "thread" as const, uri: q.uri }; 139 + return ( 140 + <> 141 + <BskyPostContent 154 142 key={`mention-${index}`} 155 - uri={pv.uri} 156 - rkey={new AtUri(pv.uri).rkey} 157 - content={pv.record.text as string} 158 - user={pv.author.displayName || pv.author.handle} 159 - profile={pv.author} 160 - handle={pv.author.handle} 161 - replyCount={pv.replyCount} 162 - quoteCount={pv.quoteCount} 143 + post={post} 144 + parent={parent} 145 + showBlueskyLink={true} 146 + showEmbed={true} 147 + avatarSize="medium" 148 + quoteEnabled 149 + replyEnabled 150 + className="text-sm" 151 + compactEmbed 163 152 /> 164 - ); 165 - })} 166 - </div> 153 + {directMentions.length !== index + 1 && ( 154 + <hr className="border-border-light my-4" /> 155 + )} 156 + </> 157 + ); 158 + })} 167 159 </div> 168 160 )} 169 161 </div> ··· 172 164 ); 173 165 }; 174 166 167 + const Quote = (props: { 168 + q: { 169 + uri: string; 170 + link?: string; 171 + }; 172 + index: number; 173 + did: string; 174 + postViewMap: Map<string, PostView>; 175 + }) => { 176 + const post = props.postViewMap.get(props.q.uri); 177 + if (!post || !props.q.link) return null; 178 + const parent = { type: "thread" as const, uri: props.q.uri }; 179 + const url = new URL(props.q.link); 180 + const quoteParam = url.pathname.split("/l-quote/")[1]; 181 + if (!quoteParam) return null; 182 + const quotePosition = decodeQuotePosition(quoteParam); 183 + if (!quotePosition) return null; 184 + 185 + return ( 186 + <div key={`quote-${props.index}`} className="flex flex-col w-full"> 187 + <QuoteContent 188 + index={props.index} 189 + did={props.did} 190 + position={quotePosition} 191 + /> 192 + 193 + <div className="h-3 w-1 ml-[11px] border-l border-border-light" /> 194 + <BskyPostContent 195 + post={post} 196 + parent={parent} 197 + showBlueskyLink={true} 198 + showEmbed={false} 199 + avatarSize="medium" 200 + quoteEnabled 201 + replyEnabled 202 + className="text-sm" 203 + /> 204 + </div> 205 + ); 206 + }; 207 + 175 208 export const QuoteContent = (props: { 176 209 position: QuotePosition; 177 210 index: number; ··· 206 239 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 207 240 onClick={(e) => { 208 241 if (props.position.pageId) 209 - flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); 242 + flushSync(() => 243 + openPage(undefined, { type: "doc", id: props.position.pageId! }), 244 + ); 210 245 let scrollMargin = isMobile 211 246 ? 16 212 247 : e.currentTarget.getBoundingClientRect().top; 213 248 let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 214 - let scrollContainer = window.document.getElementById(scrollContainerId); 249 + let scrollContainer = 250 + window.document.getElementById(scrollContainerId); 215 251 let el = window.document.getElementById( 216 252 props.position.start.block.join("."), 217 253 ); ··· 236 272 blocks={content} 237 273 did={props.did} 238 274 preview 239 - className="py-0!" 275 + className="py-0! px-0! text-tertiary" 240 276 /> 241 - </div> 242 - </div> 243 - </div> 244 - ); 245 - }; 246 - 247 - export const BskyPost = (props: { 248 - uri: string; 249 - rkey: string; 250 - content: string; 251 - user: string; 252 - handle: string; 253 - profile: ProfileViewBasic; 254 - replyCount?: number; 255 - quoteCount?: number; 256 - }) => { 257 - const handleOpenThread = () => { 258 - openPage(undefined, { type: "thread", uri: props.uri }); 259 - }; 260 - 261 - return ( 262 - <div 263 - onClick={handleOpenThread} 264 - className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded" 265 - > 266 - {props.profile.avatar && ( 267 - <img 268 - className="rounded-full w-6 h-6 shrink-0" 269 - src={props.profile.avatar} 270 - alt={props.profile.displayName} 271 - /> 272 - )} 273 - <div className="flex flex-col min-w-0"> 274 - <div className="flex items-center gap-2 flex-wrap"> 275 - <div className="font-bold">{props.user}</div> 276 - <a 277 - className="text-tertiary hover:underline" 278 - href={`https://bsky.app/profile/${props.handle}`} 279 - target="_blank" 280 - onClick={(e) => e.stopPropagation()} 281 - > 282 - @{props.handle} 283 - </a> 284 - </div> 285 - <div className="text-primary">{props.content}</div> 286 - <div className="flex gap-2 items-center mt-1"> 287 - {props.replyCount != null && props.replyCount > 0 && ( 288 - <ThreadLink 289 - threadUri={props.uri} 290 - onClick={(e) => e.stopPropagation()} 291 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 292 - > 293 - <CommentTiny /> 294 - {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 295 - </ThreadLink> 296 - )} 297 - {props.quoteCount != null && props.quoteCount > 0 && ( 298 - <QuotesLink 299 - postUri={props.uri} 300 - onClick={(e) => e.stopPropagation()} 301 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 302 - > 303 - <QuoteTiny /> 304 - {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 305 - </QuotesLink> 306 - )} 307 277 </div> 308 278 </div> 309 279 </div>
-2
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 28 28 import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 29 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 30 import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 31 - import { openPage } from "./PostPages"; 32 - import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 33 31 import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 34 32 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 35 33 import { PollData } from "./fetchPollData";
+6 -5
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 55 55 56 56 // Link component for opening thread pages with prefetching 57 57 export function ThreadLink(props: { 58 - threadUri: string; 58 + postUri: string; 59 59 parent?: OpenPage; 60 60 children: React.ReactNode; 61 61 className?: string; 62 62 onClick?: (e: React.MouseEvent) => void; 63 63 }) { 64 - const { threadUri, parent, children, className, onClick } = props; 64 + const { postUri, parent, children, className, onClick } = props; 65 65 66 66 const handleClick = (e: React.MouseEvent) => { 67 + e.stopPropagation(); 67 68 onClick?.(e); 68 69 if (e.defaultPrevented) return; 69 - openPage(parent, { type: "thread", uri: threadUri }); 70 + openPage(parent, { type: "thread", uri: postUri }); 70 71 }; 71 72 72 73 const handlePrefetch = () => { 73 - prefetchThread(threadUri); 74 + prefetchThread(postUri); 74 75 }; 75 76 76 77 return ( ··· 96 97 const { postUri, parent, children, className, onClick } = props; 97 98 98 99 const handleClick = (e: React.MouseEvent) => { 100 + e.stopPropagation(); 99 101 onClick?.(e); 100 102 if (e.defaultPrevented) return; 101 103 openPage(parent, { type: "quotes", uri: postUri }); ··· 104 106 const handlePrefetch = () => { 105 107 prefetchQuotes(postUri); 106 108 }; 107 - 108 109 return ( 109 110 <button 110 111 className={className}
+20 -2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 111 111 const pageKey = getPageKey(page); 112 112 const parentKey = parent ? getPageKey(parent) : undefined; 113 113 114 + // Check if the page is already open 115 + const currentState = usePostPageUIState.getState(); 116 + const existingPageIndex = currentState.pages.findIndex( 117 + (p) => getPageKey(p) === pageKey, 118 + ); 119 + 120 + // If page is already open, just scroll to it 121 + if (existingPageIndex !== -1) { 122 + if (options?.scrollIntoView !== false) { 123 + scrollIntoView(`post-page-${pageKey}`); 124 + } 125 + return; 126 + } 127 + 114 128 flushSync(() => { 115 129 usePostPageUIState.setState((state) => { 116 130 let parentPosition = state.pages.findIndex( 117 131 (s) => getPageKey(s) === parentKey, 118 132 ); 133 + // Close any pages after the parent and add the new page 119 134 return { 120 135 pages: 121 136 parentPosition === -1 ··· 127 142 }); 128 143 129 144 if (options?.scrollIntoView !== false) { 130 - scrollIntoView(`post-page-${pageKey}`); 145 + // Use requestAnimationFrame to ensure the DOM has been painted before scrolling 146 + requestAnimationFrame(() => { 147 + scrollIntoView(`post-page-${pageKey}`); 148 + }); 131 149 } 132 150 }; 133 151 ··· 297 315 <Fragment key={pageKey}> 298 316 <SandwichSpacer /> 299 317 <ThreadPageComponent 300 - threadUri={openPage.uri} 318 + parentUri={openPage.uri} 301 319 pageId={pageKey} 302 320 hasPageBackground={hasPageBackground} 303 321 pageOptions={
+151 -137
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 6 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 7 7 import { DotLoader } from "components/utils/DotLoader"; 8 8 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 - import { openPage } from "./PostPages"; 10 9 import { useThreadState } from "src/useThreadState"; 11 - import { BskyPostContent, ClientDate } from "./BskyPostContent"; 10 + import { 11 + BskyPostContent, 12 + CompactBskyPostContent, 13 + ClientDate, 14 + } from "./BskyPostContent"; 12 15 import { 13 16 ThreadLink, 14 17 getThreadKey, ··· 25 28 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 26 29 27 30 export function ThreadPage(props: { 28 - threadUri: string; 31 + parentUri: string; 29 32 pageId: string; 30 33 pageOptions?: React.ReactNode; 31 34 hasPageBackground: boolean; 32 35 }) { 33 - const { threadUri, pageId, pageOptions } = props; 34 - const drawer = useDrawerOpen(threadUri); 36 + const { parentUri, pageId, pageOptions } = props; 37 + const drawer = useDrawerOpen(parentUri); 35 38 36 39 const { 37 40 data: thread, 38 41 isLoading, 39 42 error, 40 - } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 41 - fetchThread(threadUri), 43 + } = useSWR(parentUri ? getThreadKey(parentUri) : null, () => 44 + fetchThread(parentUri), 42 45 ); 43 46 44 47 return ( ··· 46 49 pageType="doc" 47 50 fullPageScroll={false} 48 51 id={`post-page-${pageId}`} 49 - drawerOpen={!!drawer} 52 + drawerOpen={false} 50 53 pageOptions={pageOptions} 54 + fixedWidth 51 55 > 52 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 56 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 53 57 {isLoading ? ( 54 58 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 55 59 <span>loading thread</span> ··· 60 64 Failed to load thread 61 65 </div> 62 66 ) : thread ? ( 63 - <ThreadContent thread={thread} threadUri={threadUri} /> 67 + <ThreadContent post={thread} parentUri={parentUri} /> 64 68 ) : null} 65 69 </div> 66 70 </PageWrapper> 67 71 ); 68 72 } 69 73 70 - function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 71 - const { thread, threadUri } = props; 74 + function ThreadContent(props: { post: ThreadType; parentUri: string }) { 75 + const { post, parentUri } = props; 72 76 const mainPostRef = useRef<HTMLDivElement>(null); 73 77 74 78 // Scroll the main post into view when the thread loads ··· 81 85 } 82 86 }, []); 83 87 84 - if (AppBskyFeedDefs.isNotFoundPost(thread)) { 88 + if (AppBskyFeedDefs.isNotFoundPost(post)) { 85 89 return <PostNotAvailable />; 86 90 } 87 91 88 - if (AppBskyFeedDefs.isBlockedPost(thread)) { 92 + if (AppBskyFeedDefs.isBlockedPost(post)) { 89 93 return ( 90 94 <div className="text-tertiary italic text-sm text-center py-8"> 91 95 This post is blocked ··· 93 97 ); 94 98 } 95 99 96 - if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 100 + if (!AppBskyFeedDefs.isThreadViewPost(post)) { 97 101 return <PostNotAvailable />; 98 102 } 99 103 100 104 // Collect all parent posts in order (oldest first) 101 105 const parents: ThreadViewPost[] = []; 102 - let currentParent = thread.parent; 106 + let currentParent = post.parent; 103 107 while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 104 108 parents.unshift(currentParent); 105 109 currentParent = currentParent.parent; 106 110 } 107 111 108 112 return ( 109 - <div className="flex flex-col gap-0"> 110 - {/* Parent posts */} 111 - {parents.map((parent, index) => ( 112 - <div key={parent.post.uri} className="flex flex-col"> 113 - <ThreadPost 114 - post={parent} 115 - isMainPost={false} 116 - showReplyLine={index < parents.length - 1 || true} 117 - threadUri={threadUri} 118 - /> 119 - </div> 113 + <div 114 + className={`threadContent flex flex-col gap-0 w-full ${parents.length !== 0 && "pt-1"}`} 115 + > 116 + {/* grandparent posts, if any */} 117 + {parents.map((parentPost, index) => ( 118 + <ThreadPost 119 + key={parentPost.post.uri} 120 + post={parentPost} 121 + isMainPost={false} 122 + pageUri={parentUri} 123 + /> 120 124 ))} 121 125 122 126 {/* Main post */} 123 127 <div ref={mainPostRef}> 124 - <ThreadPost 125 - post={thread} 126 - isMainPost={true} 127 - showReplyLine={false} 128 - threadUri={threadUri} 129 - /> 128 + <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 130 129 </div> 131 130 132 131 {/* Replies */} 133 - {thread.replies && thread.replies.length > 0 && ( 134 - <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 135 - <div className="text-tertiary text-xs font-bold mb-2 px-2"> 136 - Replies 137 - </div> 132 + {post.replies && post.replies.length > 0 && ( 133 + <div className="threadReplies flex flex-col mt-4 pt-4 border-t border-border-light w-full"> 138 134 <Replies 139 - replies={thread.replies as any[]} 140 - threadUri={threadUri} 135 + replies={post.replies as any[]} 136 + pageUri={post.post.uri} 137 + parentPostUri={post.post.uri} 141 138 depth={0} 142 - parentAuthorDid={thread.post.author.did} 139 + parentAuthorDid={post.post.author.did} 143 140 /> 144 141 </div> 145 142 )} ··· 150 147 function ThreadPost(props: { 151 148 post: ThreadViewPost; 152 149 isMainPost: boolean; 153 - showReplyLine: boolean; 154 - threadUri: string; 150 + pageUri: string; 155 151 }) { 156 - const { post, isMainPost, showReplyLine, threadUri } = props; 152 + const { post, isMainPost, pageUri } = props; 157 153 const postView = post.post; 158 - const parent = { type: "thread" as const, uri: threadUri }; 154 + const page = { type: "thread" as const, uri: pageUri }; 159 155 160 - return ( 161 - <div className="flex gap-2 relative"> 162 - {/* Reply line connector */} 163 - {showReplyLine && ( 164 - <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 - )} 156 + if (isMainPost) { 157 + return ( 158 + <div className="threadMainPost flex gap-2 relative w-full"> 159 + <BskyPostContent 160 + post={postView} 161 + parent={page} 162 + avatarSize="large" 163 + showBlueskyLink={true} 164 + showEmbed={true} 165 + compactEmbed 166 + quoteEnabled 167 + /> 168 + </div> 169 + ); 170 + } 166 171 167 - <BskyPostContent 172 + return ( 173 + <div className="threadGrandparentPost flex gap-2 relative w-full pl-[6px] pb-2"> 174 + <div className="absolute top-0 bottom-0 left-[6px] w-5 "> 175 + <div className="bg-border-light w-[2px] h-full mx-auto" /> 176 + </div> 177 + <CompactBskyPostContent 168 178 post={postView} 169 - parent={parent} 170 - linksEnabled={!isMainPost} 171 - showBlueskyLink={true} 172 - showEmbed={true} 179 + parent={page} 180 + quoteEnabled 181 + replyEnabled 173 182 /> 174 183 </div> 175 184 ); ··· 177 186 178 187 function Replies(props: { 179 188 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 180 - threadUri: string; 181 189 depth: number; 182 190 parentAuthorDid?: string; 191 + pageUri: string; 192 + parentPostUri: string; 183 193 }) { 184 - const { replies, threadUri, depth, parentAuthorDid } = props; 194 + const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props; 185 195 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 186 196 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 187 197 ··· 201 211 : replies; 202 212 203 213 return ( 204 - <div className="flex flex-col gap-0"> 214 + <div className="replies flex flex-col gap-0 w-full"> 205 215 {sortedReplies.map((reply, index) => { 206 216 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 207 217 return ( 208 218 <div 209 219 key={`not-found-${index}`} 210 - className="text-tertiary italic text-xs py-2 px-2" 220 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 211 221 > 212 222 Post not found 213 223 </div> ··· 218 228 return ( 219 229 <div 220 230 key={`blocked-${index}`} 221 - className="text-tertiary italic text-xs py-2 px-2" 231 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 222 232 > 223 233 Post blocked 224 234 </div> ··· 231 241 232 242 const hasReplies = reply.replies && reply.replies.length > 0; 233 243 const isCollapsed = collapsedThreads.has(reply.post.uri); 234 - const replyCount = reply.replies?.length ?? 0; 235 244 236 245 return ( 237 - <div key={reply.post.uri} className="flex flex-col"> 238 - <ReplyPost 239 - post={reply} 240 - showReplyLine={hasReplies || index < replies.length - 1} 241 - isLast={index === replies.length - 1 && !hasReplies} 242 - threadUri={threadUri} 243 - /> 244 - {hasReplies && depth < 3 && ( 245 - <div className="ml-2 flex"> 246 - {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 247 - <button 248 - onClick={(e) => { 249 - e.stopPropagation(); 250 - toggleCollapsed(reply.post.uri); 251 - }} 252 - className="group w-8 flex justify-center cursor-pointer shrink-0" 253 - aria-label={ 254 - isCollapsed ? "Expand replies" : "Collapse replies" 255 - } 256 - > 257 - <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 258 - </button> 259 - {isCollapsed ? ( 260 - <button 261 - onClick={(e) => { 262 - e.stopPropagation(); 263 - toggleCollapsed(reply.post.uri); 264 - }} 265 - className="text-xs text-accent-contrast hover:underline py-1 pl-1" 266 - > 267 - Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 268 - </button> 269 - ) : ( 270 - <div className="grow"> 271 - <Replies 272 - replies={reply.replies as any[]} 273 - threadUri={threadUri} 274 - depth={depth + 1} 275 - parentAuthorDid={reply.post.author.did} 276 - /> 277 - </div> 278 - )} 279 - </div> 280 - )} 281 - {hasReplies && depth >= 3 && ( 282 - <ThreadLink 283 - threadUri={reply.post.uri} 284 - parent={{ type: "thread", uri: threadUri }} 285 - className="ml-12 text-xs text-accent-contrast hover:underline py-1" 286 - > 287 - View more replies 288 - </ThreadLink> 289 - )} 290 - </div> 246 + <ReplyPost 247 + key={reply.post.uri} 248 + post={reply} 249 + isLast={index === replies.length - 1 && !hasReplies} 250 + pageUri={pageUri} 251 + parentPostUri={parentPostUri} 252 + toggleCollapsed={(uri) => toggleCollapsed(uri)} 253 + isCollapsed={isCollapsed} 254 + depth={props.depth} 255 + /> 291 256 ); 292 257 })} 258 + {pageUri && depth > 0 && replies.length > 3 && ( 259 + <ThreadLink 260 + postUri={pageUri} 261 + parent={{ type: "thread", uri: pageUri }} 262 + className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 263 + > 264 + <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> 265 + View {replies.length - 3} more{" "} 266 + {replies.length === 4 ? "reply" : "replies"} 267 + </ThreadLink> 268 + )} 293 269 </div> 294 270 ); 295 271 } 296 272 297 - function ReplyPost(props: { 273 + const ReplyPost = (props: { 298 274 post: ThreadViewPost; 299 - showReplyLine: boolean; 300 275 isLast: boolean; 301 - threadUri: string; 302 - }) { 303 - const { post, threadUri } = props; 276 + pageUri: string; 277 + parentPostUri: string; 278 + toggleCollapsed: (uri: string) => void; 279 + isCollapsed: boolean; 280 + depth: number; 281 + }) => { 282 + const { post, pageUri, parentPostUri } = props; 304 283 const postView = post.post; 305 - const parent = { type: "thread" as const, uri: threadUri }; 284 + 285 + const hasReplies = props.post.replies && props.post.replies.length > 0; 306 286 307 287 return ( 308 - <div 309 - className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 310 - onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 311 - > 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 - /> 288 + <div className="flex h-fit relative"> 289 + {props.depth > 0 && ( 290 + <> 291 + <div className="absolute replyLine top-0 bottom-0 left-0 w-6 pointer-events-none "> 292 + <div className="bg-border-light w-[2px] h-full mx-auto" /> 293 + </div> 294 + <button 295 + className="absolute top-0 bottom-0 left-0 w-6 z-10" 296 + onClick={(e) => { 297 + e.preventDefault(); 298 + e.stopPropagation(); 299 + 300 + props.toggleCollapsed(parentPostUri); 301 + console.log("reply clicked"); 302 + }} 303 + /> 304 + </> 305 + )} 306 + <div 307 + className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`} 308 + > 309 + <BskyPostContent 310 + post={postView} 311 + parent={{ type: "thread", uri: pageUri }} 312 + showEmbed={false} 313 + showBlueskyLink={false} 314 + quoteEnabled 315 + replyEnabled 316 + replyOnClick={(e) => { 317 + e.preventDefault(); 318 + props.toggleCollapsed(post.post.uri); 319 + }} 320 + className="text-sm" 321 + /> 322 + {hasReplies && props.depth < 3 && ( 323 + <div className="ml-[28px] flex grow "> 324 + {!props.isCollapsed && ( 325 + <Replies 326 + pageUri={pageUri} 327 + parentPostUri={post.post.uri} 328 + replies={props.post.replies as any[]} 329 + depth={props.depth + 1} 330 + parentAuthorDid={props.post.post.author.did} 331 + /> 332 + )} 333 + </div> 334 + )} 335 + </div> 322 336 </div> 323 337 ); 324 - } 338 + };
+25 -6
components/Avatar.tsx
··· 4 4 src: string | undefined; 5 5 displayName: string | undefined; 6 6 className?: string; 7 - tiny?: boolean; 8 - large?: boolean; 9 - giant?: boolean; 7 + size?: "tiny" | "small" | "medium" | "large" | "giant"; 10 8 }) => { 9 + let sizeClassName = 10 + props.size === "tiny" 11 + ? "w-4 h-4" 12 + : props.size === "small" 13 + ? "w-5 h-5" 14 + : props.size === "medium" 15 + ? "h-6 w-6" 16 + : props.size === "large" 17 + ? "w-8 h-8" 18 + : props.size === "giant" 19 + ? "h-16 w-16" 20 + : "w-6 h-6"; 21 + 11 22 if (props.src) 12 23 return ( 13 24 <img 14 - className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 25 + className={`${sizeClassName} relative rounded-full shrink-0 border border-border-light ${props.className}`} 15 26 src={props.src} 16 27 alt={ 17 28 props.displayName ··· 23 34 else 24 35 return ( 25 36 <div 26 - className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`} 37 + className={` relative bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${sizeClassName}`} 27 38 > 28 - <AccountTiny className={props.tiny ? "scale-80" : "scale-90"} /> 39 + <AccountTiny 40 + className={ 41 + props.size === "tiny" 42 + ? "scale-80" 43 + : props.size === "small" 44 + ? "scale-90" 45 + : "" 46 + } 47 + /> 29 48 </div> 30 49 ); 31 50 };
+70 -45
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 10 10 AppBskyGraphDefs, 11 11 AppBskyLabelerDefs, 12 12 } from "@atproto/api"; 13 + import { Avatar } from "components/Avatar"; 14 + import { 15 + OpenPage, 16 + openPage, 17 + } from "app/lish/[did]/[publication]/[rkey]/PostPages"; 13 18 14 19 export const BlueskyEmbed = (props: { 15 20 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 16 21 postUrl?: string; 22 + className?: string; 23 + compact?: boolean; 24 + parent?: OpenPage; 17 25 }) => { 18 26 // check this file from bluesky for ref 19 27 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx ··· 21 29 case AppBskyEmbedImages.isView(props.embed): 22 30 let imageEmbed = props.embed; 23 31 return ( 24 - <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 32 + <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 25 33 {imageEmbed.images.map( 26 34 ( 27 35 image: { ··· 68 76 let isGif = externalEmbed.external.uri.includes(".gif"); 69 77 if (isGif) { 70 78 return ( 71 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 79 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full "> 72 80 <img 73 81 src={externalEmbed.external.uri} 74 82 alt={externalEmbed.external.title} ··· 81 89 <a 82 90 href={externalEmbed.external.uri} 83 91 target="_blank" 84 - className="group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border" 92 + className={`externalLinkEmbed group border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border w-full ${props.compact ? "flex items-stretch" : "flex flex-col"} 93 + ${props.className}`} 85 94 > 86 95 {externalEmbed.external.thumb === undefined ? null : ( 87 96 <> 88 - <div className="w-full aspect-[1.91/1] overflow-hidden"> 97 + <div 98 + className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`} 99 + > 89 100 <img 90 101 src={externalEmbed.external.thumb} 91 102 alt={externalEmbed.external.title} 92 - className="w-full h-full object-cover" 103 + className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`} 93 104 /> 94 105 </div> 95 - <hr className="border-border-light" /> 106 + {!props.compact && <hr className="border-border-light" />} 96 107 </> 97 108 )} 98 - <div className="p-2 flex flex-col gap-1"> 99 - <div className="flex flex-col"> 100 - <h4>{externalEmbed.external.title}</h4> 101 - <p className="text-secondary"> 109 + <div 110 + className={`p-2 flex flex-col w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`} 111 + > 112 + <h4 className="truncate shrink-0" style={{ fontSize: "inherit" }}> 113 + {externalEmbed.external.title}{" "} 114 + </h4> 115 + <div className="grow"> 116 + <p className="text-secondary line-clamp-2"> 102 117 {externalEmbed.external.description} 103 118 </p> 104 119 </div> 105 - <hr className="border-border-light mt-1" /> 106 - <div className="text-tertiary text-xs sm:group-hover:text-accent-contrast"> 120 + 121 + <hr className="border-border-light my-1" /> 122 + <div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate"> 107 123 {externalEmbed.external.uri} 108 124 </div> 109 125 </div> ··· 116 132 : 16 / 9; 117 133 return ( 118 134 <div 119 - className="rounded-md overflow-hidden relative w-full" 135 + className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 120 136 style={{ aspectRatio: String(videoAspectRatio) }} 121 137 > 122 138 <img ··· 147 163 text = (record.value as AppBskyFeedPost.Record).text; 148 164 } 149 165 return ( 150 - <div 151 - className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 166 + <button 167 + className={`bskyPostEmbed text-left w-full flex gap-2 items-start relative overflow-hidden p-2! text-xs block-border hover:border-accent-contrast! `} 168 + onClick={(e) => { 169 + e.preventDefault(); 170 + e.stopPropagation(); 171 + 172 + openPage(props.parent, { type: "thread", uri: record.uri }); 173 + }} 152 174 > 153 - <div className="bskyAuthor w-full flex items-center "> 154 - {record.author.avatar && ( 155 - <img 156 - src={record.author?.avatar} 157 - alt={`${record.author?.displayName}'s avatar`} 158 - className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 - /> 160 - )} 161 - <div className=" font-bold text-secondary mr-1"> 162 - {record.author?.displayName} 175 + <Avatar 176 + src={record.author?.avatar} 177 + displayName={record.author?.displayName} 178 + size="small" 179 + /> 180 + <div className="flex flex-col "> 181 + <div className="flex gap-1"> 182 + <div className=" font-bold text-secondary mr-1"> 183 + {record.author?.displayName} 184 + </div> 185 + <a 186 + className="text-xs text-tertiary hover:underline" 187 + target="_blank" 188 + href={`https://bsky.app/profile/${record.author?.handle}`} 189 + > 190 + @{record.author?.handle} 191 + </a> 192 + </div> 193 + <div className="flex flex-col gap-2 "> 194 + {text && ( 195 + <pre 196 + className={`whitespace-pre-wrap text-secondary ${props.compact ? "line-clamp-6" : ""}`} 197 + > 198 + {text} 199 + </pre> 200 + )} 201 + {/*{record.embeds !== undefined 202 + ? record.embeds.map((embed, index) => ( 203 + <BlueskyEmbed embed={embed} key={index} compact /> 204 + )) 205 + : null}*/} 163 206 </div> 164 - <a 165 - className="text-xs text-tertiary hover:underline" 166 - target="_blank" 167 - href={`https://bsky.app/profile/${record.author?.handle}`} 168 - > 169 - @{record.author?.handle} 170 - </a> 171 207 </div> 172 - 173 - <div className="flex flex-col gap-2 "> 174 - {text && ( 175 - <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 - )} 177 - {record.embeds !== undefined 178 - ? record.embeds.map((embed, index) => ( 179 - <BlueskyEmbed embed={embed} key={index} /> 180 - )) 181 - : null} 182 - </div> 183 - </div> 208 + </button> 184 209 ); 185 210 } 186 211 ··· 207 232 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 208 233 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 209 234 return ( 210 - <div className={`flex flex-col gap-2`}> 235 + <div className={`bskyEmbed flex flex-col gap-2`}> 211 236 <BlueskyEmbed embed={props.embed.media} /> 212 237 <BlueskyEmbed 213 238 embed={{
+15 -65
components/Blocks/BlueskyPostBlock/index.tsx
··· 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 - import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed"; 9 + import { PostNotAvailable } from "./BlueskyEmbed"; 10 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 - import { BlueskyRichText } from "./BlueskyRichText"; 11 + 12 12 import { Separator } from "components/Layout"; 13 13 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 14 import { CommentTiny } from "components/Icons/CommentTiny"; 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 + import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 17 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 16 18 17 19 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 18 20 let { permissions } = useEntitySetContext(); ··· 76 78 77 79 //getting the url to the post 78 80 let postId = post.post.uri.split("/")[4]; 81 + let postView = post.post as PostView; 79 82 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 80 83 81 84 return ( 82 85 <BlockLayout 83 86 isSelected={!!isSelected} 84 87 hasBackground="page" 85 - className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 88 + borderOnHover 89 + className="blueskyPostBlock sm:px-3! sm:py-2! px-2! py-1!" 86 90 > 87 - {post.post.author && record && ( 88 - <> 89 - <div className="bskyAuthor w-full flex items-center gap-2"> 90 - {post.post.author?.avatar ? ( 91 - <img 92 - src={post.post.author?.avatar} 93 - alt={`${post.post.author?.displayName}'s avatar`} 94 - className="shrink-0 w-8 h-8 rounded-full border border-border-light" 95 - /> 96 - ) : ( 97 - <div className="shrink-0 w-8 h-8 rounded-full border border-border-light bg-border"></div> 98 - )} 99 - <div className="grow flex flex-col gap-0.5 leading-tight"> 100 - <div className=" font-bold text-secondary"> 101 - {post.post.author?.displayName} 102 - </div> 103 - <a 104 - className="text-xs text-tertiary hover:underline" 105 - target="_blank" 106 - href={`https://bsky.app/profile/${post.post.author?.handle}`} 107 - > 108 - @{post.post.author?.handle} 109 - </a> 110 - </div> 111 - </div> 112 - 113 - <div className="flex flex-col gap-2 "> 114 - <div> 115 - <pre className="whitespace-pre-wrap"> 116 - {BlueskyRichText({ 117 - record: record as AppBskyFeedPost.Record | null, 118 - })} 119 - </pre> 120 - </div> 121 - {post.post.embed && ( 122 - <BlueskyEmbed embed={post.post.embed} postUrl={url} /> 123 - )} 124 - </div> 125 - </> 126 - )} 127 - <div className="w-full flex gap-2 items-center justify-between"> 128 - {timestamp && <PostDate timestamp={timestamp} />} 129 - <div className="flex gap-2 items-center"> 130 - {post.post.replyCount != null && post.post.replyCount > 0 && ( 131 - <> 132 - <a 133 - className="flex items-center gap-1 hover:no-underline" 134 - target="_blank" 135 - href={url} 136 - > 137 - {post.post.replyCount} 138 - <CommentTiny /> 139 - </a> 140 - <Separator classname="h-4" /> 141 - </> 142 - )} 143 - 144 - <a className="" target="_blank" href={url}> 145 - <BlueskyTiny /> 146 - </a> 147 - </div> 148 - </div> 91 + <BskyPostContent 92 + post={postView} 93 + parent={undefined} 94 + showBlueskyLink={true} 95 + showEmbed={true} 96 + avatarSize="large" 97 + className="text-sm text-secondary " 98 + /> 149 99 </BlockLayout> 150 100 ); 151 101 }
+2 -1
components/Pages/Page.tsx
··· 80 80 onClickAction?: (e: React.MouseEvent) => void; 81 81 pageType: "canvas" | "doc"; 82 82 drawerOpen: boolean | undefined; 83 + fixedWidth?: boolean; 83 84 }) => { 84 85 const cardBorderHidden = useCardBorderHidden(); 85 86 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); ··· 112 113 } 113 114 ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 114 115 ${props.fullPageScroll && "max-w-full "} 115 - ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 116 + ${props.pageType === "doc" && !props.fullPageScroll ? (props.fixedWidth ? "w-[10000px] sm:max-w-prose max-w-[var(--page-width-units)]" : "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]") : ""} 116 117 ${ 117 118 props.pageType === "canvas" && 118 119 !props.fullPageScroll &&
+8 -1
src/utils/scrollIntoView.ts
··· 6 6 threshold: number = 0.9, 7 7 ) { 8 8 const element = document.getElementById(elementId); 9 - scrollIntoViewIfNeeded(element, false, "smooth"); 9 + // Use double requestAnimationFrame to ensure the element is fully painted 10 + // before attempting to scroll. This fixes smooth scrolling when opening 11 + // pages from within other pages. 12 + requestAnimationFrame(() => { 13 + requestAnimationFrame(() => { 14 + scrollIntoViewIfNeeded(element, false, "smooth"); 15 + }); 16 + }); 10 17 }
+23 -1
src/utils/timeAgo.ts
··· 1 - export function timeAgo(timestamp: string): string { 1 + export function timeAgo( 2 + timestamp: string, 3 + options?: { compact?: boolean }, 4 + ): string { 5 + const { compact } = options ?? {}; 2 6 const now = new Date(); 3 7 const date = new Date(timestamp); 4 8 const diffMs = now.getTime() - date.getTime(); ··· 9 13 const diffWeeks = Math.floor(diffDays / 7); 10 14 const diffMonths = Math.floor(diffDays / 30); 11 15 const diffYears = Math.floor(diffDays / 365); 16 + 17 + if (compact) { 18 + if (diffYears > 0) { 19 + return `${diffYears}y`; 20 + } else if (diffMonths > 0) { 21 + return `${diffMonths}mo`; 22 + } else if (diffWeeks > 0) { 23 + return `${diffWeeks}w`; 24 + } else if (diffDays > 0) { 25 + return `${diffDays}d`; 26 + } else if (diffHours > 0) { 27 + return `${diffHours}h`; 28 + } else if (diffMinutes > 0) { 29 + return `${diffMinutes}m`; 30 + } else { 31 + return "now"; 32 + } 33 + } 12 34 13 35 if (diffYears > 0) { 14 36 return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;