import { useMemo, useState } from "react"; import { useIsBlockSelected } from "src/useUIState"; import { useEntity, useReplicache } from "src/replicache"; import { BlockProps, BlockLayout } from "./Block"; import { usePublicationData, useNormalizedPublicationRecord, } from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { PaginatedPublicationPostsList } from "app/(app)/lish/[did]/[publication]/PaginatedPublicationPostsList"; import { POSTS_LIST_PAGE_SIZE, postsListFilterKey, sortPostsForList, filterPostsByTags, type LoadPostsBatch, } from "app/(app)/lish/[did]/[publication]/postsListPagination"; import type { PublicationPostsListPost } from "app/(app)/lish/[did]/[publication]/buildPublicationPosts"; import { Popover } from "components/Popover"; import { Toggle } from "components/Toggle"; import { SettingsTriggerButton } from "./SettingsTriggerButton"; import { PlaceholderText } from "./PostSizeIcons"; import { CloseTiny } from "components/Icons/CloseTiny"; import { EmptyState } from "components/EmptyState"; type PostsListView = "small" | "medium"; export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => { let isSelected = useIsBlockSelected(props.entityID); if (props.preview) { return ( ); } return ( } > ); }; function PostsListBlockContent({ entityID }: { entityID: string }) { let { data } = usePublicationData(); let publicationRecord = useNormalizedPublicationRecord(); let viewFact = useEntity(entityID, "posts-list/view"); let view: PostsListView = viewFact?.data.value ?? "medium"; let highlightFirstFact = useEntity( entityID, "posts-list/highlight-first-post", ); let highlightFirst = highlightFirstFact?.data.value ?? false; let filterTagFacts = useEntity(entityID, "posts-list/filter-tag"); let filterTags = useMemo( () => filterTagFacts.map((f) => f.data.value), [filterTagFacts], ); let limitFact = useEntity(entityID, "posts-list/limit"); let limit = limitFact?.data.value; // The dashboard already loads every document, so order/filter that in-memory // set, hand the paginated list the full URI ordering, and resolve each batch // locally — no extra round trips, just windowed rendering. let listData = useMemo(() => { if (!data?.documents) return null; let posts: PublicationPostsListPost[] = sortPostsForList( filterPostsByTags(data.documents, filterTags), ).map((d) => ({ uri: d.uri, record: d.record, commentsCount: d.commentsCount, mentionsCount: d.mentionsCount, recommendsCount: d.recommendsCount, membersOnly: d.membersOnly, })); let byUri = new Map(posts.map((p) => [p.uri, p])); let loadBatch: LoadPostsBatch = async (batch) => batch .map((u) => byUri.get(u)) .filter((p): p is PublicationPostsListPost => p !== undefined); return { uris: posts.map((p) => p.uri), initialPosts: posts.slice(0, POSTS_LIST_PAGE_SIZE), loadBatch, }; }, [data?.documents, filterTags]); if (data === undefined) return ; if (!data?.publication) return ; if (!listData || listData.uris.length === 0) return ( You haven't published any posts yet! When you do, they'll show here. ); return ( ); } function PostsListPlaceholder() { return (
{[0, 1, 2].map((i) => (

))}
); } function PostsListSettingsButton(props: { entityID: string }) { let { rep, undoManager } = useReplicache(); let { data } = usePublicationData(); let viewFact = useEntity(props.entityID, "posts-list/view"); let view: PostsListView = viewFact?.data.value ?? "medium"; let highlightFirstFact = useEntity( props.entityID, "posts-list/highlight-first-post", ); let highlightFirst = highlightFirstFact?.data.value ?? false; let filterTagFacts = useEntity(props.entityID, "posts-list/filter-tag"); let selectedTags = useMemo( () => filterTagFacts.map((f) => f.data.value), [filterTagFacts], ); let limitFact = useEntity(props.entityID, "posts-list/limit"); let limit = limitFact?.data.value; let [filterByTagEnabled, setFilterByTagEnabled] = useState( () => selectedTags.length > 0, ); let [limitEnabled, setLimitEnabled] = useState(() => !!limit && limit > 0); let setLimit = (value: number) => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/limit", data: { type: "number", value }, }); }; let clearLimit = () => { if (!rep || !limitFact) return; rep.mutate.retractFact({ factID: limitFact.id }); }; let allTags = useMemo(() => { let tagSet = new Set(); for (let doc of data?.documents ?? []) { for (let tag of doc.record.tags ?? []) tagSet.add(tag); } return Array.from(tagSet).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }), ); }, [data?.documents]); return ( } >

List Layout

{( [ { value: "small", Icon: SmallIcon }, { value: "medium", Icon: MedIcon }, ] as { value: PostsListView; Icon: (props: { selected: boolean }) => React.ReactNode; }[] ).map((option) => { let selected = view === option.value || (option.value === "medium" && view !== "small"); return ( ); })}
{ if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/highlight-first-post", data: { type: "boolean", value: !highlightFirst }, }); }} > Highlight First Post
{ if (limitEnabled) { clearLimit(); setLimitEnabled(false); } else { setLimitEnabled(true); if (!limit || limit < 1) setLimit(5); } }} > Limit Posts {limitEnabled && (
Show only e.stopPropagation()} onChange={(e) => { let next = Math.max(1, Math.floor(Number(e.target.value))); if (Number.isFinite(next)) setLimit(next); }} className="input-tag w-16 border border-border rounded px-1 py-0.5 bg-bg-page" /> posts
)}

Settings

{ if (filterByTagEnabled) { // turning off the filter clears any selected tags undoManager.withUndoGroup(async () => { if (!rep) return; for (let fact of filterTagFacts) await rep.mutate.retractFact({ factID: fact.id }); }); setFilterByTagEnabled(false); } else { setFilterByTagEnabled(true); } }} > Filter by Tag {filterByTagEnabled && selectedTags.length > 0 && ( )}
{filterByTagEnabled ? (
{allTags.length === 0 ? (
no tags yet
) : (
{allTags.map((tag) => { let selectedFact = filterTagFacts.find( (f) => f.data.value === tag, ); let isSelected = !!selectedFact; return ( ); })}
)}
) : null}
); } const SmallIcon = ({ selected }: { selected: boolean }) => { return (
{[0, 1, 2].map((i) => (
{PlaceholderText("lg")}
{PlaceholderText("sm", "60%")}
))}
); }; const MedIcon = ({ selected }: { selected: boolean }) => { return (
{[0, 1].map((i) => (
{PlaceholderText("lg")} {PlaceholderText("md")} {PlaceholderText("md", "80%")}
{PlaceholderText("sm", "60%")}
))}
); };