diff --git a/package.json b/package.json index 82c6f0f500..4110ca0692 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@babel/preset-typescript": "^7.26.0", "@babel/runtime": "^7.26.7", "@ecency/render-helper": "^2.5.23", - "@ecency/sdk": "^2.3.86", + "@ecency/sdk": "^2.3.87", "@esteemapp/react-native-autocomplete-input": "^4.2.1", "@esteemapp/react-native-multi-slider": "^1.1.0", "@native-html/iframe-plugin": "^2.6.1", diff --git a/src/components/postCard/children/postCardContent.tsx b/src/components/postCard/children/postCardContent.tsx index a9c23b33f3..ed75786aa0 100644 --- a/src/components/postCard/children/postCardContent.tsx +++ b/src/components/postCard/children/postCardContent.tsx @@ -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'; @@ -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' }) diff --git a/src/components/postOptionsModal/container/postOptionsModal.tsx b/src/components/postOptionsModal/container/postOptionsModal.tsx index 9bde33eeac..379db88d55 100644 --- a/src/components/postOptionsModal/container/postOptionsModal.tsx +++ b/src/components/postOptionsModal/container/postOptionsModal.tsx @@ -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 diff --git a/src/components/postsList/container/postsListContainer.tsx b/src/components/postsList/container/postsListContainer.tsx index 1053a105e0..db9c3c039d 100644 --- a/src/components/postsList/container/postsListContainer.tsx +++ b/src/components/postsList/container/postsListContainer.tsx @@ -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 '../..'; @@ -105,11 +106,9 @@ 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); // Create Set for O(1) lookup instead of O(n) filter const existingPermlinks = new Set(_data.map((post) => `${post.author}/${post.permlink}`)); @@ -117,9 +116,8 @@ const postsListContainer = ( 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; }) : []; diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index 6ae13260c9..f2814aa1cf 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -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", diff --git a/src/providers/hive/hive.types.ts b/src/providers/hive/hive.types.ts index f728e1567b..fa852bb068 100644 --- a/src/providers/hive/hive.types.ts +++ b/src/providers/hive/hive.types.ts @@ -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'; + +/** + * 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 { diff --git a/src/utils/postParser.test.ts b/src/utils/postParser.test.ts index 5adeae6cb1..e645ca6380 100644 --- a/src/utils/postParser.test.ts +++ b/src/utils/postParser.test.ts @@ -9,7 +9,6 @@ import { parseVote, isVoted, isDownVoted, - getMutedReason, } from './postParser'; import { MutedReason } from '../providers/hive/hive.types'; @@ -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); @@ -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', @@ -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); @@ -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(); - }); -}); diff --git a/src/utils/postParser.tsx b/src/utils/postParser.tsx index fae0c2e2c0..45aed6f01f 100644 --- a/src/utils/postParser.tsx +++ b/src/utils/postParser.tsx @@ -1,6 +1,7 @@ import { get } from 'lodash'; import { Platform } from 'react-native'; import { postBodySummary, renderPostBody, catchPostImage } from '@ecency/render-helper'; +import { getContentModerationReason } from '@ecency/sdk'; import { Image as ExpoImage } from 'expo-image'; // Utils @@ -8,37 +9,6 @@ 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, @@ -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 @@ -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 diff --git a/yarn.lock b/yarn.lock index 3ffa3f689a..c1d7df5ea7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1210,10 +1210,10 @@ url "^0.11.0" xss "^1.0.9" -"@ecency/sdk@^2.3.86": - version "2.3.86" - resolved "https://registry.yarnpkg.com/@ecency/sdk/-/sdk-2.3.86.tgz#2a4e3f1a66a28f4d86f28bc35e4e4e560e5fcb88" - integrity sha512-8rMM+rcQJqNkiqLHR7SBM0hVWeM/2+FV6QyQFrEwphxQI7Q9j1MMEsfmrWxR6fxcUqR66CGGNji5eI3cWrGCag== +"@ecency/sdk@^2.3.87": + version "2.3.87" + resolved "https://registry.yarnpkg.com/@ecency/sdk/-/sdk-2.3.87.tgz#1701b80539c79349b34e8a284e9e5c0a8a82a356" + integrity sha512-99QQSqTREAwU5jsyR83aUk/YSkEozkskJEUgXlS6NzPpvZHwDOeN5+i5DDzrpfllt5T9abeT9+Ii7WOU9SPLuw== dependencies: "@noble/ciphers" "^2.1.1" "@noble/curves" "^2.0.1"