From b154d3ea21bcca48594aa397420b0f6718dbf7f3 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 3 Aug 2023 22:08:30 -0700 Subject: [PATCH] Labeling & moderation updates [DRAFT] (#1057) * First pass moving to the new labeling sdk (it compiles) * Correct behaviors around interpreting label moderation * Improve moderation state rendering * Improve hiders and alerts * Improve handling of mutes * Improve profile warnings * Add profile blurring to profile header * Add blocks to test cases * Render labels on profile cards, do not filter * Filter profiles from suggestions using moderation * Apply profile blurring to ProfileCard * Handle blocked and deleted quote posts * Temporarily translate content filtering settings to new labels * Fix types * Tune ContentHider & PostHider click targets * Put a warning on profilecard label pills * Fix screenhider learnmore link on mobile * Enforce no-override on user avatar * Dont enumerate profile blur-media labels in alerts * Fixes to muted posts (esp quotes of muted users) * Fixes to account/profile warnings * Bump @atproto/api@0.5.0 * Bump @atproto/api@0.5.1 * Fix tests * 1.43 * Remove log * Bump @atproto/api@0.5.2 --- __e2e__/mock-server.ts | 215 ++++++++++++++---- __e2e__/tests/profile-screen.test.ts | 8 +- app.json | 4 +- jest/test-pds.ts | 3 + package.json | 4 +- src/lib/api/hack-add-deleted-embed.ts | 24 ++ src/lib/moderation.ts | 77 +++++++ src/lib/strings/display-names.ts | 11 +- src/state/models/content/post-thread-item.ts | 6 +- src/state/models/content/post-thread.ts | 56 +++-- src/state/models/content/profile.ts | 26 +-- src/state/models/discovery/foafs.ts | 8 + .../models/discovery/suggested-actors.ts | 9 +- src/state/models/feeds/notifications.ts | 47 ++-- src/state/models/feeds/post.ts | 42 +--- src/state/models/feeds/posts-slice.ts | 16 +- src/state/models/ui/preferences.ts | 55 ++++- src/state/models/ui/shell.ts | 9 +- src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/com/modals/ModerationDetails.tsx | 101 ++++++++ src/view/com/notifications/FeedItem.tsx | 22 +- src/view/com/post-thread/PostThreadItem.tsx | 90 +++++--- src/view/com/post/Post.tsx | 31 ++- src/view/com/posts/FeedItem.tsx | 48 ++-- src/view/com/posts/FeedSlice.tsx | 99 ++++---- src/view/com/profile/ProfileCard.tsx | 93 +++++--- src/view/com/profile/ProfileHeader.tsx | 74 +----- src/view/com/util/UserAvatar.tsx | 22 +- src/view/com/util/UserBanner.tsx | 4 +- src/view/com/util/moderation/ContentHider.tsx | 120 +++++----- src/view/com/util/moderation/ImageHider.tsx | 80 ------- src/view/com/util/moderation/PostAlerts.tsx | 68 ++++++ src/view/com/util/moderation/PostHider.tsx | 131 ++++++----- .../util/moderation/ProfileHeaderAlerts.tsx | 76 +++++++ .../util/moderation/ProfileHeaderWarnings.tsx | 44 ---- src/view/com/util/moderation/ScreenHider.tsx | 57 +++-- src/view/com/util/post-embeds/QuoteEmbed.tsx | 68 +++++- src/view/com/util/post-embeds/index.tsx | 41 +--- .../screens/ModerationBlockedAccounts.tsx | 1 - src/view/screens/ModerationMutedAccounts.tsx | 1 - src/view/screens/Profile.tsx | 4 +- yarn.lock | 8 +- 43 files changed, 1193 insertions(+), 717 deletions(-) create mode 100644 src/lib/api/hack-add-deleted-embed.ts create mode 100644 src/lib/moderation.ts create mode 100644 src/view/com/modals/ModerationDetails.tsx delete mode 100644 src/view/com/util/moderation/ImageHider.tsx create mode 100644 src/view/com/util/moderation/PostAlerts.tsx create mode 100644 src/view/com/util/moderation/ProfileHeaderAlerts.tsx delete mode 100644 src/view/com/util/moderation/ProfileHeaderWarnings.tsx diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index f553fc68..79c31857 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -75,26 +75,35 @@ async function main() { ) for (const user of [ - 'csam-account', - 'csam-profile', - 'csam-posts', + 'dmca-account', + 'dmca-profile', + 'dmca-posts', 'porn-account', 'porn-profile', 'porn-posts', 'nudity-account', 'nudity-profile', 'nudity-posts', + 'scam-account', + 'scam-profile', + 'scam-posts', 'unknown-account', 'unknown-profile', 'unknown-posts', - 'always-filter-account', - 'always-filter-profile', - 'always-filter-posts', - 'always-warn-account', - 'always-warn-profile', - 'always-warn-posts', + 'hide-account', + 'hide-profile', + 'hide-posts', + 'no-promote-account', + 'no-promote-profile', + 'no-promote-posts', + 'warn-account', + 'warn-profile', + 'warn-posts', 'muted-account', 'muted-by-list-account', + 'blocking-account', + 'blockedby-account', + 'mutual-block-account', ]) { await server.mocker.createUser(user) await server.mocker.follow('alice', user) @@ -108,25 +117,25 @@ async function main() { await server.mocker.like(user, anchorPost) } - await server.mocker.labelAccount('csam', 'csam-account') - await server.mocker.labelProfile('csam', 'csam-profile') + await server.mocker.labelAccount('dmca-violation', 'dmca-account') + await server.mocker.labelProfile('dmca-violation', 'dmca-profile') await server.mocker.labelPost( - 'csam', - await server.mocker.createPost('csam-posts', 'csam post'), + 'dmca-violation', + await server.mocker.createPost('dmca-posts', 'dmca post'), ) await server.mocker.labelPost( - 'csam', + 'dmca-violation', await server.mocker.createQuotePost( - 'csam-posts', - 'csam quote post', + 'dmca-posts', + 'dmca quote post', anchorPost, ), ) await server.mocker.labelPost( - 'csam', + 'dmca-violation', await server.mocker.createReply( - 'csam-posts', - 'csam reply', + 'dmca-posts', + 'dmca reply', anchorPost, ), ) @@ -177,6 +186,29 @@ async function main() { ), ) + await server.mocker.labelAccount('scam', 'scam-account') + await server.mocker.labelProfile('scam', 'scam-profile') + await server.mocker.labelPost( + 'scam', + await server.mocker.createPost('scam-posts', 'scam post'), + ) + await server.mocker.labelPost( + 'scam', + await server.mocker.createQuotePost( + 'scam-posts', + 'scam quote post', + anchorPost, + ), + ) + await server.mocker.labelPost( + 'scam', + await server.mocker.createReply( + 'scam-posts', + 'scam reply', + anchorPost, + ), + ) + await server.mocker.labelAccount( 'not-a-real-label', 'unknown-account', @@ -206,54 +238,74 @@ async function main() { ), ) - await server.mocker.labelAccount('!filter', 'always-filter-account') - await server.mocker.labelProfile('!filter', 'always-filter-profile') + await server.mocker.labelAccount('!hide', 'hide-account') + await server.mocker.labelProfile('!hide', 'hide-profile') await server.mocker.labelPost( - '!filter', - await server.mocker.createPost( - 'always-filter-posts', - 'always-filter post', - ), + '!hide', + await server.mocker.createPost('hide-posts', 'hide post'), ) await server.mocker.labelPost( - '!filter', + '!hide', await server.mocker.createQuotePost( - 'always-filter-posts', - 'always-filter quote post', + 'hide-posts', + 'hide quote post', anchorPost, ), ) await server.mocker.labelPost( - '!filter', + '!hide', await server.mocker.createReply( - 'always-filter-posts', - 'always-filter reply', + 'hide-posts', + 'hide reply', anchorPost, ), ) - await server.mocker.labelAccount('!warn', 'always-warn-account') - await server.mocker.labelProfile('!warn', 'always-warn-profile') + await server.mocker.labelAccount('!no-promote', 'no-promote-account') + await server.mocker.labelProfile('!no-promote', 'no-promote-profile') + await server.mocker.labelPost( + '!no-promote', + await server.mocker.createPost( + 'no-promote-posts', + 'no-promote post', + ), + ) + await server.mocker.labelPost( + '!no-promote', + await server.mocker.createQuotePost( + 'no-promote-posts', + 'no-promote quote post', + anchorPost, + ), + ) + await server.mocker.labelPost( + '!no-promote', + await server.mocker.createReply( + 'no-promote-posts', + 'no-promote reply', + anchorPost, + ), + ) + + await server.mocker.labelAccount('!warn', 'warn-account') + await server.mocker.labelProfile('!warn', 'warn-profile') await server.mocker.labelPost( '!warn', - await server.mocker.createPost( - 'always-warn-posts', - 'always-warn post', - ), + await server.mocker.createPost('warn-posts', 'warn post'), ) await server.mocker.labelPost( '!warn', await server.mocker.createQuotePost( - 'always-warn-posts', - 'always-warn quote post', + 'warn-posts', + 'warn quote post', anchorPost, ), ) await server.mocker.labelPost( '!warn', await server.mocker.createReply( - 'always-warn-posts', - 'always-warn reply', + 'warn-posts', + 'warn reply', anchorPost, ), ) @@ -291,6 +343,85 @@ async function main() { 'account reply', anchorPost, ) + + await server.mocker.createPost('blocking-account', 'blocking post') + await server.mocker.createQuotePost( + 'blocking-account', + 'blocking quote post', + anchorPost, + ) + await server.mocker.createReply( + 'blocking-account', + 'blocking reply', + anchorPost, + ) + await server.mocker.users.alice.agent.app.bsky.graph.block.create( + { + repo: server.mocker.users.alice.did, + }, + { + subject: server.mocker.users['blocking-account'].did, + createdAt: new Date().toISOString(), + }, + ) + + await server.mocker.createPost('blockedby-account', 'blockedby post') + await server.mocker.createQuotePost( + 'blockedby-account', + 'blockedby quote post', + anchorPost, + ) + await server.mocker.createReply( + 'blockedby-account', + 'blockedby reply', + anchorPost, + ) + await server.mocker.users[ + 'blockedby-account' + ].agent.app.bsky.graph.block.create( + { + repo: server.mocker.users['blockedby-account'].did, + }, + { + subject: server.mocker.users.alice.did, + createdAt: new Date().toISOString(), + }, + ) + + await server.mocker.createPost( + 'mutual-block-account', + 'mutual-block post', + ) + await server.mocker.createQuotePost( + 'mutual-block-account', + 'mutual-block quote post', + anchorPost, + ) + await server.mocker.createReply( + 'mutual-block-account', + 'mutual-block reply', + anchorPost, + ) + await server.mocker.users.alice.agent.app.bsky.graph.block.create( + { + repo: server.mocker.users.alice.did, + }, + { + subject: server.mocker.users['mutual-block-account'].did, + createdAt: new Date().toISOString(), + }, + ) + await server.mocker.users[ + 'mutual-block-account' + ].agent.app.bsky.graph.block.create( + { + repo: server.mocker.users['mutual-block-account'].did, + }, + { + subject: server.mocker.users.alice.did, + createdAt: new Date().toISOString(), + }, + ) } } console.log('Ready') diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts index 6c6d6db9..7d2b5c36 100644 --- a/__e2e__/tests/profile-screen.test.ts +++ b/__e2e__/tests/profile-screen.test.ts @@ -53,7 +53,7 @@ describe('Profile screen', () => { await expect(element(by.id('profileHeaderDisplayName'))).toHaveText( 'alice.test', ) - await expect(element(by.id('profileHeaderDescription'))).toHaveText('') + await expect(element(by.id('profileHeaderDescription'))).not.toExist() }) it('Set avi and banner via the edit profile modal', async () => { @@ -107,13 +107,13 @@ describe('Profile screen', () => { }) it('Can mute/unmute another user', async () => { - await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist() + await expect(element(by.id('profileHeaderAlert'))).not.toExist() await element(by.id('profileHeaderDropdownBtn')).tap() await element(by.text('Mute Account')).tap() - await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible() + await expect(element(by.id('profileHeaderAlert'))).toBeVisible() await element(by.id('profileHeaderDropdownBtn')).tap() await element(by.text('Unmute Account')).tap() - await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist() + await expect(element(by.id('profileHeaderAlert'))).not.toExist() }) it('Can report another user', async () => { diff --git a/app.json b/app.json index e32cd9ca..cf3af39d 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "bluesky", "scheme": "bluesky", "owner": "blueskysocial", - "version": "1.42.0", + "version": "1.43.0", "runtimeVersion": { "policy": "appVersion" }, @@ -43,7 +43,7 @@ "backgroundColor": "#ffffff" }, "android": { - "versionCode": 28, + "versionCode": 29, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 34c3e8bc..21d90b93 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -146,6 +146,7 @@ class Mocker { } return await agent.post({ text, + langs: ['en'], createdAt: new Date().toISOString(), }) } @@ -162,6 +163,7 @@ class Mocker { return await agent.post({ text, embed: {$type: 'app.bsky.embed.record', record: {uri, cid}}, + langs: ['en'], createdAt: new Date().toISOString(), }) } @@ -178,6 +180,7 @@ class Mocker { return await agent.post({ text, reply: {root: {uri, cid}, parent: {uri, cid}}, + langs: ['en'], createdAt: new Date().toISOString(), }) } diff --git a/package.json b/package.json index 05b08b3d..1da54bb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.42.0", + "version": "1.43.0", "private": true, "scripts": { "prepare": "is-ci || husky install", @@ -24,7 +24,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "^0.4.3", + "@atproto/api": "^0.5.2", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/html-elements": "^0.4.2", diff --git a/src/lib/api/hack-add-deleted-embed.ts b/src/lib/api/hack-add-deleted-embed.ts new file mode 100644 index 00000000..59aad21a --- /dev/null +++ b/src/lib/api/hack-add-deleted-embed.ts @@ -0,0 +1,24 @@ +import { + AppBskyFeedDefs, + AppBskyFeedPost, + ComAtprotoRepoStrongRef, +} from '@atproto/api' + +/** + * HACK + * The server doesnt seem to be correctly giving the notFound view yet + * so I'm adding it manually for now + * -prf + */ +export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) { + const record = post.record as AppBskyFeedPost.Record + if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) { + post.embed = { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewNotFound', + uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri, + }, + } + } +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 00000000..758e3de3 --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,77 @@ +import {ModerationCause, ProfileModeration} from '@atproto/api' + +export interface ModerationCauseDescription { + name: string + description: string +} + +export function describeModerationCause( + cause: ModerationCause | undefined, + context: 'account' | 'content', +): ModerationCauseDescription { + if (!cause) { + return { + name: 'Content Warning', + description: + 'Moderator has chosen to set a general warning on the content.', + } + } + if (cause.type === 'blocking') { + return { + name: 'Blocked User', + description: 'You have blocked this user. You cannot view their content.', + } + } + if (cause.type === 'blocked-by') { + return { + name: 'Blocking You', + description: 'This user has blocked you. You cannot view their content.', + } + } + if (cause.type === 'muted') { + if (cause.source.type === 'user') { + return { + name: context === 'account' ? 'Muted User' : 'Post by muted user', + description: 'You have muted this user', + } + } else { + return { + name: + context === 'account' + ? `Muted by "${cause.source.list.name}"` + : `Post by muted user ("${cause.source.list.name}")`, + description: 'You have muted this user', + } + } + } + return cause.labelDef.strings[context].en +} + +export function getProfileModerationCauses( + moderation: ProfileModeration, +): ModerationCause[] { + /* + Gather everything on profile and account that blurs or alerts + */ + return [ + moderation.decisions.profile.cause, + ...moderation.decisions.profile.additionalCauses, + moderation.decisions.account.cause, + ...moderation.decisions.account.additionalCauses, + ].filter(cause => { + if (!cause) { + return false + } + if (cause?.type === 'label') { + if ( + cause.labelDef.onwarn === 'blur' || + cause.labelDef.onwarn === 'alert' + ) { + return true + } else { + return false + } + } + return true + }) as ModerationCause[] +} diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index b9815373..29b7c3b5 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -1,10 +1,19 @@ +import {ModerationUI} from '@atproto/api' +import {describeModerationCause} from '../moderation' + // \u2705 = ✅ // \u2713 = ✓ // \u2714 = ✔ // \u2611 = ☑ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu -export function sanitizeDisplayName(str: string): string { +export function sanitizeDisplayName( + str: string, + moderation?: ModerationUI, +): string { + if (moderation?.blur) { + return `⚠${describeModerationCause(moderation.cause, 'account').name}` + } if (typeof str === 'string') { return str.replace(CHECK_MARKS_RE, '').trim() } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 14aa607e..141b4f93 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -3,9 +3,9 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import {PostsFeedItemModel} from '../feeds/post' type PostView = AppBskyFeedDefs.PostView @@ -67,10 +67,6 @@ export class PostThreadItemModel { return this.data.isThreadMuted } - get labelInfo(): PostLabelInfo { - return this.data.labelInfo - } - get moderation(): PostModeration { return this.data.moderation } diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index c500174a..85ed13cb 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedDefs, + PostModeration, } from '@atproto/api' import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' @@ -231,7 +232,6 @@ export class PostThreadModel { return } pruneReplies(res.data.thread) - sortThread(res.data.thread) const thread = new PostThreadItemModel( this.rootStore, res.data.thread as AppBskyFeedDefs.ThreadViewPost, @@ -241,6 +241,7 @@ export class PostThreadModel { res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) + sortThread(thread) this.thread = thread } } @@ -262,24 +263,28 @@ function pruneReplies(post: MaybePost) { } } -function sortThread(post: MaybePost) { - if (post.notFound) { +type MaybeThreadItem = + | PostThreadItemModel + | AppBskyFeedDefs.NotFoundPost + | AppBskyFeedDefs.BlockedPost +function sortThread(item: MaybeThreadItem) { + if ('notFound' in item) { return } - post = post as AppBskyFeedDefs.ThreadViewPost - if (post.replies) { - post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as AppBskyFeedDefs.ThreadViewPost - if (a.notFound) { + item = item as PostThreadItemModel + if (item.replies) { + item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => { + if ('notFound' in a && a.notFound) { return 1 } - if (b.notFound) { + if ('notFound' in b && b.notFound) { return -1 } - a = a as AppBskyFeedDefs.ThreadViewPost - b = b as AppBskyFeedDefs.ThreadViewPost - const aIsByOp = a.post.author.did === post.post.author.did - const bIsByOp = b.post.author.did === post.post.author.did + item = item as PostThreadItemModel + a = a as PostThreadItemModel + b = b as PostThreadItemModel + const aIsByOp = a.post.author.did === item.post.author.did + const bIsByOp = b.post.author.did === item.post.author.did if (aIsByOp && bIsByOp) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsByOp) { @@ -287,8 +292,31 @@ function sortThread(post: MaybePost) { } else if (bIsByOp) { return 1 // op's own reply } + // put moderated content down at the bottom + if (modScore(a.moderation) !== modScore(b.moderation)) { + return modScore(a.moderation) - modScore(b.moderation) + } return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest }) - post.replies.forEach(reply => sortThread(reply)) + item.replies.forEach(reply => sortThread(reply)) } } + +function modScore(mod: PostModeration): number { + if (mod.content.blur && mod.content.noOverride) { + return 5 + } + if (mod.content.blur) { + return 4 + } + if (mod.content.alert) { + return 3 + } + if (mod.embed.blur && mod.embed.noOverride) { + return 2 + } + if (mod.embed.blur) { + return 1 + } + return 0 +} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 2ea4ada6..26fa6008 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -6,18 +6,14 @@ import { AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, + moderateProfile, + ProfileModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {FollowState} from '../cache/my-follows' import {Image as RNImage} from 'react-native-image-crop-picker' -import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types' -import { - getProfileModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' export class ProfileViewerModel { @@ -26,7 +22,8 @@ export class ProfileViewerModel { following?: string followedBy?: string blockedBy?: boolean - blocking?: string + blocking?: string; + [key: string]: unknown constructor() { makeAutoObservable(this) @@ -53,7 +50,8 @@ export class ProfileModel { followsCount: number = 0 postsCount: number = 0 labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel() + viewer = new ProfileViewerModel(); + [key: string]: unknown // added data descriptionRichText?: RichText = new RichText({text: ''}) @@ -85,18 +83,8 @@ export class ProfileModel { return this.hasLoaded && !this.hasContent } - get labelInfo(): ProfileLabelInfo { - return { - accountLabels: filterAccountLabels(this.labels), - profileLabels: filterProfileLabels(this.labels), - isMuted: this.viewer?.muted || false, - isBlocking: !!this.viewer?.blocking || false, - isBlockedBy: !!this.viewer?.blockedBy || false, - } - } - get moderation(): ProfileModeration { - return getProfileModeration(this.rootStore, this.labelInfo) + return moderateProfile(this, this.rootStore.preferences.moderationOpts) } // public api diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 4b25ed4a..4bcb2bdd 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,6 +1,7 @@ import { AppBskyActorDefs, AppBskyGraphGetFollows as GetFollows, + moderateProfile, } from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' @@ -52,6 +53,13 @@ export class FoafsModel { cursor, limit: 100, }) + res.data.follows = res.data.follows.filter( + profile => + !moderateProfile( + profile, + this.rootStore.preferences.moderationOpts, + ).account.filter, + ) this.rootStore.me.follows.hydrateProfiles(res.data.follows) if (!res.data.cursor) { break diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 50faae61..533e14ea 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' @@ -69,7 +69,12 @@ export class SuggestedActorsModel { limit: 25, cursor: this.loadMoreCursor, }) - const {actors, cursor} = res.data + let {actors, cursor} = res.data + actors = actors.filter( + actor => + !moderateProfile(actor, this.rootStore.preferences.moderationOpts) + .account.filter, + ) this.rootStore.me.follows.hydrateProfiles(actors) runInAction(() => { diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index b7ac3a53..5f170062 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -8,6 +8,8 @@ import { AppBskyFeedLike, AppBskyGraphFollow, ComAtprotoLabelDefs, + moderatePost, + moderateProfile, } from '@atproto/api' import AwaitLock from 'await-lock' import chunk from 'lodash.chunk' @@ -15,16 +17,6 @@ import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {PostThreadModel} from '../content/post-thread' import {cleanError} from 'lib/strings/errors' -import { - PostLabelInfo, - PostModeration, - ModerationBehaviorCode, -} from 'lib/labeling/types' -import { - getPostModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 @@ -100,27 +92,19 @@ export class NotificationsFeedItemModel { } } - get labelInfo(): PostLabelInfo { - const addedInfo = this.additionalPost?.thread?.labelInfo - return { - postLabels: (this.labels || []).concat(addedInfo?.postLabels || []), - accountLabels: filterAccountLabels(this.author.labels).concat( - addedInfo?.accountLabels || [], - ), - profileLabels: filterProfileLabels(this.author.labels).concat( - addedInfo?.profileLabels || [], - ), - isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, - mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, - isBlocking: - !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, - isBlockedBy: - !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false, + get shouldFilter(): boolean { + if (this.additionalPost?.thread) { + const postMod = moderatePost( + this.additionalPost.thread.data.post, + this.rootStore.preferences.moderationOpts, + ) + return postMod.content.filter || false } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) + const profileMod = moderateProfile( + this.author, + this.rootStore.preferences.moderationOpts, + ) + return profileMod.account.filter || false } get numUnreadInGroup(): number { @@ -565,8 +549,7 @@ export class NotificationsFeedModel { ): NotificationsFeedItemModel[] { return items .filter(item => { - const hideByLabel = - item.moderation.list.behavior === ModerationBehaviorCode.Hide + const hideByLabel = item.shouldFilter let mutedThread = !!( item.reasonSubjectRootUri && this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index 47039c72..68cc3de4 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -3,21 +3,13 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + moderatePost, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - filterAccountLabels, - filterProfileLabels, - getPostModeration, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' +import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed' type FeedViewPost = AppBskyFeedDefs.FeedViewPost type ReasonRepost = AppBskyFeedDefs.ReasonRepost @@ -44,6 +36,7 @@ export class PostsFeedItemModel { if (FeedPost.isRecord(this.post.record)) { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { + hackAddDeletedEmbed(this.post) this.postRecord = this.post.record this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { @@ -86,33 +79,8 @@ export class PostsFeedItemModel { return this.rootStore.mutedThreads.uris.has(this.rootUri) } - get labelInfo(): PostLabelInfo { - return { - postLabels: (this.post.labels || []).concat( - getEmbedLabels(this.post.embed), - ), - accountLabels: filterAccountLabels(this.post.author.labels), - profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: - this.post.author.viewer?.muted || - getEmbedMuted(this.post.embed) || - false, - mutedByList: - this.post.author.viewer?.mutedByList || - getEmbedMutedByList(this.post.embed), - isBlocking: - !!this.post.author.viewer?.blocking || - getEmbedBlocking(this.post.embed) || - false, - isBlockedBy: - !!this.post.author.viewer?.blockedBy || - getEmbedBlockedBy(this.post.embed) || - false, - } - } - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) + return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } copy(v: FeedViewPost) { diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts index 239bc5b6..c02faed3 100644 --- a/src/state/models/feeds/posts-slice.ts +++ b/src/state/models/feeds/posts-slice.ts @@ -1,7 +1,6 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {FeedViewPostsSlice} from 'lib/api/feed-manip' -import {mergePostModerations} from 'lib/labeling/helpers' import {PostsFeedItemModel} from './post' let _idCounter = 0 @@ -55,7 +54,20 @@ export class PostsFeedSliceModel { } get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) + // prefer the most stringent item + const topItem = this.items.find(item => item.moderation.content.filter) + if (topItem) { + return topItem.moderation + } + // otherwise just use the first one + return this.items[0].moderation + } + + shouldFilter(ignoreFilterForDid: string | undefined): boolean { + const mods = this.items + .filter(item => item.post.author.did !== ignoreFilterForDid) + .map(item => item.moderation) + return !!mods.find(mod => mod.content.filter) } containsUri(uri: string) { diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index e1c0b1f7..a892d8d3 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,9 +1,14 @@ import {makeAutoObservable, runInAction} from 'mobx' +import {LabelPreference as APILabelPreference} from '@atproto/api' import AwaitLock from 'await-lock' import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' -import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' +import { + ComAtprotoLabelDefs, + AppBskyActorDefs, + ModerationOpts, +} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS, deviceLocales} from 'platform/detection' import {LANGUAGES} from '../../../locale/languages' -export type LabelPreference = 'show' | 'warn' | 'hide' +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +export type LabelPreference = APILabelPreference | 'show' const LABEL_GROUPS = [ 'nsfw', 'nudity', @@ -408,6 +414,43 @@ export class PreferencesModel { return res } + get moderationOpts(): ModerationOpts { + return { + userDid: this.rootStore.session.currentSession?.did || '', + adultContentEnabled: this.adultContentEnabled, + labelerSettings: [ + { + labeler: { + did: '', + displayName: 'Bluesky Social', + }, + settings: { + // TEMP translate old settings until this UI can be migrated -prf + porn: tempfixLabelPref(this.contentLabels.nsfw), + sexual: tempfixLabelPref(this.contentLabels.suggestive), + nudity: tempfixLabelPref(this.contentLabels.nudity), + nsfl: tempfixLabelPref(this.contentLabels.gore), + corpse: tempfixLabelPref(this.contentLabels.gore), + gore: tempfixLabelPref(this.contentLabels.gore), + torture: tempfixLabelPref(this.contentLabels.gore), + 'self-harm': tempfixLabelPref(this.contentLabels.gore), + 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-sexual-orientation': tempfixLabelPref( + this.contentLabels.hate, + ), + 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), + intolerant: tempfixLabelPref(this.contentLabels.hate), + 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), + spam: tempfixLabelPref(this.contentLabels.spam), + impersonation: tempfixLabelPref(this.contentLabels.impersonation), + scam: 'warn', + }, + }, + ], + } + } + async setSavedFeeds(saved: string[], pinned: string[]) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds @@ -485,3 +528,11 @@ export class PreferencesModel { this.requireAltTextEnabled = !this.requireAltTextEnabled } } + +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +function tempfixLabelPref(pref: LabelPreference): APILabelPreference { + if (pref === 'show') { + return 'ignore' + } + return pref +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index e33a34ac..47627759 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,4 @@ -import {AppBskyEmbedRecord} from '@atproto/api' +import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' @@ -42,6 +42,12 @@ export interface ServerInputModal { onSelect: (url: string) => void } +export interface ModerationDetailsModal { + name: 'moderation-details' + context: 'account' | 'content' + moderation: ModerationUI +} + export interface ReportPostModal { name: 'report-post' postUri: string @@ -146,6 +152,7 @@ export type Modal = | PreferencesHomeFeed // Moderation + | ModerationDetailsModal | ReportAccountModal | ReportPostModal | CreateOrEditMuteListModal diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 00d06161..46949297 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -30,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -136,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'onboarding') { snapPoints = OnboardingModal.snapPoints element = + } else if (activeModal?.name === 'moderation-details') { + snapPoints = ModerationDetailsModal.snapPoints + element = } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 39cdbd86..df13dfed 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' import * as PreferencesHomeFeed from './PreferencesHomeFeed' @@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'onboarding') { element = + } else if (modal.name === 'moderation-details') { + element = } else { return null } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx new file mode 100644 index 00000000..abeb2fdf --- /dev/null +++ b/src/view/com/modals/ModerationDetails.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {listUriToHref} from 'lib/strings/url-helpers' +import {Button} from '../util/forms/Button' + +export const snapPoints = [300] + +export function Component({ + context, + moderation, +}: { + context: 'account' | 'content' + moderation: ModerationUI +}) { + const store = useStores() + const pal = usePalette('default') + + let name + let description + if (!moderation.cause) { + name = 'Content Warning' + description = + 'Moderator has chosen to set a general warning on the content.' + } else if (moderation.cause.type === 'blocking') { + name = 'Account Blocked' + description = 'You have blocked this user. You cannot view their content.' + } else if (moderation.cause.type === 'blocked-by') { + name = 'Account Blocks You' + description = 'This user has blocked you. You cannot view their content.' + } else if (moderation.cause.type === 'muted') { + if (moderation.cause.source.type === 'user') { + name = 'Account Muted' + description = 'You have muted this user.' + } else { + const list = moderation.cause.source.list + name = <>Account Muted by List + description = ( + <> + This user is included the{' '} + {' '} + list which you have muted. + + ) + } + } else { + name = moderation.cause.labelDef.strings[context].en.name + description = moderation.cause.labelDef.strings[context].en.description + } + + return ( + + + {name} + + + {description} + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: isDesktopWeb ? 0 : 14, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + marginBottom: 12, + }, + description: { + textAlign: 'center', + }, + btn: { + paddingVertical: 14, + marginTop: isDesktopWeb ? 40 : 0, + marginBottom: isDesktopWeb ? 0 : 40, + }, +}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 7b9f0715..ce9f5bc0 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -7,7 +7,11 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyEmbedImages} from '@atproto/api' +import { + AppBskyEmbedImages, + ProfileModeration, + moderateProfile, +} from '@atproto/api' import {AtUri} from '@atproto/api' import { FontAwesomeIcon, @@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ProfileModeration} from 'lib/labeling/types' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' @@ -99,9 +98,9 @@ export const FeedItem = observer(function ({ handle: item.author.handle, displayName: item.author.displayName, avatar: item.author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(item.author), + moderation: moderateProfile( + item.author, + store.preferences.moderationOpts, ), }, ...(item.additional?.map(({author}) => { @@ -111,10 +110,7 @@ export const FeedItem = observer(function ({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(author), - ), + moderation: moderateProfile(author, store.preferences.moderationOpts), } }) || []), ] diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index edf8d774..b5469c6f 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -26,7 +26,7 @@ import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {PostSandboxWarning} from '../util/PostSandboxWarning' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' @@ -159,10 +159,9 @@ export const PostThreadItem = observer(function PostThreadItem({ if (item._isHighlightedPost) { return ( - + style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}> @@ -227,7 +226,16 @@ export const PostThreadItem = observer(function PostThreadItem({ - + + {item.richText?.text ? ( ) : undefined} - - - + {item.post.embed && ( + + + + )} - + ) } else { return ( @@ -325,7 +335,7 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.view, item._showParentReplyLine && styles.noTopBorder, ]} - moderation={item.moderation.thread}> + moderation={item.moderation.content}> {item._showParentReplyLine && ( - - {item.richText?.text ? ( - - - - ) : undefined} - + + {item.richText?.text ? ( + + + + ) : undefined} + {item.post.embed && ( + - - {needsTranslation && ( - - - - Translate this post - - - - )} - + + )} + {needsTranslation && ( + + + + Translate this post + + + + )} + {showReplyLine && } @@ -251,8 +247,13 @@ const PostLoaded = observer( )} + moderation={item.moderation.content} + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + {item.richText?.text ? ( ) : undefined} - + - + {needsTranslation && ( @@ -302,7 +303,7 @@ const PostLoaded = observer( /> - + ) }, ) @@ -323,6 +324,9 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', @@ -341,6 +345,9 @@ const styles = StyleSheet.create({ borderLeftColor: colors.gray2, }, contentHider: { - marginTop: 4, + marginBottom: 6, + }, + contentHiderChild: { + marginTop: 6, }, }) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 75c32114..9d2bc72b 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,16 +8,14 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {PostsFeedItemModel} from 'state/models/feeds/post' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' import * as Toast from '../util/Toast' @@ -35,13 +33,11 @@ export const FeedItem = observer(function ({ item, isThreadChild, isThreadParent, - ignoreMuteFor, }: { item: PostsFeedItemModel isThreadChild?: boolean isThreadParent?: boolean showReplyLine?: boolean - ignoreMuteFor?: string }) { const store = useStores() const pal = usePalette('default') @@ -147,26 +143,17 @@ export const FeedItem = observer(function ({ isThreadParent ? styles.outerNoBottom : undefined, ] - // moderation override - let moderation = item.moderation.list - if ( - ignoreMuteFor === item.post.author.did && - moderation.isMute && - !moderation.noOverride - ) { - moderation = {behavior: ModerationBehaviorCode.Show} - } - if (!record || deleted) { return } return ( - + noFeedback + accessible={false}> {isThreadChild && ( )} + moderation={item.moderation.content} + ignoreMute + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + {item.richText?.text ? ( ) : undefined} - + - + {needsTranslation && ( @@ -306,7 +301,7 @@ export const FeedItem = observer(function ({ /> - + ) }) @@ -358,6 +353,10 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginTop: 6, + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', @@ -365,7 +364,10 @@ const styles = StyleSheet.create({ paddingBottom: 4, }, contentHider: { - marginTop: 4, + marginBottom: 6, + }, + contentHiderChild: { + marginTop: 6, }, embed: { marginBottom: 6, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index b73d4a99..6345f777 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' import {AtUri} from '@atproto/api' import {Link} from '../util/Link' @@ -7,65 +8,61 @@ import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' -export function FeedSlice({ - slice, - ignoreMuteFor, -}: { - slice: PostsFeedSliceModel - ignoreMuteFor?: string -}) { - if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { - if (!ignoreMuteFor && !slice.moderation.list.noOverride) { +export const FeedSlice = observer( + ({ + slice, + ignoreFilterFor, + }: { + slice: PostsFeedSliceModel + ignoreFilterFor?: string + }) => { + if (slice.shouldFilter(ignoreFilterFor)) { return null } - } - if (slice.isThread && slice.items.length > 3) { - const last = slice.items.length - 1 + + if (slice.isThread && slice.items.length > 3) { + const last = slice.items.length - 1 + return ( + <> + + + + + + ) + } + return ( <> - - - - + {slice.items.map((item, i) => ( + + ))} ) - } - - return ( - <> - {slice.items.map((item, i) => ( - - ))} - - ) -} + }, +) function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const pal = usePalette('default') diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 946e0f2a..ba0c59de 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + moderateProfile, + ProfileModeration, +} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -11,12 +15,11 @@ import {useStores} from 'state/index' import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' +import { + describeModerationCause, + getProfileModerationCauses, +} from 'lib/moderation' export const ProfileCard = observer( ({ @@ -25,7 +28,6 @@ export const ProfileCard = observer( noBg, noBorder, followers, - overrideModeration, renderButton, }: { testID?: string @@ -33,7 +35,6 @@ export const ProfileCard = observer( noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - overrideModeration?: boolean renderButton?: ( profile: AppBskyActorDefs.ProfileViewBasic, ) => React.ReactNode @@ -41,18 +42,11 @@ export const ProfileCard = observer( const store = useStores() const pal = usePalette('default') - const moderation = getProfileModeration( - store, - getProfileViewBasicLabelInfo(profile), + const moderation = moderateProfile( + profile, + store.preferences.moderationOpts, ) - if ( - moderation.list.behavior === ModerationBehaviorCode.Hide && - !overrideModeration - ) { - return null - } - return ( {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} {sanitizeHandle(profile.handle, '@')} - {!!profile.viewer?.followedBy && ( - - - - Follows You - - - - )} + + {!!profile.viewer?.followedBy && } {renderButton ? ( {renderButton(profile)} @@ -114,6 +105,44 @@ export const ProfileCard = observer( }, ) +function ProfileCardPills({ + followedBy, + moderation, +}: { + followedBy: boolean + moderation: ProfileModeration +}) { + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!followedBy && !causes.length) { + return null + } + + return ( + + {followedBy && ( + + + Follows You + + + )} + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + + + {cause?.type === 'label' ? '⚠' : ''} + {desc.name} + + + ) + })} + + ) +} + const FollowersList = observer( ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { const store = useStores() @@ -125,9 +154,9 @@ const FollowersList = observer( const followersWithMods = followers .map(f => ({ f, - mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)), + mod: moderateProfile(f, store.preferences.moderationOpts), })) - .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide) + .filter(({mod}) => !mod.account.filter) return ( @@ -218,6 +247,12 @@ const styles = StyleSheet.create({ paddingRight: 10, paddingBottom: 10, }, + pills: { + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 6, + rowGap: 2, + }, pill: { borderRadius: 4, paddingHorizontal: 6, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index a372f0d8..f8531d76 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -21,15 +21,13 @@ import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' import {ThemedText} from '../util/text/ThemedText' -import {TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' +import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' -import {listUriToHref} from 'lib/strings/url-helpers' import {isDesktopWeb, isNative} from 'platform/detection' import {FollowState} from 'state/models/cache/my-follows' import {shareUrl} from 'lib/sharing' @@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer( }, [navigation]) const onPressAvi = React.useCallback(() => { - if (view.avatar) { + if ( + view.avatar && + !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { store.shell.openLightbox(new ProfileImageLightbox(view)) } }, [store, view]) @@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer( style={[pal.text, styles.title]}> {sanitizeDisplayName( view.displayName || sanitizeHandle(view.handle), + view.moderation.profile, )} @@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer( - {view.descriptionRichText ? ( + {view.description && + view.descriptionRichText && + !view.moderation.profile.blur ? ( )} - - - {view.viewer.blocking ? ( - - - - Account blocked - - - ) : view.viewer.muted ? ( - - - - Account muted{' '} - {view.viewer.mutedByList && ( - - by{' '} - - - )} - - - ) : undefined} - {view.viewer.blockedBy && ( - - - - This account has blocked you - - - )} - + {!isDesktopWeb && !hideBackButton && ( { - if (!moderation?.warn) { + const alert = useMemo(() => { + if (!moderation?.alert) { return null } return ( - + ) - }, [moderation?.warn, size, pal]) + }, [moderation?.alert, size, pal]) // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( @@ -259,12 +259,12 @@ export function UserAvatar({ source={{uri: avatar}} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} /> - {warning} + {alert} ) : ( - {warning} + {alert} ) } @@ -289,13 +289,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', backgroundColor: colors.gray5, }, - warningIconContainer: { + alertIconContainer: { position: 'absolute', right: 0, bottom: 0, borderRadius: 100, }, - warningIcon: { + alertIcon: { color: colors.red3, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index b7e91b5d..7c5c583c 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,6 +1,7 @@ import React, {useMemo} from 'react' import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' @@ -10,7 +11,6 @@ import { useCameraPermission, } from 'lib/hooks/usePermissions' import {usePalette} from 'lib/hooks/usePalette' -import {AvatarModeration} from 'lib/labeling/types' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' @@ -21,7 +21,7 @@ export function UserBanner({ onSelectNewBanner, }: { banner?: string | null - moderation?: AvatarModeration + moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void }) { const store = useStores() diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index ac5c8395..6be2f8be 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -1,36 +1,32 @@ import React from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' +import {ModerationUI} from '@atproto/api' import {Text} from '../text/Text' -import {addStyle} from 'lib/styles' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' +import {InfoCircleIcon} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' export function ContentHider({ testID, moderation, + ignoreMute, style, - containerStyle, + childContainerStyle, children, }: React.PropsWithChildren<{ testID?: string - moderation: ModerationBehavior + moderation: ModerationUI + ignoreMute?: boolean style?: StyleProp - containerStyle?: StyleProp + childContainerStyle?: StyleProp }>) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const onPressShow = React.useCallback(() => { - setOverride(true) - }, [setOverride]) - const onPressHide = React.useCallback(() => { - setOverride(false) - }, [setOverride]) - if ( - moderation.behavior === ModerationBehaviorCode.Show || - moderation.behavior === ModerationBehaviorCode.Warn || - moderation.behavior === ModerationBehaviorCode.WarnImages - ) { + if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { return ( {children} @@ -38,73 +34,61 @@ export function ContentHider({ ) } - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - + const desc = describeModerationCause(moderation.cause, 'content') return ( - + - {override && ( - - - {children} + {!moderation.noOverride && ( + + + {override ? 'Hide' : 'Show'} + - - )} + )} + + {override && {children}} ) } const styles = StyleSheet.create({ - container: { - marginBottom: 10, - borderWidth: 1, - borderRadius: 12, - }, - description: { + cover: { flexDirection: 'row', alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 4, paddingVertical: 14, paddingLeft: 14, - paddingRight: 18, - borderRadius: 12, - }, - descriptionOpen: { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - }, - icon: { - marginRight: 10, + paddingRight: isDesktopWeb ? 18 : 22, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, - childrenContainer: { - paddingHorizontal: 12, - paddingTop: 8, - }, - child: {}, }) diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx deleted file mode 100644 index 40c9d0a2..00000000 --- a/src/view/com/util/moderation/ImageHider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../text/Text' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' -import {isDesktopWeb} from 'platform/detection' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' - -export function ImageHider({ - testID, - moderation, - style, - children, -}: React.PropsWithChildren<{ - testID?: string - moderation: ModerationBehavior - style?: StyleProp -}>) { - const pal = usePalette('default') - const [override, setOverride] = React.useState(false) - const onPressToggle = React.useCallback(() => { - setOverride(v => !v) - }, [setOverride]) - - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior !== ModerationBehaviorCode.WarnImages) { - return ( - - {children} - - ) - } - - return ( - - - - - - {moderation.reason || 'Content warning'} - - - - {override ? 'Hide' : 'Show'} - - - - {override && children} - - ) -} - -const styles = StyleSheet.create({ - cover: { - borderRadius: 8, - marginTop: 4, - }, - toggleBtn: { - flexDirection: 'row', - gap: 8, - alignItems: 'center', - paddingHorizontal: isDesktopWeb ? 24 : 20, - paddingVertical: isDesktopWeb ? 20 : 18, - }, - flex1: { - flex: 1, - }, -}) diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx new file mode 100644 index 00000000..45937c2d --- /dev/null +++ b/src/view/com/util/moderation/PostAlerts.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {InfoCircleIcon} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' + +export function PostAlerts({ + moderation, + includeMute, + style, +}: { + moderation: ModerationUI + includeMute?: boolean + style?: StyleProp +}) { + const store = useStores() + const pal = usePalette('default') + + const shouldAlert = + !!moderation.cause && + (moderation.alert || + (includeMute && moderation.blur && moderation.cause?.type === 'muted')) + if (!shouldAlert) { + return null + } + + const desc = describeModerationCause(moderation.cause, 'content') + return ( + { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + + + {desc.name} + + + Learn More + + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 8, + paddingLeft: 14, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index f2b6dbdd..dc74d3e3 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -1,17 +1,20 @@ import React, {ComponentProps} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, Pressable, View} from 'react-native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {Link} from '../Link' import {Text} from '../text/Text' import {addStyle} from 'lib/styles' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {InfoCircleIcon} from 'lib/icons' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' interface Props extends ComponentProps { // testID?: string // href?: string // style: StyleProp - moderation: ModerationBehavior + moderation: ModerationUI } export function PostHider({ @@ -22,60 +25,71 @@ export function PostHider({ children, ...props }: Props) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const bg = override ? pal.viewLight : pal.view - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior === ModerationBehaviorCode.Warn) { + if (!moderation.blur) { return ( - <> - - - - {moderation.reason || 'Content warning'} - - setOverride(v => !v)} - accessibilityRole="button"> - - {override ? 'Hide' : 'Show'} post - - - - {override && ( - - - {children} - - - )} - + + {children} + ) } - // NOTE: any further label enforcement should occur in ContentContainer + const desc = describeModerationCause(moderation.cause, 'content') return ( - - {children} - + <> + { + if (!moderation.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" + style={[styles.description, pal.viewLight]}> + { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + + + + {desc.name} + + {!moderation.noOverride && ( + + {override ? 'Hide' : 'Show'} + + )} + + {override && ( + + + {children} + + + )} + ) } @@ -83,22 +97,23 @@ const styles = StyleSheet.create({ description: { flexDirection: 'row', alignItems: 'center', + gap: 4, paddingVertical: 14, - paddingHorizontal: 18, - borderTopWidth: 1, - }, - icon: { - marginRight: 10, + paddingLeft: 18, + paddingRight: isDesktopWeb ? 18 : 22, + marginTop: 1, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, childrenContainer: { - paddingHorizontal: 6, + paddingHorizontal: 4, paddingBottom: 6, }, child: { - borderWidth: 1, - borderRadius: 12, + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, }, }) diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 00000000..3cc3b5b9 --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ProfileModeration} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {InfoCircleIcon} from 'lib/icons' +import { + describeModerationCause, + getProfileModerationCauses, +} from 'lib/moderation' +import {useStores} from 'state/index' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ProfileModeration + style?: StyleProp +}) { + const store = useStores() + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!causes.length) { + return null + } + + return ( + + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation: {cause}, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + + + {desc.name} + + + Learn More + + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + grid: { + gap: 4, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx deleted file mode 100644 index 7a1a8e29..00000000 --- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' - -export function ProfileHeaderWarnings({ - moderation, -}: { - moderation: ModerationBehavior -}) { - const palErr = usePalette('error') - if (moderation.behavior === ModerationBehaviorCode.Show) { - return null - } - return ( - - - - This account has been flagged: {moderation.reason} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 8, - }, -}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx index 2e7b07e1..b76b1101 100644 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -1,16 +1,24 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import { + TouchableWithoutFeedback, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {NavigationProp} from 'lib/routes/types' import {Text} from '../text/Text' import {Button} from '../forms/Button' import {isDesktopWeb} from 'platform/detection' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' export function ScreenHider({ testID, @@ -22,24 +30,17 @@ export function ScreenHider({ }: React.PropsWithChildren<{ testID?: string screenDescription: string - moderation: ModerationBehavior + moderation: ModerationUI style?: StyleProp containerStyle?: StyleProp }>) { + const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') const [override, setOverride] = React.useState(false) const navigation = useNavigation() - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (moderation.behavior !== ModerationBehaviorCode.Hide || override) { + if (!moderation.blur || override) { return ( {children} @@ -47,6 +48,7 @@ export function ScreenHider({ ) } + const desc = describeModerationCause(moderation.cause, 'account') return ( @@ -63,11 +65,38 @@ export function ScreenHider({ This {screenDescription} has been flagged:{' '} - {moderation.reason || 'Content warning'} + + {desc.name} + + .{' '} + { + store.shell.openModal({ + name: 'moderation-details', + context: 'account', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + + Learn More + + {!isDesktopWeb && } -