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