a tool for shared writing and social publishing
1"use client"; 2import { AtUri } from "@atproto/api"; 3import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4import { PubIcon } from "components/ActionBar/Publications"; 5import { ButtonPrimary } from "components/Buttons"; 6import { CommentTiny } from "components/Icons/CommentTiny"; 7import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8import { QuoteTiny } from "components/Icons/QuoteTiny"; 9import { Separator } from "components/Layout"; 10import { SpeedyLink } from "components/SpeedyLink"; 11import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13import { useSmoker } from "components/Toast"; 14import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16import { Json } from "supabase/database.types"; 17import type { Cursor, Post } from "./getReaderFeed"; 18import useSWRInfinite from "swr/infinite"; 19import { getReaderFeed } from "./getReaderFeed"; 20import { useEffect, useRef } from "react"; 21import { useRouter } from "next/navigation"; 22 23export const ReaderContent = (props: { 24 root_entity: string; 25 posts: Post[]; 26 nextCursor: Cursor | null; 27}) => { 28 const getKey = ( 29 pageIndex: number, 30 previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 31 ) => { 32 // Reached the end 33 if (previousPageData && !previousPageData.nextCursor) return null; 34 35 // First page, we don't have previousPageData 36 if (pageIndex === 0) return ["reader-feed", null] as const; 37 38 // Add the cursor to the key 39 return ["reader-feed", previousPageData?.nextCursor] as const; 40 }; 41 42 const { data, error, size, setSize, isValidating } = useSWRInfinite( 43 getKey, 44 ([_, cursor]) => getReaderFeed(cursor), 45 { 46 fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 47 revalidateFirstPage: false, 48 }, 49 ); 50 51 const loadMoreRef = useRef<HTMLDivElement>(null); 52 53 // Set up intersection observer to load more when trigger element is visible 54 useEffect(() => { 55 const observer = new IntersectionObserver( 56 (entries) => { 57 if (entries[0].isIntersecting && !isValidating) { 58 const hasMore = data && data[data.length - 1]?.nextCursor; 59 if (hasMore) { 60 setSize(size + 1); 61 } 62 } 63 }, 64 { threshold: 0.1 }, 65 ); 66 67 if (loadMoreRef.current) { 68 observer.observe(loadMoreRef.current); 69 } 70 71 return () => observer.disconnect(); 72 }, [data, size, setSize, isValidating]); 73 74 const allPosts = data ? data.flatMap((page) => page.posts) : []; 75 76 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 77 78 return ( 79 <div className="flex flex-col gap-3 relative"> 80 {allPosts.map((p) => ( 81 <Post {...p} key={p.documents.uri} /> 82 ))} 83 {/* Trigger element for loading more posts */} 84 <div 85 ref={loadMoreRef} 86 className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 87 aria-hidden="true" 88 /> 89 {isValidating && ( 90 <div className="text-center text-tertiary py-4"> 91 Loading more posts... 92 </div> 93 )} 94 </div> 95 ); 96}; 97 98const Post = (props: Post) => { 99 let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 100 101 let postRecord = props.documents.data as PubLeafletDocument.Record; 102 let postUri = new AtUri(props.documents.uri); 103 104 let theme = usePubTheme(pubRecord); 105 let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 106 ? blobRefToSrc( 107 pubRecord?.theme?.backgroundImage?.image?.ref, 108 new AtUri(props.publication.uri).host, 109 ) 110 : null; 111 112 let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 113 let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 114 115 let showPageBackground = pubRecord.theme?.showPageBackground; 116 117 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 118 let comments = 119 pubRecord.preferences?.showComments === false 120 ? 0 121 : props.documents.comments_on_documents?.[0]?.count || 0; 122 123 return ( 124 <BaseThemeProvider {...theme} local> 125 <div 126 style={{ 127 backgroundImage: `url(${backgroundImage})`, 128 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 129 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 130 }} 131 className={`no-underline! flex flex-row gap-2 w-full relative 132 bg-bg-leaflet 133 border border-border-light rounded-lg 134 sm:p-2 p-2 selected-outline 135 hover:outline-accent-contrast hover:border-accent-contrast 136 `} 137 > 138 <a 139 className="h-full w-full absolute top-0 left-0" 140 href={`${props.publication.href}/${postUri.rkey}`} 141 /> 142 <div 143 className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 144 style={{ 145 backgroundColor: showPageBackground 146 ? "rgba(var(--bg-page), var(--bg-page-alpha))" 147 : "transparent", 148 }} 149 > 150 <h3 className="text-primary truncate">{postRecord.title}</h3> 151 152 <p className="text-secondary">{postRecord.description}</p> 153 <div className="flex justify-between items-end"> 154 <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-center justify-start pt-1 md:pt-3"> 155 <PubInfo 156 href={props.publication.href} 157 pubRecord={pubRecord} 158 uri={props.publication.uri} 159 /> 160 <Separator classname="h-4 !min-h-0 md:block hidden" /> 161 <PostInfo 162 author={props.author || ""} 163 publishedAt={postRecord.publishedAt} 164 /> 165 </div> 166 167 <PostInterations 168 postUrl={`${props.publication.href}/${postUri.rkey}`} 169 quotesCount={quotes} 170 commentsCount={comments} 171 showComments={pubRecord.preferences?.showComments} 172 /> 173 </div> 174 </div> 175 </div> 176 </BaseThemeProvider> 177 ); 178}; 179 180const PubInfo = (props: { 181 href: string; 182 pubRecord: PubLeafletPublication.Record; 183 uri: string; 184}) => { 185 return ( 186 <a 187 href={props.href} 188 className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 189 > 190 <PubIcon small record={props.pubRecord} uri={props.uri} /> 191 {props.pubRecord.name} 192 </a> 193 ); 194}; 195 196const PostInfo = (props: { 197 author: string; 198 publishedAt: string | undefined; 199}) => { 200 return ( 201 <div className="flex gap-2 grow items-center shrink-0"> 202 {props.author} 203 {props.publishedAt && ( 204 <> 205 <Separator classname="h-4 !min-h-0" /> 206 {new Date(props.publishedAt).toLocaleDateString("en-US", { 207 year: "numeric", 208 month: "short", 209 day: "numeric", 210 })}{" "} 211 </> 212 )} 213 </div> 214 ); 215}; 216 217const PostInterations = (props: { 218 quotesCount: number; 219 commentsCount: number; 220 postUrl: string; 221 showComments: boolean | undefined; 222}) => { 223 let smoker = useSmoker(); 224 let interactionsAvailable = 225 props.quotesCount > 0 || 226 (props.showComments !== false && props.commentsCount > 0); 227 228 return ( 229 <div className={`flex gap-2 text-tertiary text-sm items-center`}> 230 {props.quotesCount === 0 ? null : ( 231 <div className={`flex gap-1 items-center `}> 232 <span className="sr-only">Post quotes</span> 233 <QuoteTiny aria-hidden /> {props.quotesCount} 234 </div> 235 )} 236 {props.showComments === false || props.commentsCount === 0 ? null : ( 237 <div className={`flex gap-1 items-center`}> 238 <span className="sr-only">Post comments</span> 239 <CommentTiny aria-hidden /> {props.commentsCount} 240 </div> 241 )} 242 {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 243 <button 244 id={`copy-post-link-${props.postUrl}`} 245 className="flex gap-1 items-center hover:font-bold relative" 246 onClick={(e) => { 247 e.stopPropagation(); 248 e.preventDefault(); 249 let mouseX = e.clientX; 250 let mouseY = e.clientY; 251 252 if (!props.postUrl) return; 253 navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 254 255 smoker({ 256 text: <strong>Copied Link!</strong>, 257 position: { 258 y: mouseY, 259 x: mouseX, 260 }, 261 }); 262 }} 263 > 264 Share 265 </button> 266 </div> 267 ); 268}; 269const ReaderEmpty = () => { 270 return ( 271 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary"> 272 Nothing to read yet <br /> 273 Subscribe to publications and find their posts here! 274 <ButtonPrimary className="mx-auto place-self-center"> 275 <DiscoverSmall /> Discover Publications 276 </ButtonPrimary> 277 </div> 278 ); 279};