a tool for shared writing and social publishing

styling the grandparents in the threadviewer

+188 -69
+103 -7
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 20 20 export function BskyPostContent(props: { 21 21 post: PostView; 22 22 parent: OpenPage; 23 - avatarSize?: "tiny" | "medium" | "large" | "giant"; 23 + avatarSize?: "tiny" | "small" | "medium" | "large" | "giant"; 24 24 className?: string; 25 25 showEmbed?: boolean; 26 + compactEmbed?: boolean; 26 27 showBlueskyLink?: boolean; 27 28 onEmbedClick?: (e: React.MouseEvent) => void; 28 29 quoteEnabled?: boolean; ··· 35 36 const { 36 37 post, 37 38 parent, 38 - avatarSize = "md", 39 + avatarSize = "medium", 39 40 showEmbed = true, 41 + compactEmbed = false, 40 42 showBlueskyLink = true, 41 43 onEmbedClick, 42 44 quoteEnabled, ··· 67 69 <Avatar 68 70 src={post.author.avatar} 69 71 displayName={post.author.displayName} 70 - size={props.avatarSize ? props.avatarSize : "medium"} 72 + size={avatarSize ? avatarSize : "medium"} 71 73 /> 72 74 {replyLine && ( 73 75 <button ··· 82 84 )} 83 85 </div> 84 86 <div 85 - className={`flex flex-col w-full z-0 ${props.replyLine ? "mt-2" : ""}`} 87 + className={`flex flex-col min-w-0 w-full z-0 ${props.replyLine ? "mt-2" : ""}`} 86 88 > 87 89 <button 88 - className={`bskyPostTextContent flex flex-col grow min-w-0 mt-1 text-left`} 90 + className="bskyPostTextContent flex flex-col grow mt-1 text-left" 89 91 onClick={() => { 90 92 openPage(parent, { type: "thread", uri: post.uri }); 91 93 }} ··· 93 95 <div 94 96 className={`postInfo flex justify-between items-center gap-2 leading-tight `} 95 97 > 96 - <div className="flex gap-2 items-center"> 98 + <div className={`flex gap-2 items-center `}> 97 99 <div className="font-bold text-secondary"> 98 100 {post.author.displayName} 99 101 </div> ··· 112 114 </div> 113 115 114 116 <div className={`postContent flex flex-col gap-2 mt-0.5`}> 115 - <div className="text-sm text-secondary"> 117 + <div className="text-secondary text-sm"> 116 118 <BlueskyRichText record={record} /> 117 119 </div> 118 120 {showEmbed && post.embed && ( 119 121 <div onClick={onEmbedClick}> 120 122 <BlueskyEmbed 121 123 embed={post.embed} 124 + compact={compactEmbed} 122 125 postUrl={url} 123 126 className="text-sm" 124 127 /> ··· 151 154 </> 152 155 )} 153 156 </div> 157 + </div> 158 + ) : null} 159 + </div> 160 + </div> 161 + </div> 162 + ); 163 + } 164 + 165 + export function CompactBskyPostContent(props: { 166 + post: PostView; 167 + parent: OpenPage; 168 + className?: string; 169 + quoteEnabled?: boolean; 170 + replyEnabled?: boolean; 171 + replyOnClick?: (e: React.MouseEvent) => void; 172 + replyLine?: { 173 + onToggle: (e: React.MouseEvent) => void; 174 + }; 175 + }) { 176 + const { post, parent, quoteEnabled, replyEnabled, replyOnClick, replyLine } = 177 + props; 178 + 179 + const record = post.record as AppBskyFeedPost.Record; 180 + const postId = post.uri.split("/")[4]; 181 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 182 + 183 + return ( 184 + <div className="bskyPost relative flex flex-col w-full"> 185 + <div className={`flex gap-2 text-left w-full ${props.className}`}> 186 + <div className="flex flex-col items-start shrink-0 w-fit"> 187 + <Avatar 188 + src={post.author.avatar} 189 + displayName={post.author.displayName} 190 + size="small" 191 + /> 192 + {replyLine && ( 193 + <button 194 + onClick={(e) => { 195 + replyLine.onToggle(e); 196 + }} 197 + className="relative w-full grow flex" 198 + aria-label="Toggle replies" 199 + > 200 + <div className="w-0.5 h-full bg-border-light mx-auto" /> 201 + </button> 202 + )} 203 + </div> 204 + <div 205 + className={`flex flex-col min-w-0 w-full z-0 ${replyLine ? "mb-2" : ""}`} 206 + > 207 + <button 208 + className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 209 + onClick={() => { 210 + openPage(parent, { type: "thread", uri: post.uri }); 211 + }} 212 + > 213 + <div className="postInfo flex justify-between items-center gap-2 leading-tight"> 214 + <div className="flex gap-2 items-center"> 215 + <div className="font-bold text-secondary"> 216 + {post.author.displayName} 217 + </div> 218 + <ProfilePopover 219 + trigger={ 220 + <div className="text-xs text-tertiary hover:underline"> 221 + @{post.author.handle} 222 + </div> 223 + } 224 + didOrHandle={post.author.handle} 225 + /> 226 + </div> 227 + <div className="text-xs text-tertiary"> 228 + {timeAgo(record.createdAt, { compact: true })} 229 + </div> 230 + </div> 231 + 232 + <div className="postContent flex flex-col gap-2 mt-0.5"> 233 + <div className="line-clamp-3 text-tertiary text-xs"> 234 + <BlueskyRichText record={record} /> 235 + </div> 236 + </div> 237 + </button> 238 + {(post.quoteCount && post.quoteCount > 0) || 239 + (post.replyCount && post.replyCount > 0) ? ( 240 + <div className="postCountsAndLink flex gap-2 items-center justify-between mt-2"> 241 + <PostCounts 242 + post={post} 243 + parent={parent} 244 + replyEnabled={replyEnabled} 245 + replyOnClick={replyOnClick} 246 + quoteEnabled={quoteEnabled} 247 + showBlueskyLink={false} 248 + url={url} 249 + /> 154 250 </div> 155 251 ) : null} 156 252 </div>
+66 -49
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, ··· 30 33 pageOptions?: React.ReactNode; 31 34 hasPageBackground: boolean; 32 35 }) { 33 - const { parentUri: parentUri, pageId, pageOptions } = props; 36 + const { parentUri, pageId, pageOptions } = props; 34 37 const drawer = useDrawerOpen(parentUri); 35 38 36 39 const { ··· 49 52 drawerOpen={!!drawer} 50 53 pageOptions={pageOptions} 51 54 > 52 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 55 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 53 56 {isLoading ? ( 54 57 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 55 58 <span>loading thread</span> ··· 106 109 } 107 110 108 111 return ( 109 - <div className="threadContent flex flex-col gap-0"> 112 + <div 113 + className={`threadContent flex flex-col gap-0 w-full ${parents.length !== 0 && "pt-1"}`} 114 + > 110 115 {/* grandparent posts, if any */} 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 - parentUri={parentUri} 118 - /> 119 - </div> 116 + {parents.map((parentPost, index) => ( 117 + <ThreadPost 118 + key={parentPost.post.uri} 119 + post={parentPost} 120 + isMainPost={false} 121 + pageUri={parentUri} 122 + /> 120 123 ))} 121 124 122 125 {/* Main post */} 123 126 <div ref={mainPostRef}> 124 - <ThreadPost 125 - post={post} 126 - isMainPost={true} 127 - showReplyLine={false} 128 - parentUri={parentUri} 129 - /> 127 + <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 130 128 </div> 131 129 132 130 {/* Replies */} 133 131 {post.replies && post.replies.length > 0 && ( 134 - <div className="threadReplies flex flex-col mt-2 pt-2 border-t border-border-light"> 132 + <div className="threadReplies flex flex-col mt-4 pt-3 border-t border-border-light w-full"> 135 133 <Replies 136 134 replies={post.replies as any[]} 137 - parentUri={post.post.uri} 135 + pageUri={post.post.uri} 136 + parentPostUri={post.post.uri} 138 137 depth={0} 139 138 parentAuthorDid={post.post.author.did} 140 139 /> ··· 147 146 function ThreadPost(props: { 148 147 post: ThreadViewPost; 149 148 isMainPost: boolean; 150 - showReplyLine: boolean; 151 - parentUri: string; 149 + pageUri: string; 152 150 }) { 153 - const { post, isMainPost, showReplyLine, parentUri } = props; 151 + const { post, isMainPost, pageUri } = props; 154 152 const postView = post.post; 155 - const parent = { type: "thread" as const, uri: parentUri }; 153 + const page = { type: "thread" as const, uri: pageUri }; 154 + 155 + if (isMainPost) { 156 + return ( 157 + <div className="threadPost flex gap-2 relative w-full"> 158 + <BskyPostContent 159 + post={postView} 160 + parent={page} 161 + avatarSize="large" 162 + showBlueskyLink={true} 163 + showEmbed={true} 164 + compactEmbed 165 + quoteEnabled 166 + /> 167 + </div> 168 + ); 169 + } 156 170 157 171 return ( 158 - <div className="threadPost flex gap-2 relative"> 159 - {/* Reply line connector */} 160 - {showReplyLine && ( 161 - <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 162 - )} 163 - <BskyPostContent 172 + <div className="threadPost flex gap-2 relative w-full pl-1"> 173 + <CompactBskyPostContent 164 174 post={postView} 165 - parent={parent} 166 - showBlueskyLink={true} 167 - showEmbed={true} 175 + parent={page} 168 176 quoteEnabled 169 - replyEnabled={!isMainPost} 177 + replyEnabled 178 + replyLine={{ 179 + onToggle: () => {}, 180 + }} 170 181 /> 171 182 </div> 172 183 ); ··· 176 187 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 177 188 depth: number; 178 189 parentAuthorDid?: string; 179 - parentUri: string; 190 + pageUri: string; 191 + parentPostUri: string; 180 192 }) { 181 - const { replies, depth, parentAuthorDid, parentUri } = props; 193 + const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props; 182 194 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 183 195 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 184 196 ··· 198 210 : replies; 199 211 200 212 return ( 201 - <div className="threadPageReplies flex flex-col gap-0"> 213 + <div className="threadPageReplies flex flex-col gap-0 pt-1 pb-2"> 202 214 {sortedReplies.map((reply, index) => { 203 215 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 204 216 return ( ··· 231 243 232 244 return ( 233 245 <ReplyPost 246 + key={reply.post.uri} 234 247 post={reply} 235 248 isLast={index === replies.length - 1 && !hasReplies} 236 - parentUri={parentUri} 249 + pageUri={pageUri} 250 + parentPostUri={parentPostUri} 237 251 toggleCollapsed={(uri) => toggleCollapsed(uri)} 238 252 isCollapsed={isCollapsed} 239 253 depth={props.depth} 240 254 /> 241 255 ); 242 256 })} 243 - {parentUri && depth > 0 && replies.length > 3 && ( 257 + {pageUri && depth > 0 && replies.length > 3 && ( 244 258 <ThreadLink 245 - postUri={parentUri} 246 - parent={{ type: "thread", uri: parentUri }} 259 + postUri={pageUri} 260 + parent={{ type: "thread", uri: pageUri }} 247 261 className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 248 262 > 249 263 <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> ··· 258 272 const ReplyPost = (props: { 259 273 post: ThreadViewPost; 260 274 isLast: boolean; 261 - parentUri: string; 275 + pageUri: string; 276 + parentPostUri: string; 262 277 toggleCollapsed: (uri: string) => void; 263 278 isCollapsed: boolean; 264 279 depth: number; 265 280 }) => { 266 - const { post, parentUri } = props; 281 + const { post, pageUri, parentPostUri } = props; 267 282 const postView = post.post; 268 283 269 284 const hasReplies = props.post.replies && props.post.replies.length > 0; ··· 274 289 > 275 290 <BskyPostContent 276 291 post={postView} 277 - parent={{ type: "thread", uri: parentUri }} 292 + parent={{ type: "thread", uri: pageUri }} 278 293 showEmbed={false} 279 294 showBlueskyLink={false} 280 295 replyLine={ 281 296 props.depth > 0 282 297 ? { 283 298 onToggle: () => { 284 - props.toggleCollapsed(props.parentUri); 299 + props.toggleCollapsed(parentPostUri); 285 300 }, 286 301 } 287 302 : undefined ··· 291 306 replyOnClick={(e) => { 292 307 e.preventDefault(); 293 308 props.toggleCollapsed(post.post.uri); 309 + console.log(post.post.uri); 294 310 }} 295 311 onEmbedClick={(e) => e.stopPropagation()} 296 312 className="text-sm z-10" ··· 300 316 {!props.isCollapsed && ( 301 317 <div className="grow"> 302 318 <Replies 303 - parentUri={parentUri} 319 + pageUri={pageUri} 320 + parentPostUri={post.post.uri} 304 321 replies={props.post.replies as any[]} 305 322 depth={props.depth + 1} 306 323 parentAuthorDid={props.post.post.author.did} ··· 313 330 {hasReplies && props.depth >= 3 && ( 314 331 <ThreadLink 315 332 postUri={props.post.post.uri} 316 - parent={{ type: "thread", uri: parentUri }} 333 + parent={{ type: "thread", uri: pageUri }} 317 334 className="text-left ml-10 text-sm text-accent-contrast hover:underline" 318 335 > 319 336 View more replies
+19 -13
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 15 15 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 16 16 postUrl?: string; 17 17 className?: string; 18 + compact?: boolean; 18 19 }) => { 19 20 // check this file from bluesky for ref 20 21 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx ··· 22 23 case AppBskyEmbedImages.isView(props.embed): 23 24 let imageEmbed = props.embed; 24 25 return ( 25 - <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 26 + <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 26 27 {imageEmbed.images.map( 27 28 ( 28 29 image: { ··· 69 70 let isGif = externalEmbed.external.uri.includes(".gif"); 70 71 if (isGif) { 71 72 return ( 72 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 73 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full "> 73 74 <img 74 75 src={externalEmbed.external.uri} 75 76 alt={externalEmbed.external.title} ··· 82 83 <a 83 84 href={externalEmbed.external.uri} 84 85 target="_blank" 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}`} 86 + 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" : "flex flex-col"} 87 + ${props.className}`} 86 88 > 87 89 {externalEmbed.external.thumb === undefined ? null : ( 88 90 <> 89 - <div className="w-full aspect-[1.91/1] overflow-hidden"> 91 + <div 92 + className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`} 93 + > 90 94 <img 91 95 src={externalEmbed.external.thumb} 92 96 alt={externalEmbed.external.title} 93 - className="w-full h-full object-cover" 97 + className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`} 94 98 /> 95 99 </div> 96 - <hr className="border-border-light" /> 100 + {!props.compact && <hr className="border-border-light" />} 97 101 </> 98 102 )} 99 - <div className="p-2 flex flex-col gap-1"> 100 - <div className="flex flex-col"> 101 - <h4>{externalEmbed.external.title}</h4> 102 - <p className="text-secondary"> 103 + <div 104 + className={`p-2 flex flex-col gap-1 w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`} 105 + > 106 + <div className="flex flex-col shrink-0"> 107 + <h4 className="truncate">{externalEmbed.external.title} </h4> 108 + <p className="text-secondary line-clamp-2 grow"> 103 109 {externalEmbed.external.description} 104 110 </p> 105 111 </div> 106 112 <hr className="border-border-light mt-1" /> 107 - <div className="text-tertiary text-xs sm:group-hover:text-accent-contrast"> 113 + <div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate"> 108 114 {externalEmbed.external.uri} 109 115 </div> 110 116 </div> ··· 117 123 : 16 / 9; 118 124 return ( 119 125 <div 120 - className={`rounded-md overflow-hidden relative w-full ${props.className}`} 126 + className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 121 127 style={{ aspectRatio: String(videoAspectRatio) }} 122 128 > 123 129 <img ··· 149 155 } 150 156 return ( 151 157 <div 152 - className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 158 + className={`bskyPostEmbed flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 153 159 > 154 160 <div className="bskyAuthor w-full flex items-center "> 155 161 {record.author.avatar && (