a tool for shared writing and social publishing
at main 312 lines 11 kB view raw
1"use client"; 2import { AtUri } from "@atproto/api"; 3import { PubIcon } from "components/ActionBar/Publications"; 4import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 5import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 6import { blobRefToSrc } from "src/utils/blobRefToSrc"; 7import type { 8 NormalizedDocument, 9 NormalizedPublication, 10} from "src/utils/normalizeRecords"; 11import { hasLeafletContent } from "lexicons/src/normalize"; 12import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 14import Link from "next/link"; 15import { useEffect, useRef, useState } from "react"; 16import { TagPopover } from "./InteractionsPreview"; 17import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 18import { useSmoker } from "./Toast"; 19import { CommentTiny } from "./Icons/CommentTiny"; 20import { QuoteTiny } from "./Icons/QuoteTiny"; 21import { ShareTiny } from "./Icons/ShareTiny"; 22import { useSelectedPostListing } from "src/useSelectedPostState"; 23import { mergePreferences } from "src/utils/mergePreferences"; 24import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 25import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 26import { RecommendButton } from "./RecommendButton"; 27import { getFirstParagraph } from "src/utils/getFirstParagraph"; 28 29export const PostListing = (props: Post & { selected?: boolean }) => { 30 let pubRecord = props.publication?.pubRecord as 31 | NormalizedPublication 32 | undefined; 33 34 let postRecord = props.documents.data as NormalizedDocument | null; 35 36 // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) 37 if (!postRecord) { 38 return null; 39 } 40 let postUri = new AtUri(props.documents.uri); 41 let uri = props.publication ? props.publication?.uri : props.documents.uri; 42 43 // For standalone documents (no publication), pass isStandalone to get correct defaults 44 let isStandalone = !pubRecord; 45 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 46 let themeRecord = pubRecord?.theme || postRecord?.theme; 47 let elRef = useRef<HTMLDivElement>(null); 48 let [hasBackgroundImage, setHasBackgroundImage] = useState(false); 49 50 useEffect(() => { 51 if (!themeRecord?.backgroundImage?.image || !elRef.current) { 52 setHasBackgroundImage(false); 53 return; 54 } 55 let alpha = Number( 56 window 57 .getComputedStyle(elRef.current) 58 .getPropertyValue("--bg-page-alpha"), 59 ); 60 setHasBackgroundImage(alpha < 0.7); 61 }, [themeRecord?.backgroundImage?.image]); 62 63 let backgroundImage = 64 themeRecord?.backgroundImage?.image?.ref && uri 65 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) 66 : null; 67 68 let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; 69 let backgroundImageSize = themeRecord?.backgroundImage?.width || 500; 70 71 let showPageBackground = pubRecord 72 ? pubRecord?.theme?.showPageBackground 73 : postRecord.theme?.showPageBackground ?? true; 74 75 let mergedPrefs = mergePreferences( 76 postRecord?.preferences, 77 pubRecord?.preferences, 78 ); 79 80 let quotes = 81 props.documents.mentionsCount ?? 82 props.documents.document_mentions_in_bsky?.[0]?.count ?? 83 0; 84 let comments = 85 mergedPrefs.showComments === false 86 ? 0 87 : props.documents.comments_on_documents?.[0]?.count || 0; 88 let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 89 let tags = (postRecord?.tags as string[] | undefined) || []; 90 91 // For standalone posts, link directly to the document 92 let postUrl = getDocumentURL(postRecord, props.documents.uri, pubRecord); 93 94 return ( 95 <div className="postListing flex flex-col gap-1"> 96 <BaseThemeProvider {...theme} local> 97 <div 98 ref={elRef} 99 id={`post-listing-${postUri}`} 100 className={` 101 relative 102 flex flex-col overflow-hidden 103 selected-outline rounded-lg w-full 104 ${props.selected ? "outline-2 outline-offset-1 outline-accent-contrast border-accent-contrast" : "hover:outline-accent-contrast hover:border-accent-contrast border-border-light"} 105 ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} 106 style={ 107 hasBackgroundImage 108 ? { 109 backgroundImage: backgroundImage 110 ? `url(${backgroundImage})` 111 : undefined, 112 backgroundRepeat: backgroundImageRepeat 113 ? "repeat" 114 : "no-repeat", 115 backgroundSize: backgroundImageRepeat 116 ? `${backgroundImageSize}px` 117 : "cover", 118 } 119 : {} 120 } 121 > 122 <Link 123 className="h-full w-full absolute top-0 left-0" 124 href={postUrl} 125 /> 126 {postRecord.coverImage && ( 127 <div className="postListingImage"> 128 <img 129 src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} 130 alt={postRecord.title || ""} 131 className="w-full h-auto aspect-video object-cover object-top-left rounded" 132 /> 133 </div> 134 )} 135 <div className="postListingInfo px-3 py-2"> 136 {postRecord.title && ( 137 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base pb-0.5"> 138 {postRecord.title} 139 </h3> 140 )} 141 142 <p className="postListingDescription text-secondary line-clamp-3 leading-snug sm:text-base text-sm"> 143 {postRecord.description || getFirstParagraph(postRecord)} 144 </p> 145 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full"> 146 {props.publication && pubRecord && ( 147 <PubInfo 148 href={props.publication.href} 149 pubRecord={pubRecord} 150 uri={props.publication.uri} 151 postRecord={postRecord} 152 /> 153 )} 154 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> 155 <PostDate publishedAt={postRecord.publishedAt} /> 156 {tags.length === 0 ? null : <TagPopover tags={tags!} />} 157 </div> 158 </div> 159 </div> 160 </div> 161 </BaseThemeProvider> 162 <div className="text-sm flex justify-between text-tertiary"> 163 <Interactions 164 postUrl={postUrl} 165 quotesCount={quotes} 166 commentsCount={comments} 167 recommendsCount={recommends} 168 tags={tags} 169 showComments={mergedPrefs.showComments !== false} 170 showMentions={mergedPrefs.showMentions !== false} 171 documentUri={props.documents.uri} 172 document={postRecord} 173 publication={pubRecord} 174 /> 175 <Share postUrl={postUrl} /> 176 </div> 177 </div> 178 ); 179}; 180 181const PubInfo = (props: { 182 href: string; 183 pubRecord: NormalizedPublication; 184 uri: string; 185 postRecord: NormalizedDocument; 186}) => { 187 let isLeaflet = hasLeafletContent(props.postRecord); 188 let cleanUrl = props.pubRecord.url 189 ?.replace(/^https?:\/\//, "") 190 .replace(/^www\./, ""); 191 192 return ( 193 <div className="flex flex-col shrink-0 w-full"> 194 <hr className=" block border-border-light mb-1" /> 195 <div className="flex justify-between gap-4 w-full "> 196 <Link 197 href={props.href} 198 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" 199 > 200 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 201 <div className="w-max min-w-0">{props.pubRecord.name}</div> 202 </Link> 203 {!isLeaflet && ( 204 <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0"> 205 <div className="truncate min-w-0">{cleanUrl}</div> 206 <ExternalLinkTiny className="shrink-0" /> 207 </div> 208 )} 209 </div> 210 </div> 211 ); 212}; 213 214const PostDate = (props: { publishedAt: string | undefined }) => { 215 let localizedDate = useLocalizedDate(props.publishedAt || "", { 216 year: "numeric", 217 month: "short", 218 day: "numeric", 219 }); 220 if (props.publishedAt) { 221 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; 222 } else return null; 223}; 224 225const Interactions = (props: { 226 quotesCount: number; 227 commentsCount: number; 228 recommendsCount: number; 229 tags?: string[]; 230 postUrl: string; 231 showComments: boolean; 232 showMentions: boolean; 233 documentUri: string; 234 document: NormalizedDocument; 235 publication?: NormalizedPublication; 236}) => { 237 let setSelectedPostListing = useSelectedPostListing( 238 (s) => s.setSelectedPostListing, 239 ); 240 let selectPostListing = (drawer: "quotes" | "comments") => { 241 setSelectedPostListing({ 242 document_uri: props.documentUri, 243 document: props.document, 244 publication: props.publication, 245 drawer, 246 }); 247 }; 248 249 return ( 250 <div 251 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 252 > 253 <div className="postListingsInteractions flex gap-3"> 254 <RecommendButton 255 documentUri={props.documentUri} 256 recommendsCount={props.recommendsCount} 257 /> 258 {!props.showMentions || props.quotesCount === 0 ? null : ( 259 <button 260 aria-label="Post quotes" 261 onClick={() => selectPostListing("quotes")} 262 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 263 > 264 <QuoteTiny /> {props.quotesCount} 265 </button> 266 )} 267 {!props.showComments || props.commentsCount === 0 ? null : ( 268 <button 269 aria-label="Post comments" 270 onClick={() => selectPostListing("comments")} 271 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 272 > 273 <CommentTiny /> {props.commentsCount} 274 </button> 275 )} 276 </div> 277 </div> 278 ); 279}; 280 281const Share = (props: { postUrl: string }) => { 282 let smoker = useSmoker(); 283 return ( 284 <button 285 id={`copy-post-link-${props.postUrl}`} 286 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" 287 onClick={(e) => { 288 e.stopPropagation(); 289 e.preventDefault(); 290 let mouseX = e.clientX; 291 let mouseY = e.clientY; 292 293 if (!props.postUrl) return; 294 navigator.clipboard.writeText( 295 props.postUrl.includes("http") 296 ? props.postUrl 297 : `leaflet.pub/${props.postUrl}`, 298 ); 299 300 smoker({ 301 text: <strong>Copied Link!</strong>, 302 position: { 303 y: mouseY, 304 x: mouseX, 305 }, 306 }); 307 }} 308 > 309 Share <ShareTiny /> 310 </button> 311 ); 312};