a tool for shared writing and social publishing
at update/reader 307 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 { InteractionPreview, TagPopover } from "./InteractionsPreview"; 17import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 18import { useSmoker } from "./Toast"; 19import { Separator } from "./Layout"; 20import { CommentTiny } from "./Icons/CommentTiny"; 21import { QuoteTiny } from "./Icons/QuoteTiny"; 22import { ShareTiny } from "./Icons/ShareTiny"; 23import { useSelectedPostListing } from "src/useSelectedPostState"; 24import { mergePreferences } from "src/utils/mergePreferences"; 25import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 26import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 27import { RecommendButton } from "./RecommendButton"; 28 29export const PostListing = (props: Post) => { 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 border-border-light rounded-lg w-full hover:outline-accent-contrast 104 hover:border-accent-contrast 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 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> 137 {postRecord.title} 138 </h3> 139 140 <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> 141 {postRecord.description} 142 </p> 143 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full"> 144 {props.publication && pubRecord && ( 145 <PubInfo 146 href={props.publication.href} 147 pubRecord={pubRecord} 148 uri={props.publication.uri} 149 postRecord={postRecord} 150 /> 151 )} 152 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> 153 <PostDate publishedAt={postRecord.publishedAt} /> 154 {tags.length === 0 ? null : <TagPopover tags={tags!} />} 155 </div> 156 </div> 157 </div> 158 </div> 159 </BaseThemeProvider> 160 <div className="text-sm flex justify-between text-tertiary"> 161 <Interactions 162 postUrl={postUrl} 163 quotesCount={quotes} 164 commentsCount={comments} 165 recommendsCount={recommends} 166 tags={tags} 167 showComments={mergedPrefs.showComments !== false} 168 showMentions={mergedPrefs.showMentions !== false} 169 documentUri={props.documents.uri} 170 document={postRecord} 171 /> 172 <Share postUrl={postUrl} /> 173 </div> 174 </div> 175 ); 176}; 177 178const PubInfo = (props: { 179 href: string; 180 pubRecord: NormalizedPublication; 181 uri: string; 182 postRecord: NormalizedDocument; 183}) => { 184 let isLeaflet = hasLeafletContent(props.postRecord); 185 let cleanUrl = props.pubRecord.url 186 ?.replace(/^https?:\/\//, "") 187 .replace(/^www\./, ""); 188 189 return ( 190 <div className="flex flex-col shrink-0 w-full"> 191 <hr className=" block border-border-light mb-1" /> 192 <div className="flex justify-between gap-4 w-full "> 193 <Link 194 href={props.href} 195 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" 196 > 197 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 198 <div className="w-max min-w-0">{props.pubRecord.name}</div> 199 </Link> 200 {!isLeaflet && ( 201 <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0"> 202 <div className="truncate min-w-0">{cleanUrl}</div> 203 <ExternalLinkTiny className="shrink-0" /> 204 </div> 205 )} 206 </div> 207 </div> 208 ); 209}; 210 211const PostDate = (props: { publishedAt: string | undefined }) => { 212 let localizedDate = useLocalizedDate(props.publishedAt || "", { 213 year: "numeric", 214 month: "short", 215 day: "numeric", 216 }); 217 if (props.publishedAt) { 218 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; 219 } else return null; 220}; 221 222const Interactions = (props: { 223 quotesCount: number; 224 commentsCount: number; 225 recommendsCount: number; 226 tags?: string[]; 227 postUrl: string; 228 showComments: boolean; 229 showMentions: boolean; 230 documentUri: string; 231 document: NormalizedDocument; 232}) => { 233 let setSelectedPostListing = useSelectedPostListing( 234 (s) => s.setSelectedPostListing, 235 ); 236 let selectPostListing = (drawer: "quotes" | "comments") => { 237 setSelectedPostListing({ 238 document_uri: props.documentUri, 239 document: props.document, 240 drawer, 241 }); 242 }; 243 244 return ( 245 <div 246 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 247 > 248 <div className="postListingsInteractions flex gap-3"> 249 <RecommendButton 250 documentUri={props.documentUri} 251 recommendsCount={props.recommendsCount} 252 /> 253 {!props.showMentions || props.quotesCount === 0 ? null : ( 254 <button 255 aria-label="Post quotes" 256 onClick={() => selectPostListing("quotes")} 257 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 258 > 259 <QuoteTiny /> {props.quotesCount} 260 </button> 261 )} 262 {!props.showComments || props.commentsCount === 0 ? null : ( 263 <button 264 aria-label="Post comments" 265 onClick={() => selectPostListing("comments")} 266 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 267 > 268 <CommentTiny /> {props.commentsCount} 269 </button> 270 )} 271 </div> 272 </div> 273 ); 274}; 275 276const Share = (props: { postUrl: string }) => { 277 let smoker = useSmoker(); 278 return ( 279 <button 280 id={`copy-post-link-${props.postUrl}`} 281 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" 282 onClick={(e) => { 283 e.stopPropagation(); 284 e.preventDefault(); 285 let mouseX = e.clientX; 286 let mouseY = e.clientY; 287 288 if (!props.postUrl) return; 289 navigator.clipboard.writeText( 290 props.postUrl.includes("http") 291 ? props.postUrl 292 : `leaflet.pub/${props.postUrl}`, 293 ); 294 295 smoker({ 296 text: <strong>Copied Link!</strong>, 297 position: { 298 y: mouseY, 299 x: mouseX, 300 }, 301 }); 302 }} 303 > 304 Share <ShareTiny /> 305 </button> 306 ); 307};