Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
11 kB · 307 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308"use client";import { AtUri } from "@atproto/api";import { PubIcon } from "components/ActionBar/Publications";import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";import { blobRefToSrc } from "src/utils/blobRefToSrc";import type { NormalizedDocument, NormalizedPublication,} from "src/utils/normalizeRecords";import { hasLeafletContent } from "lexicons/src/normalize";import type { Post } from "app/(home-pages)/reader/getReaderFeed";
import Link from "next/link";import { useEffect, useRef, useState } from "react";import { InteractionPreview, TagPopover } from "./InteractionsPreview";import { useLocalizedDate } from "src/hooks/useLocalizedDate";import { useSmoker } from "./Toast";import { Separator } from "./Layout";import { CommentTiny } from "./Icons/CommentTiny";import { QuoteTiny } from "./Icons/QuoteTiny";import { ShareTiny } from "./Icons/ShareTiny";import { useSelectedPostListing } from "src/useSelectedPostState";import { mergePreferences } from "src/utils/mergePreferences";import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";import { getDocumentURL } from "app/lish/createPub/getPublicationURL";import { RecommendButton } from "./RecommendButton";
export const PostListing = (props: Post) => { let pubRecord = props.publication?.pubRecord as | NormalizedPublication | undefined;
let postRecord = props.documents.data as NormalizedDocument | null;
// Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) if (!postRecord) { return null; } let postUri = new AtUri(props.documents.uri); let uri = props.publication ? props.publication?.uri : props.documents.uri;
// For standalone documents (no publication), pass isStandalone to get correct defaults let isStandalone = !pubRecord; let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); let themeRecord = pubRecord?.theme || postRecord?.theme; let elRef = useRef<HTMLDivElement>(null); let [hasBackgroundImage, setHasBackgroundImage] = useState(false);
useEffect(() => { if (!themeRecord?.backgroundImage?.image || !elRef.current) { setHasBackgroundImage(false); return; } let alpha = Number( window .getComputedStyle(elRef.current) .getPropertyValue("--bg-page-alpha"), ); setHasBackgroundImage(alpha < 0.7); }, [themeRecord?.backgroundImage?.image]);
let backgroundImage = themeRecord?.backgroundImage?.image?.ref && uri ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) : null;
let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
let showPageBackground = pubRecord ? pubRecord?.theme?.showPageBackground : postRecord.theme?.showPageBackground ?? true;
let mergedPrefs = mergePreferences( postRecord?.preferences, pubRecord?.preferences, );
let quotes = props.documents.mentionsCount ?? props.documents.document_mentions_in_bsky?.[0]?.count ?? 0; let comments = mergedPrefs.showComments === false ? 0 : props.documents.comments_on_documents?.[0]?.count || 0; let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; let tags = (postRecord?.tags as string[] | undefined) || [];
// For standalone posts, link directly to the document let postUrl = getDocumentURL(postRecord, props.documents.uri, pubRecord);
return ( <div className="postListing flex flex-col gap-1"> <BaseThemeProvider {...theme} local> <div ref={elRef} id={`post-listing-${postUri}`} className={` relative flex flex-col overflow-hidden selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast hover:border-accent-contrast ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} style={ hasBackgroundImage ? { backgroundImage: backgroundImage ? `url(${backgroundImage})` : undefined, backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", backgroundSize: backgroundImageRepeat ? `${backgroundImageSize}px` : "cover", } : {} } > <Link className="h-full w-full absolute top-0 left-0" href={postUrl} /> {postRecord.coverImage && ( <div className="postListingImage"> <img src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} alt={postRecord.title || ""} className="w-full h-auto aspect-video object-cover object-top-left rounded" /> </div> )} <div className="postListingInfo px-3 py-2"> <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> {postRecord.title} </h3>
<p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> {postRecord.description} </p> <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full"> {props.publication && pubRecord && ( <PubInfo href={props.publication.href} pubRecord={pubRecord} uri={props.publication.uri} postRecord={postRecord} /> )} <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> <PostDate publishedAt={postRecord.publishedAt} /> {tags.length === 0 ? null : <TagPopover tags={tags!} />} </div> </div> </div> </div> </BaseThemeProvider> <div className="text-sm flex justify-between text-tertiary"> <Interactions postUrl={postUrl} quotesCount={quotes} commentsCount={comments} recommendsCount={recommends} tags={tags} showComments={mergedPrefs.showComments !== false} showMentions={mergedPrefs.showMentions !== false} documentUri={props.documents.uri} document={postRecord} /> <Share postUrl={postUrl} /> </div> </div> );};
const PubInfo = (props: { href: string; pubRecord: NormalizedPublication; uri: string; postRecord: NormalizedDocument;}) => { let isLeaflet = hasLeafletContent(props.postRecord); let cleanUrl = props.pubRecord.url ?.replace(/^https?:\/\//, "") .replace(/^www\./, "");
return ( <div className="flex flex-col shrink-0 w-full"> <hr className=" block border-border-light mb-1" /> <div className="flex justify-between gap-4 w-full "> <Link href={props.href} className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" > <PubIcon tiny record={props.pubRecord} uri={props.uri} /> <div className="w-max min-w-0">{props.pubRecord.name}</div> </Link> {!isLeaflet && ( <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0"> <div className="truncate min-w-0">{cleanUrl}</div> <ExternalLinkTiny className="shrink-0" /> </div> )} </div> </div> );};
const PostDate = (props: { publishedAt: string | undefined }) => { let localizedDate = useLocalizedDate(props.publishedAt || "", { year: "numeric", month: "short", day: "numeric", }); if (props.publishedAt) { return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; } else return null;};
const Interactions = (props: { quotesCount: number; commentsCount: number; recommendsCount: number; tags?: string[]; postUrl: string; showComments: boolean; showMentions: boolean; documentUri: string; document: NormalizedDocument;}) => { let setSelectedPostListing = useSelectedPostListing( (s) => s.setSelectedPostListing, ); let selectPostListing = (drawer: "quotes" | "comments") => { setSelectedPostListing({ document_uri: props.documentUri, document: props.document, drawer, }); };
return ( <div className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} > <div className="postListingsInteractions flex gap-3"> <RecommendButton documentUri={props.documentUri} recommendsCount={props.recommendsCount} /> {!props.showMentions || props.quotesCount === 0 ? null : ( <button aria-label="Post quotes" onClick={() => selectPostListing("quotes")} className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" > <QuoteTiny /> {props.quotesCount} </button> )} {!props.showComments || props.commentsCount === 0 ? null : ( <button aria-label="Post comments" onClick={() => selectPostListing("comments")} className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" > <CommentTiny /> {props.commentsCount} </button> )} </div> </div> );};
const Share = (props: { postUrl: string }) => { let smoker = useSmoker(); return ( <button id={`copy-post-link-${props.postUrl}`} className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" onClick={(e) => { e.stopPropagation(); e.preventDefault(); let mouseX = e.clientX; let mouseY = e.clientY;
if (!props.postUrl) return; navigator.clipboard.writeText( props.postUrl.includes("http") ? props.postUrl : `leaflet.pub/${props.postUrl}`, );
smoker({ text: <strong>Copied Link!</strong>, position: { y: mouseY, x: mouseX, }, }); }} > Share <ShareTiny /> </button> );};