a tool for shared writing and social publishing

just like a bunch of stuff sorry

+374 -323
+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
+11 -13
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 ··· 69 76 return ( 70 77 <div className="flex flex-col gap-0"> 71 78 {posts.map((post) => ( 72 - <QuotePost 73 - key={post.uri} 74 - post={post} 75 - quotesUri={postUri} 76 - /> 79 + <QuotePost key={post.uri} post={post} quotesUri={postUri} /> 77 80 ))} 78 81 </div> 79 82 ); 80 83 } 81 84 82 - function QuotePost(props: { 83 - post: PostView; 84 - quotesUri: string; 85 - }) { 85 + function QuotePost(props: { post: PostView; quotesUri: string }) { 86 86 const { post, quotesUri } = props; 87 87 const parent = { type: "quotes" as const, uri: quotesUri }; 88 88 ··· 94 94 <BskyPostContent 95 95 post={post} 96 96 parent={parent} 97 - linksEnabled={true} 98 97 showEmbed={true} 99 98 showBlueskyLink={true} 100 - onLinkClick={(e) => e.stopPropagation()} 101 99 onEmbedClick={(e) => e.stopPropagation()} 102 100 /> 103 101 </div>
+83 -77
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"; ··· 12 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 11 import { 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 22 parent?: OpenPage; 21 - linksEnabled?: boolean; 22 - avatarSize?: "sm" | "md"; 23 + avatarSize?: "tiny" | "medium" | "large" | "giant"; 24 + className?: string; 23 25 showEmbed?: boolean; 24 26 showBlueskyLink?: boolean; 25 27 onEmbedClick?: (e: React.MouseEvent) => void; 26 - onLinkClick?: (e: React.MouseEvent) => void; 28 + quoteCountOnClick?: (e: React.MouseEvent) => void; 29 + replyCountOnClick?: (e: React.MouseEvent) => void; 27 30 }) { 28 31 const { 29 32 post, 30 33 parent, 31 - linksEnabled = true, 32 34 avatarSize = "md", 33 35 showEmbed = true, 34 36 showBlueskyLink = true, 35 37 onEmbedClick, 36 - onLinkClick, 38 + quoteCountOnClick, 39 + replyCountOnClick, 37 40 } = props; 38 41 39 42 const record = post.record as AppBskyFeedPost.Record; 40 43 const postId = post.uri.split("/")[4]; 41 44 const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 45 43 - const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 44 - 45 46 return ( 46 47 <> 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> 48 + <Avatar 49 + src={post.author.avatar} 50 + displayName={post.author.displayName} 51 + size={props.avatarSize ? props.avatarSize : "medium"} 52 + /> 58 53 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} 54 + <div className={`flex flex-col grow min-w-0 ${props.className}`}> 55 + <div 56 + className={`flex justify-between items-center gap-2 leading-tight `} 57 + > 58 + <div className="flex gap-2 items-center"> 59 + <div className="font-bold text-secondary"> 60 + {post.author.displayName} 61 + </div> 62 + <ProfilePopover 63 + trigger={ 64 + <div className="text-sm text-tertiary hover:underline"> 65 + @{post.author.handle} 66 + </div> 67 + } 68 + didOrHandle={post.author.handle} 69 + /> 63 70 </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> 71 + <div className="text-sm text-tertiary"> 72 + {timeAgo(record.createdAt, { compact: true })} 73 + </div> 72 74 </div> 73 75 74 - <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 76 + <div 77 + className={`flex flex-col gap-2 ${avatarSize === "large" ? "mt-0.5" : "mt-1"}`} 78 + > 75 79 <div className="text-sm text-secondary"> 76 80 <BlueskyRichText record={record} /> 77 81 </div> 78 82 {showEmbed && post.embed && ( 79 83 <div onClick={onEmbedClick}> 80 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 84 + <BlueskyEmbed 85 + embed={post.embed} 86 + postUrl={url} 87 + className="text-sm" 88 + /> 81 89 </div> 82 90 )} 83 91 </div> 84 92 85 - <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 - <ClientDate date={record.createdAt} /> 93 + <div className={`flex gap-2 items-center justify-between mt-2`}> 87 94 <PostCounts 88 95 post={post} 89 96 parent={parent} 90 - linksEnabled={linksEnabled} 97 + replyCountOnClick={replyCountOnClick} 98 + quoteCountOnClick={quoteCountOnClick} 91 99 showBlueskyLink={showBlueskyLink} 92 100 url={url} 93 - onLinkClick={onLinkClick} 94 101 /> 102 + <div className="flex gap-3 items-center"> 103 + {showBlueskyLink && ( 104 + <> 105 + <a className="text-tertiary" target="_blank" href={url}> 106 + <BlueskyLinkTiny /> 107 + </a> 108 + </> 109 + )} 110 + </div> 95 111 </div> 96 112 </div> 97 113 </> ··· 101 117 function PostCounts(props: { 102 118 post: PostView; 103 119 parent?: OpenPage; 104 - linksEnabled: boolean; 120 + quoteCountOnClick?: (e: React.MouseEvent) => void; 121 + replyCountOnClick?: (e: React.MouseEvent) => void; 105 122 showBlueskyLink: boolean; 106 123 url: string; 107 - onLinkClick?: (e: React.MouseEvent) => void; 108 124 }) { 109 - const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 110 - 111 125 return ( 112 - <div className="flex gap-2 items-center"> 113 - {post.replyCount != null && post.replyCount > 0 && ( 126 + <div className="postCounts flex gap-2 items-center"> 127 + {props.post.replyCount != null && props.post.replyCount > 0 && ( 114 128 <> 115 - <Separator classname="h-3" /> 116 - {linksEnabled ? ( 129 + {props.replyCountOnClick ? ( 117 130 <ThreadLink 118 - threadUri={post.uri} 131 + threadUri={props.post.uri} 119 132 parent={parent} 120 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 - onClick={onLinkClick} 133 + className="relative postRepliesLink flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 134 + onClick={props.replyCountOnClick} 122 135 > 123 - {post.replyCount} 124 136 <CommentTiny /> 137 + {props.post.replyCount} 125 138 </ThreadLink> 126 139 ) : ( 127 - <div className="flex items-center gap-1 text-tertiary text-xs"> 128 - {post.replyCount} 140 + <div className="postRepliesCount flex items-center gap-1 text-tertiary text-xs"> 129 141 <CommentTiny /> 142 + {props.post.replyCount} 130 143 </div> 131 144 )} 132 145 </> 133 146 )} 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 && ( 147 + {props.post.quoteCount != null && props.post.quoteCount > 0 && ( 149 148 <> 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> 149 + {props.quoteCountOnClick ? ( 150 + <QuotesLink 151 + postUri={props.post.uri} 152 + parent={parent} 153 + className="relative flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 154 + onClick={props.quoteCountOnClick} 155 + > 156 + <QuoteTiny /> 157 + {props.post.quoteCount} 158 + </QuotesLink> 159 + ) : ( 160 + <div className="postQuoteCount flex items-center gap-1 text-tertiary text-xs"> 161 + <QuoteTiny /> 162 + {props.post.quoteCount} 163 + </div> 164 + )} 159 165 </> 160 166 )} 161 167 </div>
+1 -1
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;
+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}
+98 -128
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"> 108 + {quotesWithLinks.length > 0 && ( 109 + <div className="flex flex-col gap-4"> 110 + Quotes 111 + {/* Quotes with links (quoted content) */} 112 + {quotesWithLinks.map((q, index) => { 113 + return ( 114 + <div className="flex gap-2"> 115 + <Quote 116 + q={q} 117 + index={index} 118 + did={props.did} 119 + postViewMap={postViewMap} 120 + /> 121 + </div> 122 + ); 123 + })} 124 + </div> 125 + )} 144 126 {/* Direct post mentions (without quoted content) */} 145 127 {directMentions.length > 0 && ( 146 128 <div className="flex flex-col gap-4"> 147 - <div className="text-secondary font-bold">Post Mentions</div> 129 + <div className="text-secondary font-bold"> 130 + Mentions on Bluesky 131 + </div> 148 132 <div className="flex flex-col gap-8"> 149 133 {directMentions.map((q, index) => { 150 - const pv = postViewMap.get(q.uri); 151 - if (!pv) return null; 134 + const post = postViewMap.get(q.uri); 135 + if (!post) return null; 136 + 137 + const parent = { type: "thread" as const, uri: q.uri }; 152 138 return ( 153 - <BskyPost 154 - 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} 163 - /> 139 + <button 140 + className="flex gap-2 text-left" 141 + onClick={() => { 142 + openPage(undefined, { type: "thread", uri: q.uri }); 143 + }} 144 + > 145 + <BskyPostContent 146 + key={`mention-${index}`} 147 + post={post} 148 + parent={parent} 149 + showBlueskyLink={true} 150 + showEmbed={true} 151 + avatarSize="large" 152 + quoteCountOnClick={(e) => { 153 + e.stopPropagation(); 154 + e.preventDefault(); 155 + openPage(undefined, { type: "quotes", uri: q.uri }); 156 + }} 157 + /> 158 + </button> 164 159 ); 165 160 })} 166 161 </div> ··· 172 167 ); 173 168 }; 174 169 170 + const Quote = (props: { 171 + q: { 172 + uri: string; 173 + link?: string; 174 + }; 175 + index: number; 176 + did: string; 177 + postViewMap: Map<string, PostView>; 178 + }) => { 179 + const post = props.postViewMap.get(props.q.uri); 180 + if (!post || !props.q.link) return null; 181 + const parent = { type: "thread" as const, uri: props.q.uri }; 182 + const url = new URL(props.q.link); 183 + const quoteParam = url.pathname.split("/l-quote/")[1]; 184 + if (!quoteParam) return null; 185 + const quotePosition = decodeQuotePosition(quoteParam); 186 + if (!quotePosition) return null; 187 + 188 + return ( 189 + <div key={`quote-${props.index}`} className="flex flex-col "> 190 + <QuoteContent 191 + index={props.index} 192 + did={props.did} 193 + position={quotePosition} 194 + /> 195 + 196 + <div className="h-5 w-1 ml-5 border-l border-border-light" /> 197 + <BskyPostContent 198 + post={post} 199 + parent={parent} 200 + showBlueskyLink={true} 201 + showEmbed={true} 202 + avatarSize="large" 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 ); ··· 238 274 preview 239 275 className="py-0!" 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>
-1
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 104 104 const handlePrefetch = () => { 105 105 prefetchQuotes(postUri); 106 106 }; 107 - 108 107 return ( 109 108 <button 110 109 className={className}
+116 -86
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 106 106 } 107 107 108 108 return ( 109 - <div className="flex flex-col gap-0"> 110 - {/* Parent posts */} 109 + <div className="threadContent flex flex-col gap-0"> 110 + {/* grandparent posts, if any */} 111 111 {parents.map((parent, index) => ( 112 112 <div key={parent.post.uri} className="flex flex-col"> 113 113 <ThreadPost ··· 131 131 132 132 {/* Replies */} 133 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> 134 + <div className="threadReplies flex flex-col mt-2 pt-2 border-t border-border-light"> 138 135 <Replies 139 136 replies={thread.replies as any[]} 140 137 threadUri={threadUri} ··· 158 155 const parent = { type: "thread" as const, uri: threadUri }; 159 156 160 157 return ( 161 - <div className="flex gap-2 relative"> 158 + <div className="threadPost flex gap-2 relative"> 162 159 {/* Reply line connector */} 163 160 {showReplyLine && ( 164 161 <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 162 )} 166 - 167 163 <BskyPostContent 168 164 post={postView} 169 165 parent={parent} 170 - linksEnabled={!isMainPost} 171 166 showBlueskyLink={true} 172 167 showEmbed={true} 168 + quoteCountOnClick={(e) => { 169 + e.stopPropagation(); 170 + e.preventDefault(); 171 + openPage(parent, { type: "quotes", uri: postView.uri }); 172 + }} 173 + replyCountOnClick={(e) => { 174 + e.stopPropagation(); 175 + e.preventDefault(); 176 + }} 173 177 /> 174 178 </div> 175 179 ); ··· 180 184 threadUri: string; 181 185 depth: number; 182 186 parentAuthorDid?: string; 187 + parentUri?: string; 183 188 }) { 184 - const { replies, threadUri, depth, parentAuthorDid } = props; 189 + const { replies, threadUri, depth, parentAuthorDid, parentUri } = props; 185 190 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 186 191 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 187 192 ··· 201 206 : replies; 202 207 203 208 return ( 204 - <div className="flex flex-col gap-0"> 209 + <div className="threadPageReplies flex flex-col gap-0"> 205 210 {sortedReplies.map((reply, index) => { 206 211 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 207 212 return ( 208 213 <div 209 214 key={`not-found-${index}`} 210 - className="text-tertiary italic text-xs py-2 px-2" 215 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 211 216 > 212 217 Post not found 213 218 </div> ··· 218 223 return ( 219 224 <div 220 225 key={`blocked-${index}`} 221 - className="text-tertiary italic text-xs py-2 px-2" 226 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 222 227 > 223 228 Post blocked 224 229 </div> ··· 231 236 232 237 const hasReplies = reply.replies && reply.replies.length > 0; 233 238 const isCollapsed = collapsedThreads.has(reply.post.uri); 234 - const replyCount = reply.replies?.length ?? 0; 235 239 236 240 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> 241 + <ReplyPost 242 + post={reply} 243 + isLast={index === replies.length - 1 && !hasReplies} 244 + threadUri={threadUri} 245 + toggleCollapsed={(e) => { 246 + e.stopPropagation(); 247 + e.preventDefault(); 248 + if (parentUri) toggleCollapsed(parentUri); 249 + console.log("collapse?"); 250 + }} 251 + isCollapsed={isCollapsed} 252 + depth={props.depth} 253 + /> 291 254 ); 292 255 })} 256 + {parentUri && depth > 0 && replies.length > 3 && ( 257 + <ThreadLink 258 + threadUri={parentUri} 259 + parent={{ type: "thread", uri: threadUri }} 260 + className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 261 + > 262 + <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> 263 + View {replies.length - 3} more{" "} 264 + {replies.length === 4 ? "reply" : "replies"} 265 + </ThreadLink> 266 + )} 293 267 </div> 294 268 ); 295 269 } 296 270 297 - function ReplyPost(props: { 271 + const ReplyPost = (props: { 298 272 post: ThreadViewPost; 299 - showReplyLine: boolean; 300 273 isLast: boolean; 301 274 threadUri: string; 302 - }) { 275 + toggleCollapsed: (e: React.MouseEvent) => void; 276 + isCollapsed: boolean; 277 + depth: number; 278 + }) => { 303 279 const { post, threadUri } = props; 304 280 const postView = post.post; 305 281 const parent = { type: "thread" as const, uri: threadUri }; 306 282 283 + const hasReplies = props.post.replies && props.post.replies.length > 0; 284 + 285 + // was in the middle of trying to get the right set of comments to close when this line is clicked 286 + // then i really need to style the parent and grandparent threads, hide some of the content unless its the main post 287 + // the thread line on them is also weird 307 288 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 - /> 289 + <div className="threadReply relative flex flex-col"> 290 + {props.depth > 0 && ( 291 + <button 292 + onClick={(e) => { 293 + props.toggleCollapsed(e); 294 + }} 295 + className="replyThreadLine absolute top-0 bottom-0 left-1 z-0 cursor-pointer shrink-0 " 296 + aria-label={"Toggle replies"} 297 + > 298 + <div className="mx-[15px] w-0.5 h-full bg-border-light" /> 299 + </button> 300 + )} 301 + 302 + <button 303 + className="replyThreadPost flex gap-2 text-left relative py-2 px-2 rounded cursor-pointer" 304 + onClick={() => { 305 + openPage(parent, { type: "thread", uri: postView.uri }); 306 + }} 307 + > 308 + <BskyPostContent 309 + post={postView} 310 + parent={parent} 311 + showEmbed={false} 312 + showBlueskyLink={false} 313 + quoteCountOnClick={(e) => { 314 + e.preventDefault(); 315 + e.stopPropagation(); 316 + openPage(parent, { type: "quotes", uri: postView.uri }); 317 + }} 318 + replyCountOnClick={(e) => { 319 + e.preventDefault(); 320 + e.stopPropagation(); 321 + props.toggleCollapsed(); 322 + }} 323 + onEmbedClick={(e) => e.stopPropagation()} 324 + className="text-sm z-10" 325 + /> 326 + </button> 327 + {hasReplies && props.depth < 3 && ( 328 + <div className="ml-[28px] flex"> 329 + {!props.isCollapsed && ( 330 + <div className="grow"> 331 + <Replies 332 + parentUri={postView.uri} 333 + replies={props.post.replies as any[]} 334 + threadUri={threadUri} 335 + depth={props.depth + 1} 336 + parentAuthorDid={props.post.post.author.did} 337 + /> 338 + </div> 339 + )} 340 + </div> 341 + )} 342 + 343 + {hasReplies && props.depth >= 3 && ( 344 + <ThreadLink 345 + threadUri={props.post.post.uri} 346 + parent={{ type: "thread", uri: threadUri }} 347 + className="text-left ml-10 text-sm text-accent-contrast hover:underline" 348 + > 349 + View more replies 350 + </ThreadLink> 351 + )} 322 352 </div> 323 353 ); 324 - } 354 + };
+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 };
+4 -3
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 14 14 export const BlueskyEmbed = (props: { 15 15 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 16 16 postUrl?: string; 17 + className?: string; 17 18 }) => { 18 19 // check this file from bluesky for ref 19 20 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx ··· 81 82 <a 82 83 href={externalEmbed.external.uri} 83 84 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" 85 + className={`blueskyPostEmbed group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border ${props.className}`} 85 86 > 86 87 {externalEmbed.external.thumb === undefined ? null : ( 87 88 <> ··· 116 117 : 16 / 9; 117 118 return ( 118 119 <div 119 - className="rounded-md overflow-hidden relative w-full" 120 + className={`rounded-md overflow-hidden relative w-full ${props.className}`} 120 121 style={{ aspectRatio: String(videoAspectRatio) }} 121 122 > 122 123 <img ··· 207 208 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 208 209 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 209 210 return ( 210 - <div className={`flex flex-col gap-2`}> 211 + <div className={`bskyEmbed flex flex-col gap-2`}> 211 212 <BlueskyEmbed embed={props.embed.media} /> 212 213 <BlueskyEmbed 213 214 embed={{
+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`;