Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
@@ -1,8 +1,7 @@
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 { isHiddenPost, isLowTrustSeoPost } from "@ecency/sdk";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
import { EntryPageMightContainsMutedCommentsWarning } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-might-contains-muted-comments-warning";

interface Props {
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
4 changes: 2 additions & 2 deletions apps/web/src/features/shared/discussion/discussion-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ import {
createReplyPermlink,
dateToFormatted,
dateToFullRelative,
isHiddenPost,
makeJsonMetaDataReply
} from "@/utils";
import {
getCommunityContextQueryOptions,
getCommunityPermissions,
getCommunityType
getCommunityType,
isHiddenPost
} from "@ecency/sdk";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/button";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"use client";

import { useActiveAccount } from "@/core/hooks/use-active-account";

import i18next from "i18next";
import React, { useEffect, useMemo, useState } from "react";
import { Entry } from "@/entities";
Expand All @@ -11,38 +9,29 @@ import { postBodySummary } from "@ecency/render-helper";
import { useGlobalStore } from "@/core/global-store";
import { EcencyClientServerBridge } from "@/core/client-server-bridge";
import { EntryListItemContext } from "@/features/shared/entry-list-item/entry-list-item-context";
import { getMutedUsersQueryOptions } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import { ContentModerationReason, getContentModerationReason } from "@ecency/sdk";
import Link from "next/link";
import { UilMapPinAlt } from "@tooni/iconscout-unicons-react";
import { isHiddenPost, useEntryLocation } from "@/utils";
import { isLowTrustSeoPost } from "@/utils/is-low-trust-author";
import { useEntryLocation } from "@/utils";

interface Props {
entry: Entry;
isThumbLcp?: boolean;
}

export function EntryListItemMutedContent({ entry: entryProp, isThumbLcp }: Props) {
const { activeUser } = useActiveAccount();
const globalNsfw = useGlobalStore((s) => s.nsfw);
const { showNsfw } = EcencyClientServerBridge.useSafeContext(EntryListItemContext);

const { data: mutedUsers } = useQuery(getMutedUsersQueryOptions(activeUser?.username));

const location = useEntryLocation(entryProp);

const isPostMuted = useMemo(
() => (activeUser && mutedUsers?.includes(entryProp.author)) ?? false,
[activeUser, entryProp.author, mutedUsers]
);
const entry = useMemo(() => entryProp.original_entry || entryProp, [entryProp]);
const isCrossPost = useMemo(() => !!entry.original_entry, [entry.original_entry]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const isModMuted = useMemo(() => entry.stats?.gray ?? false, [entry.stats?.gray]);
const isHidden = useMemo(
() => isHiddenPost(entry.net_rshares, entry.stats?.total_votes ?? entry.active_votes?.length ?? 0),
[entry.net_rshares, entry.stats?.total_votes, entry.active_votes?.length]
);

// Which rule fired (moderator action, downvotes, low-trust promo) is decided in
// the SDK, so the mobile app flags the very same posts for the very same reason.
const moderationReason = useMemo(() => getContentModerationReason(entry), [entry]);

const nsfw = useMemo(
() =>
entry.json_metadata &&
Expand All @@ -51,56 +40,31 @@ export function EntryListItemMutedContent({ entry: entryProp, isThumbLcp }: Prop
entry.json_metadata.tags.includes("nsfw"),
[entry]
);
// SEO/backlink-farm signal: low-reputation account + an outbound promo link.
const isLowTrust = useMemo(() => isLowTrustSeoPost(entry), [entry]);

const [showMuted, setShowMuted] = useState(isPostMuted);
const [showModMuted, setShowModMuted] = useState(isModMuted);
const [showHidden, setShowHidden] = useState(isHidden);
const [showLowTrust, setShowLowTrust] = useState(isLowTrust);

useEffect(() => {
setShowMuted(false);
}, [activeUser]);

useEffect(() => {
setShowMuted(isPostMuted);
}, [isPostMuted]);

useEffect(() => {
setShowModMuted(isModMuted);
}, [isModMuted]);

useEffect(() => {
setShowHidden(isHidden);
}, [isHidden]);
const [isRevealed, setIsRevealed] = useState(false);

// A recycled card (same component, different post) must come back dimmed.
useEffect(() => {
setShowLowTrust(isLowTrust);
}, [isLowTrust]);
setIsRevealed(false);
}, [entry.author, entry.permlink, moderationReason]);

if (nsfw && !showNsfw && !globalNsfw) {
return <></>;
}

const shouldShowMutedOverlay = showModMuted || showHidden || showMuted || showLowTrust;
const shouldShowMutedOverlay = !!moderationReason && !isRevealed;

const mutedMessage = !shouldShowMutedOverlay
const mutedMessage = !moderationReason
? ""
: showModMuted
: moderationReason === ContentModerationReason.MOD_MUTED
? i18next.t("g.modmuted-message")
: showHidden
: moderationReason === ContentModerationReason.DOWNVOTED
? i18next.t("g.hidden-message")
: showMuted
? i18next.t("g.muted-message")
: i18next.t("g.lowtrust-message");
: i18next.t("g.lowtrust-message");

const handleReveal = (e: React.MouseEvent) => {
e.preventDefault();
if (showModMuted) setShowModMuted(false);
if (showHidden) setShowHidden(false);
if (showMuted) setShowMuted(false);
if (showLowTrust) setShowLowTrust(false);
setIsRevealed(true);
};

return (
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/features/shared/entry-list-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { EntryListItemPollIcon } from "@/features/shared/entry-list-item/entry-l
import { HydrateOnVisible } from "@/features/shared/hydrate-on-visible";
import { TranslateChip } from "@/features/shared/entry-translate/translate-chip";
import { UilComment } from "@tooni/iconscout-unicons-react";
import { useActiveAccount } from "@/core/hooks/use-active-account";
import { getMutedUsersQueryOptions, isAuthorMuted } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";

setProxyBase(defaults.imageServer);

Expand All @@ -57,6 +60,8 @@ export function EntryListItemComponent({
filter
}: Props) {
const pageAccount = account as FullAccount;
const { activeUser } = useActiveAccount();
const { data: mutedUsers } = useQuery(getMutedUsersQueryOptions(activeUser?.username));

// Keyboard backstop for the deferred action bar: once focus enters this card
// (its server-rendered title/author/tag links), mount the action controls so a
Expand All @@ -74,6 +79,15 @@ export function EntryListItemComponent({
? asAuthor
: undefined;

// Muting an author takes their posts out of the viewer's lists entirely, the
// same as the mobile app, rather than leaving a dimmed placeholder behind. The
// bridge still returns them (an observer only marks content), so the drop
// happens here, the first place with the viewer's mute list. It lands after
// hydration, since that list is a client query.
if (isAuthorMuted(entryProp.author, mutedUsers)) {
return null;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

return (
<div
className={classNameObject({
Expand Down
101 changes: 100 additions & 1 deletion apps/web/src/specs/features/shared/entry-list-item.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,19 @@ vi.mock("@/features/shared/entry-list-item/entry-list-item-thumbnail", () => ({

import { EntryListItem } from "@/features/shared/entry-list-item";

function renderItem(entry: Entry, props: Partial<React.ComponentProps<typeof EntryListItem>> = {}) {
function renderItem(
entry: Entry,
props: Partial<React.ComponentProps<typeof EntryListItem>> = {},
mutedUsers?: string[]
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
if (mutedUsers) {
// The builder is mocked to a disabled query, so seeding the cache is how a
// spec hands the card a mute list.
queryClient.setQueryData(["muted-users"], mutedUsers);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return render(
<QueryClientProvider client={queryClient}>
<EntryListItem entry={entry} order={0} {...props} />
Expand Down Expand Up @@ -289,4 +298,94 @@ describe("EntryListItem", () => {
fireEvent.focusIn(screen.getByTestId("profile-link"));
await waitFor(() => expect(screen.getByTestId("entry-vote-btn")).toBeInTheDocument());
});

describe("moderation treatment", () => {
it("drops the whole card when the viewer has muted the author", () => {
const entry = mockEntry({ author: "spammer", permlink: "post", title: "Muted Author Post" });

const { container } = renderItem(entry, {}, ["spammer"]);

expect(container).toBeEmptyDOMElement();
});

it("keeps cards from authors the viewer has not muted", () => {
const entry = mockEntry({ author: "alice", permlink: "post", title: "Fine Post" });

renderItem(entry, {}, ["spammer"]);

expect(screen.getByText("Fine Post")).toBeInTheDocument();
});

it("dims a moderator-muted post behind a hint, keeping title and summary", () => {
const entry = mockEntry({
author: "alice",
permlink: "gray-post",
title: "Grayed Post",
stats: { flag_weight: 0, gray: true, hide: false, total_votes: 2 }
});

renderItem(entry);

expect(screen.getByText("g.modmuted-message")).toBeInTheDocument();
// Nothing is hidden, only de-emphasized.
expect(screen.getByText("Grayed Post")).toBeInTheDocument();
});

it("reports downvotes rather than low trust for a downvoted small account", () => {
const entry = mockEntry({
author: "alice",
permlink: "downvoted-post",
title: "Downvoted Post",
author_reputation: 12,
body: "buy at https://shop.example",
net_rshares: -50000000000,
stats: { flag_weight: 0, gray: false, hide: false, total_votes: 9 }
});

renderItem(entry);

expect(screen.getByText("g.hidden-message")).toBeInTheDocument();
});

it("flags a low-reputation author only when the post carries an outbound link", () => {
const promo = mockEntry({
author: "alice",
permlink: "promo-post",
title: "Promo Post",
author_reputation: 12,
body: "buy at https://shop.example"
});

const { unmount } = renderItem(promo);
expect(screen.getByText("g.lowtrust-message")).toBeInTheDocument();
unmount();

const diary = mockEntry({
author: "alice",
permlink: "diary-post",
title: "Diary Post",
author_reputation: 12,
body: "just my diary, no links"
});

renderItem(diary);
expect(screen.queryByText("g.lowtrust-message")).not.toBeInTheDocument();
});

it("clears the dim when the hint is clicked", () => {
const entry = mockEntry({
author: "alice",
permlink: "gray-post",
title: "Grayed Post",
stats: { flag_weight: 0, gray: true, hide: false, total_votes: 2 }
});

renderItem(entry);

fireEvent.click(screen.getByText("g.modmuted-message"));

expect(screen.queryByText("g.modmuted-message")).not.toBeInTheDocument();
expect(screen.getByText("Grayed Post")).toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions apps/web/src/specs/setup-any-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ vi.mock("i18next", () => ({
}));

vi.mock("@ecency/sdk", async () => ({
// The moderation rules are pure functions with no dependencies, and components
// call them during render, so hand out the real implementations from source
// (not dist, which is only rebuilt on a labelled release).
...(await vi.importActual<Record<string, unknown>>(
"../../../../packages/sdk/src/modules/moderation"
)),
PrivateKey: { fromString: vi.fn(), fromLogin: vi.fn(), from: vi.fn() },
PublicKey: { fromString: vi.fn(), from: vi.fn() },
Signature: { from: vi.fn() },
Expand Down
Loading
Loading