a tool for shared writing and social publishing
at update/reader 267 lines 9.1 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 type { Post } from "app/(home-pages)/reader/getReaderFeed"; 12 13import Link from "next/link"; 14import { InteractionPreview, TagPopover } from "./InteractionsPreview"; 15import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16import { useSmoker } from "./Toast"; 17import { Separator } from "./Layout"; 18import { CommentTiny } from "./Icons/CommentTiny"; 19import { QuoteTiny } from "./Icons/QuoteTiny"; 20import { ShareTiny } from "./Icons/ShareTiny"; 21import { useSelectedPostListing } from "src/useSelectedPostState"; 22import { mergePreferences } from "src/utils/mergePreferences"; 23 24export const PostListing = (props: Post) => { 25 let pubRecord = props.publication?.pubRecord as 26 | NormalizedPublication 27 | undefined; 28 29 let postRecord = props.documents.data as NormalizedDocument | null; 30 31 // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) 32 if (!postRecord) { 33 return null; 34 } 35 let postUri = new AtUri(props.documents.uri); 36 let uri = props.publication ? props.publication?.uri : props.documents.uri; 37 38 // For standalone documents (no publication), pass isStandalone to get correct defaults 39 let isStandalone = !pubRecord; 40 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 41 let themeRecord = pubRecord?.theme || postRecord?.theme; 42 let el = document?.getElementById(`post-listing-${postUri}`); 43 44 let hasBackgroundImage = 45 !!themeRecord?.backgroundImage?.image && 46 el && 47 Number(window.getComputedStyle(el).getPropertyValue("--bg-page-alpha")) < 48 0.7; 49 50 let backgroundImage = 51 themeRecord?.backgroundImage?.image?.ref && uri 52 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) 53 : null; 54 55 let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; 56 let backgroundImageSize = themeRecord?.backgroundImage?.width || 500; 57 58 let showPageBackground = pubRecord 59 ? pubRecord?.theme?.showPageBackground 60 : postRecord.theme?.showPageBackground ?? true; 61 62 let mergedPrefs = mergePreferences( 63 postRecord?.preferences, 64 pubRecord?.preferences, 65 ); 66 67 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 68 let comments = 69 mergedPrefs.showComments === false 70 ? 0 71 : props.documents.comments_on_documents?.[0]?.count || 0; 72 let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 73 let tags = (postRecord?.tags as string[] | undefined) || []; 74 75 // For standalone posts, link directly to the document 76 let postUrl = props.publication 77 ? `${props.publication.href}/${postUri.rkey}` 78 : `/p/${postUri.host}/${postUri.rkey}`; 79 80 return ( 81 <div className="postListing flex flex-col gap-1"> 82 <BaseThemeProvider {...theme} local> 83 <div 84 id={`post-listing-${postUri}`} 85 className={` 86 relative 87 flex flex-col overflow-hidden 88 selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast 89 hover:border-accent-contrast 90 ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} 91 style={ 92 hasBackgroundImage 93 ? { 94 backgroundImage: backgroundImage 95 ? `url(${backgroundImage})` 96 : undefined, 97 backgroundRepeat: backgroundImageRepeat 98 ? "repeat" 99 : "no-repeat", 100 backgroundSize: backgroundImageRepeat 101 ? `${backgroundImageSize}px` 102 : "cover", 103 } 104 : {} 105 } 106 > 107 <Link 108 className="h-full w-full absolute top-0 left-0" 109 href={postUrl} 110 /> 111 {postRecord.coverImage && ( 112 <div className="postListingImage"> 113 <img 114 src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} 115 alt={postRecord.title || ""} 116 className="w-full h-auto aspect-video rounded" 117 /> 118 </div> 119 )} 120 <div className="postListingInfo px-3 py-2"> 121 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> 122 {postRecord.title} 123 </h3> 124 125 <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> 126 {postRecord.description} 127 </p> 128 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 129 {props.publication && pubRecord && ( 130 <PubInfo 131 href={props.publication.href} 132 pubRecord={pubRecord} 133 uri={props.publication.uri} 134 /> 135 )} 136 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> 137 <PostDate publishedAt={postRecord.publishedAt} /> 138 {tags.length === 0 ? null : <TagPopover tags={tags!} />} 139 </div> 140 </div> 141 </div> 142 </div> 143 </BaseThemeProvider> 144 <div className="text-sm flex justify-between text-tertiary"> 145 <Interactions 146 postUrl={postUrl} 147 quotesCount={quotes} 148 commentsCount={comments} 149 tags={tags} 150 showComments={mergedPrefs.showComments !== false} 151 showMentions={mergedPrefs.showMentions !== false} 152 documentUri={props.documents.uri} 153 document={postRecord} 154 /> 155 <Share postUrl={postUrl} /> 156 </div> 157 </div> 158 ); 159}; 160 161const PubInfo = (props: { 162 href: string; 163 pubRecord: NormalizedPublication; 164 uri: string; 165}) => { 166 return ( 167 <div className="flex flex-col md:w-auto shrink-0 w-full"> 168 <hr className="md:hidden block border-border-light mb-1" /> 169 <Link 170 href={props.href} 171 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center md:w-fit relative shrink-0" 172 > 173 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 174 {props.pubRecord.name} 175 </Link> 176 </div> 177 ); 178}; 179 180const PostDate = (props: { publishedAt: string | undefined }) => { 181 let localizedDate = useLocalizedDate(props.publishedAt || "", { 182 year: "numeric", 183 month: "short", 184 day: "numeric", 185 }); 186 if (props.publishedAt) { 187 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; 188 } else return null; 189}; 190 191const Interactions = (props: { 192 quotesCount: number; 193 commentsCount: number; 194 tags?: string[]; 195 postUrl: string; 196 showComments: boolean; 197 showMentions: boolean; 198 documentUri: string; 199 document: NormalizedDocument; 200}) => { 201 let setSelectedPostListing = useSelectedPostListing( 202 (s) => s.setSelectedPostListing, 203 ); 204 let selectPostListing = (drawer: "quotes" | "comments") => { 205 setSelectedPostListing({ 206 document_uri: props.documentUri, 207 document: props.document, 208 drawer, 209 }); 210 }; 211 212 return ( 213 <div 214 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 215 > 216 <div className="postListingsInteractions flex gap-3"> 217 {!props.showMentions || props.quotesCount === 0 ? null : ( 218 <button 219 aria-label="Post quotes" 220 onClick={() => selectPostListing("quotes")} 221 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 222 > 223 <QuoteTiny /> {props.quotesCount} 224 </button> 225 )} 226 {!props.showComments || props.commentsCount === 0 ? null : ( 227 <button 228 aria-label="Post comments" 229 onClick={() => selectPostListing("comments")} 230 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 231 > 232 <CommentTiny /> {props.commentsCount} 233 </button> 234 )} 235 </div> 236 </div> 237 ); 238}; 239 240const Share = (props: { postUrl: string }) => { 241 let smoker = useSmoker(); 242 return ( 243 <button 244 id={`copy-post-link-${props.postUrl}`} 245 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" 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 <ShareTiny /> 265 </button> 266 ); 267};