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
11 changes: 7 additions & 4 deletions src/components/postCard/children/postCardContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useLayoutState } from '@shopify/flash-list';
import styles from '../styles/postCard.styles';
import { PostCardActionIds } from '../container/postCard';
import ROUTES from '../../../constants/routeNames';
import { ContentType, MutedReason } from '../../../providers/hive/hive.types';
import { ContentType, LegacyMutedReason, MutedReason } from '../../../providers/hive/hive.types';
import { isCommunity } from '../../../utils/communityValidation';
import { useImageReveal } from '../../../hooks/useImageReveal';
import { useMutedReveal } from '../../../hooks/useMutedReveal';
Expand Down Expand Up @@ -97,16 +97,19 @@ const PostCardContentComponent = ({ content, nsfw, handleCardInteraction }: Prop
);

// State the reason that actually fired. Posts cached by an older app version carry
// isMuted without a reason, so those fall back to the generic moderation message.
// that version's reason (or none at all), so those keep their old copy and fall back
// to the generic moderation message.
const _mutedText = useMemo(() => {
if (!_isMuted) {
return '';
}
switch (content?.mutedReason) {
case MutedReason.LOW_REPUTATION:
return intl.formatMessage({ id: 'post.muted_low_reputation' });
case MutedReason.DOWNVOTED:
return intl.formatMessage({ id: 'post.muted_downvoted' });
case MutedReason.LOW_TRUST:
return intl.formatMessage({ id: 'post.muted_low_trust' });
case LegacyMutedReason.LOW_REPUTATION:
return intl.formatMessage({ id: 'post.muted_low_reputation' });
default:
return _isCommunityPost
? intl.formatMessage({ id: 'post.community_muted' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ const PostOptionsModal = (

const _canMuteCommunityPost = _canModerateCommunityPost;

// Read `stats.gray` directly rather than the parsed `isMuted`. getMutedReason
// also reports MODERATED for low-reputation and downvoted posts, which would
// offer "unmute" on posts no moderator ever muted.
// Read `stats.gray` directly rather than the parsed `isMuted`, which is also set
// for downvoted and low-trust posts and would offer "unmute" on posts no
// moderator ever muted.
const _isMutedInCommunity = !!content && !!content.stats?.gray;

// Carried over from the legacy comment menu, so both are scoped to comments
Expand Down
12 changes: 5 additions & 7 deletions src/components/postsList/container/postsListContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useIntl } from 'react-intl';
import { SheetManager } from 'react-native-actions-sheet';

import { FlashList } from '@shopify/flash-list';
import { isAuthorMuted } from '@ecency/sdk';
import PostCard from '../../postCard';
import styles from '../view/postsListStyles';
import { Separator, UpvotePopover } from '../..';
Expand Down Expand Up @@ -105,21 +106,18 @@ const postsListContainer = (
return [];
}

// also skip muted posts
_data = _data.filter((item) => {
const isMuted = mutes && mutes.indexOf(item.author) > -1;
return !isMuted && !!item?.author;
});
// Authors the viewer muted are dropped from the list rather than dimmed, and the
// website now does the same. Shared helper so both stay on one definition.
_data = _data.filter((item) => !isAuthorMuted(item.author, mutes) && !!item?.author);
Comment on lines +109 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Author check ordered wrongly 🐞 Bug ☼ Reliability

postsListContainer calls isAuthorMuted(item.author, mutes) before verifying item.author
exists, so entries with a missing/empty author will still invoke the SDK helper. If isAuthorMuted
assumes a non-empty string, this can throw or misclassify items before they’re filtered out,
breaking feed rendering.
Agent Prompt
### Issue description
`isAuthorMuted(item.author, mutes)` is evaluated before `!!item?.author`, so the helper is invoked with a potentially missing/empty author.

### Issue Context
This affects both the main `_data` filter and the promoted-posts filter.

### Fix Focus Areas
- src/components/postsList/container/postsListContainer.tsx[109-121]

### Suggested change
Reorder the predicate so author validation happens first:
- `_data = _data.filter((item) => !!item?.author && !isAuthorMuted(item.author, mutes));`
- Apply the same ordering in the `_promotedPosts` filter.

If `isAuthorMuted` accepts only strings, consider normalizing: `const author = item?.author ?? "";` and keep the explicit `!!author` guard before calling the helper.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half right, and worth fixing, though not for the stated reason. isAuthorMuted is null-safe on its own:

return !!author && !!mutedAuthors?.includes(author);

so an entry with a missing or empty author cannot throw there or be misclassified; it returns false and the !!item?.author check then drops the item.

What is real is the ordering: item.author is dereferenced twice before the ?. that exists to tolerate a nullish item, so a null entry would take the feed down at the dereference rather than being filtered out. That predates this PR (the previous line was !isMuted && !!item?.author), but it reads as the opposite of what the code means.

Fixed in #3504, guard first in both the main filter and the promoted one.


// Create Set for O(1) lookup instead of O(n) filter
const existingPermlinks = new Set(_data.map((post) => `${post.author}/${post.permlink}`));

const _promotedPosts =
promotedPosts && Array.isArray(promotedPosts)
? promotedPosts.filter((item) => {
const isMuted = mutes && mutes.indexOf(item.author) > -1;
const notInPosts = !existingPermlinks.has(`${item.author}/${item.permlink}`);
return !isMuted && !!item?.author && notInPosts;
return !isAuthorMuted(item.author, mutes) && !!item?.author && notInPosts;
})
: [];

Expand Down
1 change: 1 addition & 0 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,7 @@
"community_muted": "Content muted for community guidelines violation",
"muted_low_reputation": "Content from a low reputation account",
"muted_downvoted": "Content downvoted by users",
"muted_low_trust": "Low reputation account with an unverified outbound link",
"muted_reveal": "Tap to reveal",
"promoted": "PROMOTED",
"image_saved": "Image saved to Photo Gallery",
Expand Down
21 changes: 14 additions & 7 deletions src/providers/hive/hive.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ export enum ContentType {
}

/**
* Why a post or comment is collapsed behind the muted overlay. Each reason gets its
* own message so the UI never claims a guideline violation for content that was only
* flagged on reputation or downvotes. Assigned by getMutedReason in utils/postParser.
* Why a post or comment is dimmed behind the moderation hint. Each reason gets its own
* message so the UI never claims a guideline violation for content that was only
* downvoted. Assigned by parsePost/parseComment from the SDK's shared rules, so the
* website flags the same content for the same reason.
*
* Lives here rather than in postParser so components can read it without pulling the
* parser's import chain (postParser -> utils/image -> redux/store) into their bundle.
* Re-exported under the local name rather than imported from the SDK everywhere, so
* components keep reading one mobile-side symbol.
*/
export enum MutedReason {
export { ContentModerationReason as MutedReason } from '@ecency/sdk';

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
/**
* Values written by app versions that carried their own rules. Posts cached before the
* update still hold these, so the card copy keeps mapping them until the cache turns
* over. Nothing writes them any more.
*/
export enum LegacyMutedReason {
MODERATED = 'moderated',
LOW_REPUTATION = 'low_reputation',
DOWNVOTED = 'downvoted',
}

export enum PollPreferredInterpretation {
Expand Down
79 changes: 31 additions & 48 deletions src/utils/postParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
parseVote,
isVoted,
isDownVoted,
getMutedReason,
} from './postParser';
import { MutedReason } from '../providers/hive/hive.types';

Expand Down Expand Up @@ -286,24 +285,40 @@ describe('parsePost', () => {
const post = makePost({ stats: { gray: true }, author_reputation: 50 });
const result = parsePost(post, 'viewer', false);
expect(result!.isMuted).toBe(true);
expect(result!.mutedReason).toBe(MutedReason.MODERATED);
expect(result!.mutedReason).toBe(MutedReason.MOD_MUTED);
});

it('sets isMuted for low reputation', () => {
const post = makePost({ author_reputation: 10 });
it('sets isMuted for a low reputation author promoting an outbound link', () => {
const post = makePost({ author_reputation: 10, body: 'buy at https://shop.example' });
// parseReputation mock returns floor of input if 0 < x <= 100
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parseReputation } = require('./user');
parseReputation.mockReturnValueOnce(10);
const result = parsePost(post, 'viewer', false);
expect(result!.isMuted).toBe(true);
expect(result!.mutedReason).toBe(MutedReason.LOW_REPUTATION);
expect(result!.mutedReason).toBe(MutedReason.LOW_TRUST);
});

it('leaves a low reputation author alone when the post carries no outbound link', () => {
const post = makePost({ author_reputation: 10, body: 'just my diary' });
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parseReputation } = require('./user');
parseReputation.mockReturnValueOnce(10);
const result = parsePost(post, 'viewer', false);
expect(result!.isMuted).toBe(false);
expect(result!.mutedReason).toBeNull();
});

it('sets isMuted for heavily downvoted posts', () => {
const post = makePost({
net_rshares: -8000000000,
active_votes: [{ voter: 'a' }, { voter: 'b' }, { voter: 'c' }, { voter: 'd' }],
net_rshares: -20000000000,
active_votes: [
{ voter: 'a' },
{ voter: 'b' },
{ voter: 'c' },
{ voter: 'd' },
{ voter: 'e' },
],
author_reputation: 50,
});
const result = parsePost(post, 'viewer', false);
Expand All @@ -323,7 +338,7 @@ describe('parsePost', () => {
// reason must follow the content actually shown.
const post = makePost({
author_reputation: 50,
net_rshares: -8000000000,
net_rshares: -20000000000,
original_entry: {
author: 'original',
permlink: 'orig-post',
Expand All @@ -347,8 +362,14 @@ describe('parsePost', () => {
permlink: 'orig-post',
body: 'original body',
author_reputation: 50,
net_rshares: -8000000000,
active_votes: [{ voter: 'a' }, { voter: 'b' }, { voter: 'c' }, { voter: 'd' }],
net_rshares: -20000000000,
active_votes: [
{ voter: 'a' },
{ voter: 'b' },
{ voter: 'c' },
{ voter: 'd' },
{ voter: 'e' },
],
},
});
const result = parsePost(post, 'viewer', false);
Expand Down Expand Up @@ -756,41 +777,3 @@ describe('parseVote', () => {
expect(result.percent100).toBe(-50);
});
});

describe('getMutedReason', () => {
it('reports moderation ahead of the heuristics', () => {
// A moderated post by a low-reputation author must read as moderated, not low rep.
expect(getMutedReason({ stats: { gray: true }, author_reputation: 10 })).toBe(
MutedReason.MODERATED,
);
expect(getMutedReason({ stats: { hide: true }, author_reputation: 50 })).toBe(
MutedReason.MODERATED,
);
});

it('reports low reputation regardless of account age', () => {
// Age is not an input: an old account below the threshold reads the same as a new one.
expect(getMutedReason({ author_reputation: 24, created: '2019-01-01T00:00:00' })).toBe(
MutedReason.LOW_REPUTATION,
);
expect(getMutedReason({ author_reputation: 25 })).toBeNull();
});

it('needs both strong negative rshares and enough voters to call it downvoted', () => {
const votes = [{ voter: 'a' }, { voter: 'b' }, { voter: 'c' }, { voter: 'd' }];
expect(
getMutedReason({ author_reputation: 50, net_rshares: -8000000000, active_votes: votes }),
).toBe(MutedReason.DOWNVOTED);
expect(
getMutedReason({
author_reputation: 50,
net_rshares: -8000000000,
active_votes: [{ voter: 'a' }],
}),
).toBeNull();
});

it('returns null for healthy content', () => {
expect(getMutedReason({ author_reputation: 50, net_rshares: 1000 })).toBeNull();
});
});
39 changes: 5 additions & 34 deletions src/utils/postParser.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,14 @@
import { get } from 'lodash';
import { Platform } from 'react-native';
import { postBodySummary, renderPostBody, catchPostImage } from '@ecency/render-helper';
import { getContentModerationReason } from '@ecency/sdk';
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump the SDK before importing the moderation API

Every clean install remains locked to @ecency/sdk 2.3.86, which exports none of getContentModerationReason, isAuthorMuted, or ContentModerationReason; consequently yarn typecheck reports TS2305 at all three new imports and the app cannot build. Update package.json and yarn.lock to the SDK release containing these APIs as part of this change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and resolved in 32e2e6e before merge: @ecency/sdk 2.3.87 (published from ecency/vision-web#1493 under the patch:sdk label) is now pinned in both package.json and yarn.lock. The PR was open as a draft precisely because the SDK release did not exist yet.

import { Image as ExpoImage } from 'expo-image';

// Utils
import parseAsset from './parseAsset';
import { getResizedAvatar, shouldPrefetchImages } from './image';
import { parseReputation } from './user';
import { calculateVoteReward } from './vote';
import { MutedReason } from '../providers/hive/hive.types';

// Reputation below this (human-readable 0-100 scale) collapses the content. New Hive
// accounts start at 25. Account age is NOT an input, so a years-old account that never
// gained reputation trips this exactly like a fresh one, and the copy must say
// "low reputation" rather than "new account".
export const LOW_REPUTATION_THRESHOLD = 25;

// Heavily downvoted: strongly negative rshares from more than a handful of voters.
const DOWNVOTED_RSHARES_THRESHOLD = -7000000000;
const DOWNVOTED_MIN_VOTES = 3;

/**
* First matching reason wins, most authoritative first: an explicit moderator action
* outranks the heuristics. Returns null when the content is not muted.
*/
export const getMutedReason = (content: any): MutedReason | null => {
if (content?.stats?.gray || content?.stats?.hide) {
return MutedReason.MODERATED;
}
if (content?.author_reputation < LOW_REPUTATION_THRESHOLD) {
return MutedReason.LOW_REPUTATION;
}
if (
content?.net_rshares < DOWNVOTED_RSHARES_THRESHOLD &&
content?.active_votes?.length > DOWNVOTED_MIN_VOTES
) {
return MutedReason.DOWNVOTED;
}
return null;
};

export const parsePost = (
post: any,
Expand Down Expand Up @@ -164,8 +134,9 @@ export const parsePost = (

post.total_payout = totalPayout;

// set mute status
post.mutedReason = getMutedReason(post);
// set mute status. The rules live in the SDK so this app and the website flag
// the same content for the same reason.
post.mutedReason = getContentModerationReason(post);
post.isMuted = !!post.mutedReason;

// determine vote status
Expand Down Expand Up @@ -354,7 +325,7 @@ export const parseComment = (comment: any, currentUsername?: string, currentTime
);

// set mute status
comment.mutedReason = getMutedReason(comment);
comment.mutedReason = getContentModerationReason(comment);
comment.isMuted = !!comment.mutedReason;

// set user vote status on comment
Expand Down
Loading