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