import { Fragment, useMemo, useState } from "react";
import { v7 } from "uuid";
import { useIsBlockSelected, useUIState } from "src/useUIState";
import { useEntity, useReplicache } from "src/replicache";
import { BlockProps, BlockLayout } from "./Block";
import {
usePublicationData,
useNormalizedPublicationRecord,
} from "app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
import { PaginatedPublicationPostsList } from "app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList";
import {
ChapterGrid,
ChapterShelf,
} from "app/(app)/(published)/lish/[did]/[publication]/PublicationPostsChapterList";
import { buildChapterCards } from "src/utils/chapterGrouping";
import {
buildPostsListIndex,
resolveReaderControls,
sortPostsForList,
filterPostsByTags,
type PostsListView,
} from "src/utils/postsListPagination";
import { getFirstParagraph } from "src/utils/getFirstParagraph";
import type { PublicationPostsListPost } from "src/utils/buildPublicationPosts";
import { ToggleWithLabel } from "components/Toggle";
import { BlockSettings } from "./SettingsTriggerButton";
import { PlaceholderText } from "./PostSizeIcons";
import { BlockSettingOptions } from "./BlockSettingOptions";
import { CloseTiny } from "components/Icons/CloseTiny";
import { EmptyState } from "components/EmptyState";
import { ShortcutKey } from "components/Layout";
import { HelpSmall } from "components/Icons/HelpSmall";
import { CustomizeTutorialTooltip } from "app/(app)/(identity)/lish/[did]/[publication]/edit/CustomizeTutorialTooltip";
export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => {
let isSelected = useIsBlockSelected(props.entityID);
let isFocused = useUIState(
(s) => s.focusedEntity?.entityID === props.entityID,
);
if (props.preview) {
return (
);
}
return (
}
>
);
};
function usePostsListReaderFlags(entityID: string) {
let readerControls = useEntity(entityID, "posts-list/reader-controls");
let readerSearch = useEntity(entityID, "posts-list/reader-search");
let readerTagFilter = useEntity(entityID, "posts-list/reader-tag-filter");
let readerSort = useEntity(entityID, "posts-list/reader-sort");
return {
readerControls: readerControls?.data.value,
readerSearch: readerSearch?.data.value,
readerTagFilter: readerTagFilter?.data.value,
readerSort: readerSort?.data.value,
};
}
function PostsListBlockContent({ entityID }: { entityID: string }) {
let { data } = usePublicationData();
let publicationRecord = useNormalizedPublicationRecord();
let { rootEntity } = useReplicache();
// The draft's theme facts are the width the editor is rendering at; the
// publication record only catches up on publish.
let pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value;
let viewFact = useEntity(entityID, "posts-list/view");
let view: PostsListView = viewFact?.data.value ?? "medium";
let readerFlags = usePostsListReaderFlags(entityID);
let readerControls = useMemo(
() => (view === "chapter" ? undefined : resolveReaderControls(readerFlags)),
[
view,
readerFlags.readerControls,
readerFlags.readerSearch,
readerFlags.readerTagFilter,
readerFlags.readerSort,
],
);
let highlightFirstFact = useEntity(
entityID,
"posts-list/highlight-first-post",
);
let highlightFirst = highlightFirstFact?.data.value ?? false;
let showPageCount =
useEntity(entityID, "posts-list/show-page-count")?.data.value ?? true;
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;
let listData = useMemo(() => {
if (!data?.documents) return null;
let ordered: 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,
})),
);
return {
posts: ordered,
latestPost: ordered[0],
chapterCards:
view === "chapter" && data.publication
? buildChapterCards(ordered, data.publication)
: undefined,
readerIndex: readerControls
? buildPostsListIndex(ordered, (p) => getFirstParagraph(p.record))
: undefined,
};
}, [data?.documents, data?.publication, filterTags, view, readerControls]);
if (data === undefined) return ;
if (!data?.publication) return ;
if (!listData || listData.posts.length === 0)
return (
You haven't published any posts yet! When you do, they'll show here.
0 ? limit : 3)}
pageWidth={pageWidth ?? publicationRecord?.theme?.pageWidth}
/>
);
// The editor lays the list out rather than reading it, so both branches
// disable links — clicking a post shouldn't navigate away from the page
// being customized.
if (view === "chapter") {
return (
);
}
return (
p.uri)}
index={listData.readerIndex}
knownPosts={listData.posts}
view={view}
highlightFirstPost={highlightFirst}
limit={limit}
readerControls={readerControls}
disableLinks
pageWidth={pageWidth}
/>
);
}
function PostsListPlaceholder() {
return (
);
}
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 showPageCount =
useEntity(props.entityID, "posts-list/show-page-count")?.data.value ?? true;
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 readerFlags = usePostsListReaderFlags(props.entityID);
let readerControlsEnabled = !!readerFlags.readerControls;
let [filterByTagEnabled, setFilterByTagEnabled] = useState(
() => selectedTags.length > 0,
);
let [limitEnabled, setLimitEnabled] = useState(() => !!limit && limit > 0);
let [chapterHelpOpen, setChapterHelpOpen] = useState(false);
let setReaderFlag = (
attribute:
| "posts-list/reader-controls"
| "posts-list/reader-search"
| "posts-list/reader-tag-filter"
| "posts-list/reader-sort",
value: boolean,
) => {
if (!rep) return;
return rep.mutate.assertFact({
entity: props.entityID,
attribute,
data: { type: "boolean", value },
});
};
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
options={[
{ value: "small", Icon: SmallIcon },
{ value: "medium", Icon: MedIcon },
{ value: "chapter", Icon: ChapterIcon },
]}
value={view}
halfWidthOnMobile
onSelect={(value) => {
if (!rep) return;
rep.mutate.assertFact({
entity: props.entityID,
attribute: "posts-list/view",
data: { type: "posts-list-view-union", value },
});
}}
>
{/* A sibling of the layout options rather than a child: the chapter
option is itself a button. On desktop it's the row's last
column, so its bottom right corner is the row's; on mobile it
wraps to the first column of the second row. */}
{view === "chapter" && (
)}
{view === "chapter" && chapterHelpOpen && (
Group posts into chapters based on titles
{" "}
Posts titled
"Ch1 - Pg1"
"Ch1 - Pg2"
"Ch1 - Pg3"
will group into a chapter named "Ch1".
Separate chapter names and page titles with /,{" "}
: or -.
Do not use these characters in chapter names!
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);
}
}}
/>
{filterByTagEnabled && selectedTags.length > 0 && (
)}
{filterByTagEnabled ? (
undoManager.withUndoGroup(async () => {
if (readerControlsEnabled) {
await setReaderFlag("posts-list/reader-controls", false);
} else {
// Turning the feature on should show all three rather
// than an empty control row.
await setReaderFlag("posts-list/reader-controls", true);
await setReaderFlag("posts-list/reader-search", true);
await setReaderFlag("posts-list/reader-tag-filter", true);
await setReaderFlag("posts-list/reader-sort", true);
}
})
}
/>
{readerControlsEnabled && (