Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,13 @@ export default async function CommunityPostsPage({ params, searchParams }: Props
entries={flatEntries}
loading={false}
sectionParam={tag}
showEmptyPlaceholder={false}
/>
<CommunityContentInfiniteList
community={communityData}
section={tag}
initialEntryAuthors={flatEntries.map((entry) => entry.author)}
/>
<CommunityContentInfiniteList community={communityData} section={tag} />
{olderCursor && (
<EntryArchivePager basePath={basePath} olderCursor={olderCursor} showLatest={false} />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
"use client";

import { DetectBottom, EntryListContent, EntryListContentLoading } from "@/features/shared";
import {
DetectBottom,
EntryListContent,
EntryListContentLoading,
EntryListContentNoData
} from "@/features/shared";
import React, { useMemo } from "react";
import { useBottomPagination } from "@/core/hooks";
import { usePostsFeedQuery } from "@/api/queries";
import { useVisibleEntries } from "@/features/shared/entry-list-item/use-muted-authors";
import { Community, Entry, SearchResponse } from "@/entities";
import type { InfiniteData } from "@tanstack/react-query";

interface Props {
community: Community;
section: string;
/**
* Authors of the server-rendered slice above, not a count: whether those are
* visible depends on the viewer's mute list, which only exists on the client.
* This component owns the empty state for the pair.
*/
initialEntryAuthors: string[];
}

type FeedPage = Entry[] | SearchResponse;

export function CommunityContentInfiniteList({ section, community }: Props) {
export function CommunityContentInfiniteList({ section, community, initialEntryAuthors }: Props) {
// No observer argument on purpose, so this resolves to DEFAULT_OBSERVER and
// matches the server-rendered first page, which `entryList` drops below.
// See profile-entries-infinite-list for why personalising only pages 2+ is
Expand Down Expand Up @@ -52,6 +64,25 @@ export function CommunityContentInfiniteList({ section, community }: Props) {
[data?.pages]
);

// Count what the viewer can see across the server-rendered slice and ours, so
// a community whose every loaded author they muted says so instead of
// rendering as blank space. The server slice may already include pages this
// one also holds, but double counting can only overstate a non-zero total,
// never fake a zero.
const initialVisible = useVisibleEntries(
useMemo(() => initialEntryAuthors.map((author) => ({ author })), [initialEntryAuthors])
);
const visibleEntryList = useVisibleEntries(entryList);
const hasClientData = (data?.pages?.length ?? 0) > 0;
// `!hasNextPage` keeps the message off while pages the viewer might see are
// still to come: everything loaded so far being muted is not an empty
// community, and the sentinel below is already fetching the next page.
const shouldShowEmptyState =
hasClientData &&
!isFetching &&
!hasNextPage &&
initialVisible.length + visibleEntryList.length === 0;

return (
<>
<EntryListContent
Expand All @@ -62,6 +93,9 @@ export function CommunityContentInfiniteList({ section, community }: Props) {
isPromoted={false}
showEmptyPlaceholder={false}
/>
{shouldShowEmptyState && (
<EntryListContentNoData username={community.name} loading={false} section={section} />
)}
<DetectBottom onBottom={onBottom} />
{isFetching && <EntryListContentLoading />}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export async function CommunityContent({ filter, community, tag, query, section
return <></>;
}

// The infinite list below owns the empty state for both slices, so it needs to
// know who wrote this one.
const serverEntries = data.pages.reduce<Entry[]>((acc, page) => [...acc, ...(page as Entry[])], []);

return (
<>
{data.pages.length === 0 ? <LinearProgress /> : ""}
Expand All @@ -53,11 +57,16 @@ export async function CommunityContent({ filter, community, tag, query, section
<EntryListContent
username={community.name}
isPromoted={false}
entries={data.pages.reduce<Entry[]>((acc, page) => [...acc, ...(page as Entry[])], [])}
entries={serverEntries}
loading={false}
sectionParam={filter}
showEmptyPlaceholder={false}
/>
<CommunityContentInfiniteList
community={community}
section={section}
initialEntryAuthors={serverEntries.map((entry) => entry.author)}
/>
<CommunityContentInfiniteList community={community} section={section} />
</ProfileEntriesLayout>
)}
</>
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/app/(dynamicPages)/community/[community]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export default async function CommunityPostsPage({ params }: Props) {
return <></>;
}
const data = stripAnonEntryCacheInPlace(getQueryClient(), fetched, loggedInUser);
// The infinite list below owns the empty state for both slices, so it needs to
// know who wrote this one.
const serverEntries = data.pages.reduce<Entry[]>((acc, page) => [...acc, ...(page as Entry[])], []);

return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
Expand All @@ -70,11 +73,16 @@ export default async function CommunityPostsPage({ params }: Props) {
<EntryListContent
username={community}
isPromoted={false}
entries={data.pages.reduce<Entry[]>((acc, page) => [...acc, ...(page as Entry[])], [])}
entries={serverEntries}
loading={false}
sectionParam="created"
showEmptyPlaceholder={false}
/>
<CommunityContentInfiniteList
community={communityData}
section="created"
initialEntryAuthors={serverEntries.map((entry) => entry.author)}
/>
<CommunityContentInfiniteList community={communityData} section="created" />
</ProfileEntriesLayout>
</HydrationBoundary>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import i18next from "i18next";
import { Tsx } from "@/features/i18n/helper";
import { Entry } from "@/entities";
import { isHiddenPost } from "@/utils";
import { isLowTrustSeoPost } from "@/utils/is-low-trust-author";
import { ContentModerationReason, getContentModerationReason } from "@ecency/sdk";
import { EntryPageMightContainsMutedCommentsWarning } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-might-contains-muted-comments-warning";

interface Props {
entry: Entry;
}

export function EntryPageWarnings({ entry }: Props) {
const isMuted = !!entry.stats?.gray && entry.net_rshares >= 0 && entry.author_reputation >= 0;
const isHidden = isHiddenPost(
entry?.net_rshares,
entry?.stats?.total_votes ?? entry?.active_votes?.length ?? 0
);
const isLowReputation =
!!entry.stats?.gray && entry.net_rshares >= 0 && entry.author_reputation < 0;
// One reason wins, on the SDK's precedence (moderator action, then downvotes,
// then low trust), so a post matching several rules cannot stack warnings and
// claim contradictory things about itself.
const reason = getContentModerationReason(entry);

// hivemind grays a post either because a moderator muted it or because the
// author's own reputation went negative. Both arrive as the same flag, so the
// reputation sign is what picks the wording.
const isModerated = reason === ContentModerationReason.MOD_MUTED;
const isMuted = isModerated && entry.author_reputation >= 0;
const isLowReputation = isModerated && entry.author_reputation < 0;
const isHidden = reason === ContentModerationReason.DOWNVOTED;
// Low-reputation account publishing an outbound promo link (SEO/backlink-farm
// signature; reputation only, not account age). We warn rather than hide; the
// outbound link carries no SEO value (noindex) and the reader is cautioned.
const isLowTrust = isLowTrustSeoPost(entry);
const isLowTrust = reason === ContentModerationReason.LOW_TRUST;

return (
<>
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/app/(dynamicPages)/feed/_components/feed-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSearchParams } from "next/navigation";
import { DetectBottom } from "@/features/shared/detect-bottom";
import { EntryListContent, EntryListContentLoading, EntryListContentNoData } from "@/features/shared/entry-list-content";
import { EcencyConfigManager } from "@/config";
import { useVisibleEntries } from "@/features/shared/entry-list-item/use-muted-authors";
import { getPromotedPostsQuery } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";

Expand All @@ -31,7 +32,7 @@ export function FeedList({ filter, tag, observer }: Props) {
});

// Single source of truth - one query call
const { data, fetchNextPage, isLoading, isFetching, isFetchingNextPage } =
const { data, fetchNextPage, isLoading, isFetching, isFetchingNextPage, hasNextPage } =
usePostsFeedQuery(filter, tag, observer);

// Extract entries from all pages (no skipping - simpler and works with client-side navigation)
Expand All @@ -51,9 +52,16 @@ export function FeedList({ filter, tag, observer }: Props) {
return extracted;
}, [data, filter, tag, observer, noReblog]); // Include filter/tag/observer to ensure recalc on param changes

// Simple, clear loading and empty state logic
const isLoadingData = isLoading || (isFetching && entries.length === 0);
const isEmpty = !isLoading && !isFetching && entries.length === 0;
// Everything the viewer can actually see: a feed whose every author they muted
// has to reach the empty state below, not render as blank space.
const visibleEntries = useVisibleEntries(entries);

// Simple, clear loading and empty state logic. `!hasNextPage` keeps the
// message off while pages the viewer might see are still to come: everything
// loaded so far being muted is not an empty feed, and DetectBottom is already
// fetching the next page.
const isLoadingData = isLoading || (isFetching && visibleEntries.length === 0);
const isEmpty = !isLoading && !isFetching && !hasNextPage && visibleEntries.length === 0;
const showLoading = isLoadingData || isFetchingNextPage;

// Check if this is a global feed (should never show empty state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,26 @@ import {
} from "@/features/shared";
import React, { useMemo } from "react";
import { usePostsFeedQuery } from "@/api/queries";
import { useVisibleEntries } from "@/features/shared/entry-list-item/use-muted-authors";
import { Entry, FullAccount } from "@/entities";

interface Props {
account: FullAccount;
section: string;
initialEntriesCount: number;
/**
* Authors of the server-rendered first page, not a count: whether those
* entries are visible depends on the viewer's mute list, which only exists on
* the client.
*/
initialEntryAuthors: string[];
initialPageEntriesCount: number;
initialDataLoaded: boolean;
}

export function ProfileEntriesInfiniteList({
section,
account,
initialEntriesCount,
initialEntryAuthors,
initialPageEntriesCount,
initialDataLoaded
}: Props) {
Expand All @@ -48,12 +54,23 @@ export function ProfileEntriesInfiniteList({
);
}, [account.profile?.pinned, data?.pages, dropFirstPage]);

const totalEntriesCount = initialEntriesCount + entryList.length;
// Count what the viewer can see, across the server-rendered page and ours: a
// profile whose every post the viewer muted must show its empty state rather
// than nothing at all.
const initialVisible = useVisibleEntries(
useMemo(() => initialEntryAuthors.map((author) => ({ author })), [initialEntryAuthors])
);
const visibleEntryList = useVisibleEntries(entryList);

const totalEntriesCount = initialVisible.length + visibleEntryList.length;
const hasClientData = (data?.pages?.length ?? 0) > 0;
const isDataReady = initialDataLoaded || hasClientData;
const isFetchingData = isFetching || isFetchingNextPage;
// `!hasNextPage` keeps the message off while pages the viewer might see are
// still to come: everything loaded so far being muted is not an empty profile,
// and the sentinel below is already fetching the next page.
const shouldShowEmptyState =
isDataReady && !isFetchingData && totalEntriesCount === 0;
isDataReady && !isFetchingData && !hasNextPage && totalEntriesCount === 0;

const handleBottom = () => {
if (!hasNextPage || isFetchingData) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export async function ProfileEntriesList({ section, account, initialFeed, curren

const initialPageEntriesCount = initialPageEntries.length;
const initialEntriesCount = entryList.length;
// The infinite list needs to know which of these the viewer can see before it
// decides the profile is empty, and only the client holds the mute list.
const initialEntryAuthors = entryList.map((entry) => entry.author);
const initialDataLoaded = Boolean(initialFeed) || feedPages.length > 0;

return (
Expand All @@ -81,7 +84,7 @@ export async function ProfileEntriesList({ section, account, initialFeed, curren
<ProfileEntriesInfiniteList
section={section}
account={account}
initialEntriesCount={initialEntriesCount}
initialEntryAuthors={initialEntryAuthors}
initialPageEntriesCount={initialPageEntriesCount}
initialDataLoaded={initialDataLoaded}
/>
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/app/waves/_components/waves-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ import { useCollectPageViewEvent } from "@/api/mutations";
import { useMutedUsers, useWaveImageGrid } from "@/app/waves/_hooks";
import { PostContentRenderer } from "@/features/shared";
import { useQuery } from "@tanstack/react-query";
import { getPromotedPostsQuery } from "@ecency/sdk";
import { getPromotedPostsQuery, isHiddenPost } from "@ecency/sdk";
import clsx from "clsx";
import {
WAVES_FEED_SCROLL_STORAGE_KEY,
WavesFeedScrollState,
WavesFeedType
} from "@/app/waves/_constants";
import { useOptionalWavesTagFilter } from "@/app/waves/_context";
import { isHiddenPost } from "@/utils";

const INTERACTIVE_SELECTOR =
"a,button,input,textarea,select,img,[role='button'],[role='link'],[role='menuitem'],[contenteditable='true']";
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@
"update": "Update",
"learnMore": "Learn more",
"or": "OR",
"muted-message": "Muted author, Reveal content",
"modmuted-message": "Community Moderators muted, Reveal content",
"hidden-message": "Downvoted by users, Reveal content",
"lowtrust-message": "Low reputation account with an unverified outbound link, Reveal content",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/shared/bookmarks/bookmark-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export function BookmarkItem({ author, permlink, i }: Props) {
author,
permlink
));
// Muted authors are filtered out by BookmarksList, which owns the list and its
// empty state; dropping them here instead would leave the wrapper below as an
// empty bordered card.
if (!entry) {
return <></>;
}
Expand Down
Loading
Loading