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
zio/stable
Paul Frazee 2023-08-03 22:08:30 -07:00 committed by GitHub
parent 3ae5a6b631
commit b154d3ea21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1193 additions and 717 deletions

View File

@ -75,26 +75,35 @@ async function main() {
) )
for (const user of [ for (const user of [
'csam-account', 'dmca-account',
'csam-profile', 'dmca-profile',
'csam-posts', 'dmca-posts',
'porn-account', 'porn-account',
'porn-profile', 'porn-profile',
'porn-posts', 'porn-posts',
'nudity-account', 'nudity-account',
'nudity-profile', 'nudity-profile',
'nudity-posts', 'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account', 'unknown-account',
'unknown-profile', 'unknown-profile',
'unknown-posts', 'unknown-posts',
'always-filter-account', 'hide-account',
'always-filter-profile', 'hide-profile',
'always-filter-posts', 'hide-posts',
'always-warn-account', 'no-promote-account',
'always-warn-profile', 'no-promote-profile',
'always-warn-posts', 'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account', 'muted-account',
'muted-by-list-account', 'muted-by-list-account',
'blocking-account',
'blockedby-account',
'mutual-block-account',
]) { ]) {
await server.mocker.createUser(user) await server.mocker.createUser(user)
await server.mocker.follow('alice', user) await server.mocker.follow('alice', user)
@ -108,25 +117,25 @@ async function main() {
await server.mocker.like(user, anchorPost) await server.mocker.like(user, anchorPost)
} }
await server.mocker.labelAccount('csam', 'csam-account') await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('csam', 'csam-profile') await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost( await server.mocker.labelPost(
'csam', 'dmca-violation',
await server.mocker.createPost('csam-posts', 'csam post'), await server.mocker.createPost('dmca-posts', 'dmca post'),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'csam', 'dmca-violation',
await server.mocker.createQuotePost( await server.mocker.createQuotePost(
'csam-posts', 'dmca-posts',
'csam quote post', 'dmca quote post',
anchorPost, anchorPost,
), ),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'csam', 'dmca-violation',
await server.mocker.createReply( await server.mocker.createReply(
'csam-posts', 'dmca-posts',
'csam reply', 'dmca reply',
anchorPost, 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( await server.mocker.labelAccount(
'not-a-real-label', 'not-a-real-label',
'unknown-account', 'unknown-account',
@ -206,54 +238,74 @@ async function main() {
), ),
) )
await server.mocker.labelAccount('!filter', 'always-filter-account') await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!filter', 'always-filter-profile') await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost( await server.mocker.labelPost(
'!filter', '!hide',
await server.mocker.createPost( await server.mocker.createPost('hide-posts', 'hide post'),
'always-filter-posts',
'always-filter post',
),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'!filter', '!hide',
await server.mocker.createQuotePost( await server.mocker.createQuotePost(
'always-filter-posts', 'hide-posts',
'always-filter quote post', 'hide quote post',
anchorPost, anchorPost,
), ),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'!filter', '!hide',
await server.mocker.createReply( await server.mocker.createReply(
'always-filter-posts', 'hide-posts',
'always-filter reply', 'hide reply',
anchorPost, anchorPost,
), ),
) )
await server.mocker.labelAccount('!warn', 'always-warn-account') await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!warn', 'always-warn-profile') 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( await server.mocker.labelPost(
'!warn', '!warn',
await server.mocker.createPost( await server.mocker.createPost('warn-posts', 'warn post'),
'always-warn-posts',
'always-warn post',
),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'!warn', '!warn',
await server.mocker.createQuotePost( await server.mocker.createQuotePost(
'always-warn-posts', 'warn-posts',
'always-warn quote post', 'warn quote post',
anchorPost, anchorPost,
), ),
) )
await server.mocker.labelPost( await server.mocker.labelPost(
'!warn', '!warn',
await server.mocker.createReply( await server.mocker.createReply(
'always-warn-posts', 'warn-posts',
'always-warn reply', 'warn reply',
anchorPost, anchorPost,
), ),
) )
@ -291,6 +343,85 @@ async function main() {
'account reply', 'account reply',
anchorPost, 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') console.log('Ready')

View File

@ -53,7 +53,7 @@ describe('Profile screen', () => {
await expect(element(by.id('profileHeaderDisplayName'))).toHaveText( await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
'alice.test', '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 () => { 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 () => { 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.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Mute Account')).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.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Unmute Account')).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 () => { it('Can report another user', async () => {

View File

@ -4,7 +4,7 @@
"slug": "bluesky", "slug": "bluesky",
"scheme": "bluesky", "scheme": "bluesky",
"owner": "blueskysocial", "owner": "blueskysocial",
"version": "1.42.0", "version": "1.43.0",
"runtimeVersion": { "runtimeVersion": {
"policy": "appVersion" "policy": "appVersion"
}, },
@ -43,7 +43,7 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"android": { "android": {
"versionCode": 28, "versionCode": 29,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"

View File

@ -146,6 +146,7 @@ class Mocker {
} }
return await agent.post({ return await agent.post({
text, text,
langs: ['en'],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
} }
@ -162,6 +163,7 @@ class Mocker {
return await agent.post({ return await agent.post({
text, text,
embed: {$type: 'app.bsky.embed.record', record: {uri, cid}}, embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
langs: ['en'],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
} }
@ -178,6 +180,7 @@ class Mocker {
return await agent.post({ return await agent.post({
text, text,
reply: {root: {uri, cid}, parent: {uri, cid}}, reply: {root: {uri, cid}, parent: {uri, cid}},
langs: ['en'],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.42.0", "version": "1.43.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prepare": "is-ci || husky install", "prepare": "is-ci || husky install",
@ -24,7 +24,7 @@
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.4.3", "@atproto/api": "^0.5.2",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@expo/html-elements": "^0.4.2", "@expo/html-elements": "^0.4.2",

View File

@ -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,
},
}
}
}

View File

@ -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[]
}

View File

@ -1,10 +1,19 @@
import {ModerationUI} from '@atproto/api'
import {describeModerationCause} from '../moderation'
// \u2705 = ✅ // \u2705 = ✅
// \u2713 = ✓ // \u2713 = ✓
// \u2714 = ✔ // \u2714 = ✔
// \u2611 = ☑ // \u2611 = ☑
const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu 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') { if (typeof str === 'string') {
return str.replace(CHECK_MARKS_RE, '').trim() return str.replace(CHECK_MARKS_RE, '').trim()
} }

View File

@ -3,9 +3,9 @@ import {
AppBskyFeedPost as FeedPost, AppBskyFeedPost as FeedPost,
AppBskyFeedDefs, AppBskyFeedDefs,
RichText, RichText,
PostModeration,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {PostsFeedItemModel} from '../feeds/post' import {PostsFeedItemModel} from '../feeds/post'
type PostView = AppBskyFeedDefs.PostView type PostView = AppBskyFeedDefs.PostView
@ -67,10 +67,6 @@ export class PostThreadItemModel {
return this.data.isThreadMuted return this.data.isThreadMuted
} }
get labelInfo(): PostLabelInfo {
return this.data.labelInfo
}
get moderation(): PostModeration { get moderation(): PostModeration {
return this.data.moderation return this.data.moderation
} }

View File

@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedDefs, AppBskyFeedDefs,
PostModeration,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
@ -231,7 +232,6 @@ export class PostThreadModel {
return return
} }
pruneReplies(res.data.thread) pruneReplies(res.data.thread)
sortThread(res.data.thread)
const thread = new PostThreadItemModel( const thread = new PostThreadItemModel(
this.rootStore, this.rootStore,
res.data.thread as AppBskyFeedDefs.ThreadViewPost, res.data.thread as AppBskyFeedDefs.ThreadViewPost,
@ -241,6 +241,7 @@ export class PostThreadModel {
res.data.thread as AppBskyFeedDefs.ThreadViewPost, res.data.thread as AppBskyFeedDefs.ThreadViewPost,
thread.uri, thread.uri,
) )
sortThread(thread)
this.thread = thread this.thread = thread
} }
} }
@ -262,24 +263,28 @@ function pruneReplies(post: MaybePost) {
} }
} }
function sortThread(post: MaybePost) { type MaybeThreadItem =
if (post.notFound) { | PostThreadItemModel
| AppBskyFeedDefs.NotFoundPost
| AppBskyFeedDefs.BlockedPost
function sortThread(item: MaybeThreadItem) {
if ('notFound' in item) {
return return
} }
post = post as AppBskyFeedDefs.ThreadViewPost item = item as PostThreadItemModel
if (post.replies) { if (item.replies) {
post.replies.sort((a: MaybePost, b: MaybePost) => { item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
post = post as AppBskyFeedDefs.ThreadViewPost if ('notFound' in a && a.notFound) {
if (a.notFound) {
return 1 return 1
} }
if (b.notFound) { if ('notFound' in b && b.notFound) {
return -1 return -1
} }
a = a as AppBskyFeedDefs.ThreadViewPost item = item as PostThreadItemModel
b = b as AppBskyFeedDefs.ThreadViewPost a = a as PostThreadItemModel
const aIsByOp = a.post.author.did === post.post.author.did b = b as PostThreadItemModel
const bIsByOp = b.post.author.did === post.post.author.did const aIsByOp = a.post.author.did === item.post.author.did
const bIsByOp = b.post.author.did === item.post.author.did
if (aIsByOp && bIsByOp) { if (aIsByOp && bIsByOp) {
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
} else if (aIsByOp) { } else if (aIsByOp) {
@ -287,8 +292,31 @@ function sortThread(post: MaybePost) {
} else if (bIsByOp) { } else if (bIsByOp) {
return 1 // op's own reply 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 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
}

View File

@ -6,18 +6,14 @@ import {
AppBskyActorGetProfile as GetProfile, AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile, AppBskyActorProfile,
RichText, RichText,
moderateProfile,
ProfileModeration,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {FollowState} from '../cache/my-follows' import {FollowState} from '../cache/my-follows'
import {Image as RNImage} from 'react-native-image-crop-picker' 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' import {track} from 'lib/analytics/analytics'
export class ProfileViewerModel { export class ProfileViewerModel {
@ -26,7 +22,8 @@ export class ProfileViewerModel {
following?: string following?: string
followedBy?: string followedBy?: string
blockedBy?: boolean blockedBy?: boolean
blocking?: string blocking?: string;
[key: string]: unknown
constructor() { constructor() {
makeAutoObservable(this) makeAutoObservable(this)
@ -53,7 +50,8 @@ export class ProfileModel {
followsCount: number = 0 followsCount: number = 0
postsCount: number = 0 postsCount: number = 0
labels?: ComAtprotoLabelDefs.Label[] = undefined labels?: ComAtprotoLabelDefs.Label[] = undefined
viewer = new ProfileViewerModel() viewer = new ProfileViewerModel();
[key: string]: unknown
// added data // added data
descriptionRichText?: RichText = new RichText({text: ''}) descriptionRichText?: RichText = new RichText({text: ''})
@ -85,18 +83,8 @@ export class ProfileModel {
return this.hasLoaded && !this.hasContent 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 { get moderation(): ProfileModeration {
return getProfileModeration(this.rootStore, this.labelInfo) return moderateProfile(this, this.rootStore.preferences.moderationOpts)
} }
// public api // public api

View File

@ -1,6 +1,7 @@
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyGraphGetFollows as GetFollows, AppBskyGraphGetFollows as GetFollows,
moderateProfile,
} from '@atproto/api' } from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import sampleSize from 'lodash.samplesize' import sampleSize from 'lodash.samplesize'
@ -52,6 +53,13 @@ export class FoafsModel {
cursor, cursor,
limit: 100, 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) this.rootStore.me.follows.hydrateProfiles(res.data.follows)
if (!res.data.cursor) { if (!res.data.cursor) {
break break

View File

@ -1,5 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
@ -69,7 +69,12 @@ export class SuggestedActorsModel {
limit: 25, limit: 25,
cursor: this.loadMoreCursor, 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) this.rootStore.me.follows.hydrateProfiles(actors)
runInAction(() => { runInAction(() => {

View File

@ -8,6 +8,8 @@ import {
AppBskyFeedLike, AppBskyFeedLike,
AppBskyGraphFollow, AppBskyGraphFollow,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
moderatePost,
moderateProfile,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
@ -15,16 +17,6 @@ import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {PostThreadModel} from '../content/post-thread' import {PostThreadModel} from '../content/post-thread'
import {cleanError} from 'lib/strings/errors' 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 GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -100,27 +92,19 @@ export class NotificationsFeedItemModel {
} }
} }
get labelInfo(): PostLabelInfo { get shouldFilter(): boolean {
const addedInfo = this.additionalPost?.thread?.labelInfo if (this.additionalPost?.thread) {
return { const postMod = moderatePost(
postLabels: (this.labels || []).concat(addedInfo?.postLabels || []), this.additionalPost.thread.data.post,
accountLabels: filterAccountLabels(this.author.labels).concat( this.rootStore.preferences.moderationOpts,
addedInfo?.accountLabels || [], )
), return postMod.content.filter || false
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,
} }
} const profileMod = moderateProfile(
this.author,
get moderation(): PostModeration { this.rootStore.preferences.moderationOpts,
return getPostModeration(this.rootStore, this.labelInfo) )
return profileMod.account.filter || false
} }
get numUnreadInGroup(): number { get numUnreadInGroup(): number {
@ -565,8 +549,7 @@ export class NotificationsFeedModel {
): NotificationsFeedItemModel[] { ): NotificationsFeedItemModel[] {
return items return items
.filter(item => { .filter(item => {
const hideByLabel = const hideByLabel = item.shouldFilter
item.moderation.list.behavior === ModerationBehaviorCode.Hide
let mutedThread = !!( let mutedThread = !!(
item.reasonSubjectRootUri && item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)

View File

@ -3,21 +3,13 @@ import {
AppBskyFeedPost as FeedPost, AppBskyFeedPost as FeedPost,
AppBskyFeedDefs, AppBskyFeedDefs,
RichText, RichText,
moderatePost,
PostModeration,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {updateDataOptimistically} from 'lib/async/revertible' 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 {track} from 'lib/analytics/analytics'
import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@ -44,6 +36,7 @@ export class PostsFeedItemModel {
if (FeedPost.isRecord(this.post.record)) { if (FeedPost.isRecord(this.post.record)) {
const valid = FeedPost.validateRecord(this.post.record) const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) { if (valid.success) {
hackAddDeletedEmbed(this.post)
this.postRecord = this.post.record this.postRecord = this.post.record
this.richText = new RichText(this.postRecord, {cleanNewlines: true}) this.richText = new RichText(this.postRecord, {cleanNewlines: true})
} else { } else {
@ -86,33 +79,8 @@ export class PostsFeedItemModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri) 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 { get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo) return moderatePost(this.post, this.rootStore.preferences.moderationOpts)
} }
copy(v: FeedViewPost) { copy(v: FeedViewPost) {

View File

@ -1,7 +1,6 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {FeedViewPostsSlice} from 'lib/api/feed-manip' import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {mergePostModerations} from 'lib/labeling/helpers'
import {PostsFeedItemModel} from './post' import {PostsFeedItemModel} from './post'
let _idCounter = 0 let _idCounter = 0
@ -55,7 +54,20 @@ export class PostsFeedSliceModel {
} }
get moderation() { 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) { containsUri(uri: string) {

View File

@ -1,9 +1,14 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {LabelPreference as APILabelPreference} from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {RootStoreModel} from '../root-store' 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 {LabelValGroup} from 'lib/labeling/types'
import {getLabelValueGroup} from 'lib/labeling/helpers' import {getLabelValueGroup} from 'lib/labeling/helpers'
import { import {
@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants'
import {isIOS, deviceLocales} from 'platform/detection' import {isIOS, deviceLocales} from 'platform/detection'
import {LANGUAGES} from '../../../locale/languages' 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 = [ const LABEL_GROUPS = [
'nsfw', 'nsfw',
'nudity', 'nudity',
@ -408,6 +414,43 @@ export class PreferencesModel {
return res 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[]) { async setSavedFeeds(saved: string[], pinned: string[]) {
const oldSaved = this.savedFeeds const oldSaved = this.savedFeeds
const oldPinned = this.pinnedFeeds const oldPinned = this.pinnedFeeds
@ -485,3 +528,11 @@ export class PreferencesModel {
this.requireAltTextEnabled = !this.requireAltTextEnabled 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
}

View File

@ -1,4 +1,4 @@
import {AppBskyEmbedRecord} from '@atproto/api' import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile' import {ProfileModel} from '../content/profile'
@ -42,6 +42,12 @@ export interface ServerInputModal {
onSelect: (url: string) => void onSelect: (url: string) => void
} }
export interface ModerationDetailsModal {
name: 'moderation-details'
context: 'account' | 'content'
moderation: ModerationUI
}
export interface ReportPostModal { export interface ReportPostModal {
name: 'report-post' name: 'report-post'
postUri: string postUri: string
@ -146,6 +152,7 @@ export type Modal =
| PreferencesHomeFeed | PreferencesHomeFeed
// Moderation // Moderation
| ModerationDetailsModal
| ReportAccountModal | ReportAccountModal
| ReportPostModal | ReportPostModal
| CreateOrEditMuteListModal | CreateOrEditMuteListModal

View File

@ -30,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as PreferencesHomeFeed from './PreferencesHomeFeed'
import * as OnboardingModal from './OnboardingModal' import * as OnboardingModal from './OnboardingModal'
import * as ModerationDetailsModal from './ModerationDetails'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
@ -136,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'onboarding') { } else if (activeModal?.name === 'onboarding') {
snapPoints = OnboardingModal.snapPoints snapPoints = OnboardingModal.snapPoints
element = <OnboardingModal.Component /> element = <OnboardingModal.Component />
} else if (activeModal?.name === 'moderation-details') {
snapPoints = ModerationDetailsModal.snapPoints
element = <ModerationDetailsModal.Component {...activeModal} />
} else { } else {
return null return null
} }

View File

@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as OnboardingModal from './OnboardingModal' import * as OnboardingModal from './OnboardingModal'
import * as ModerationDetailsModal from './ModerationDetails'
import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as PreferencesHomeFeed from './PreferencesHomeFeed'
@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <PreferencesHomeFeed.Component /> element = <PreferencesHomeFeed.Component />
} else if (modal.name === 'onboarding') { } else if (modal.name === 'onboarding') {
element = <OnboardingModal.Component /> element = <OnboardingModal.Component />
} else if (modal.name === 'moderation-details') {
element = <ModerationDetailsModal.Component {...modal} />
} else { } else {
return null return null
} }

View File

@ -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{' '}
<TextLink
type="2xl"
href={listUriToHref(list.uri)}
text={list.name}
style={pal.link}
/>{' '}
list which you have muted.
</>
)
}
} else {
name = moderation.cause.labelDef.strings[context].en.name
description = moderation.cause.labelDef.strings[context].en.description
}
return (
<View testID="moderationDetailsModal" style={[styles.container, pal.view]}>
<Text type="title-xl" style={[pal.text, styles.title]}>
{name}
</Text>
<Text type="2xl" style={[pal.text, styles.description]}>
{description}
</Text>
<View style={s.flex1} />
<Button
type="primary"
style={styles.btn}
onPress={() => store.shell.closeModal()}>
<Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
Okay
</Text>
</Button>
</View>
)
}
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,
},
})

View File

@ -7,7 +7,11 @@ import {
StyleSheet, StyleSheet,
View, View,
} from 'react-native' } from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api' import {
AppBskyEmbedImages,
ProfileModeration,
moderateProfile,
} from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 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 {formatCount} from '../util/numeric/format'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
@ -99,9 +98,9 @@ export const FeedItem = observer(function ({
handle: item.author.handle, handle: item.author.handle,
displayName: item.author.displayName, displayName: item.author.displayName,
avatar: item.author.avatar, avatar: item.author.avatar,
moderation: getProfileModeration( moderation: moderateProfile(
store, item.author,
getProfileViewBasicLabelInfo(item.author), store.preferences.moderationOpts,
), ),
}, },
...(item.additional?.map(({author}) => { ...(item.additional?.map(({author}) => {
@ -111,10 +110,7 @@ export const FeedItem = observer(function ({
handle: author.handle, handle: author.handle,
displayName: author.displayName, displayName: author.displayName,
avatar: author.avatar, avatar: author.avatar,
moderation: getProfileModeration( moderation: moderateProfile(author, store.preferences.moderationOpts),
store,
getProfileViewBasicLabelInfo(author),
),
} }
}) || []), }) || []),
] ]

View File

@ -26,7 +26,7 @@ import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostHider} from '../util/moderation/PostHider' import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider' import {PostAlerts} from '../util/moderation/PostAlerts'
import {PostSandboxWarning} from '../util/PostSandboxWarning' import {PostSandboxWarning} from '../util/PostSandboxWarning'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -159,10 +159,9 @@ export const PostThreadItem = observer(function PostThreadItem({
if (item._isHighlightedPost) { if (item._isHighlightedPost) {
return ( return (
<PostHider <Link
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}>
moderation={item.moderation.thread}>
<PostSandboxWarning /> <PostSandboxWarning />
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
@ -227,7 +226,16 @@ export const PostThreadItem = observer(function PostThreadItem({
</View> </View>
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider moderation={item.moderation.view}> <ContentHider
moderation={item.moderation.content}
ignoreMute
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
includeMute
style={styles.alert}
/>
{item.richText?.text ? ( {item.richText?.text ? (
<View <View
style={[ style={[
@ -242,9 +250,11 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
) : undefined} ) : undefined}
<ImageHider moderation={item.moderation.view} style={s.mb10}> {item.post.embed && (
<ContentHider moderation={item.moderation.embed} style={s.mb10}>
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider> </ContentHider>
)}
</ContentHider> </ContentHider>
<ExpandedPostDetails <ExpandedPostDetails
post={item.post} post={item.post}
@ -311,7 +321,7 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
</View> </View>
</PostHider> </Link>
) )
} else { } else {
return ( return (
@ -325,7 +335,7 @@ export const PostThreadItem = observer(function PostThreadItem({
pal.view, pal.view,
item._showParentReplyLine && styles.noTopBorder, item._showParentReplyLine && styles.noTopBorder,
]} ]}
moderation={item.moderation.thread}> moderation={item.moderation.content}>
{item._showParentReplyLine && ( {item._showParentReplyLine && (
<View <View
style={[ style={[
@ -360,9 +370,10 @@ export const PostThreadItem = observer(function PostThreadItem({
timestamp={item.post.indexedAt} timestamp={item.post.indexedAt}
postHref={itemHref} postHref={itemHref}
/> />
<ContentHider <PostAlerts
moderation={item.moderation.thread} moderation={item.moderation.content}
containerStyle={styles.contentHider}> style={styles.alert}
/>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
@ -373,9 +384,11 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
) : undefined} ) : undefined}
<ImageHider style={s.mb10} moderation={item.moderation.thread}> {item.post.embed && (
<ContentHider style={s.mb10} moderation={item.moderation.embed}>
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider> </ContentHider>
)}
{needsTranslation && ( {needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}> <View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate"> <Link href={translatorUrl} title="Translate">
@ -385,7 +398,6 @@ export const PostThreadItem = observer(function PostThreadItem({
</Link> </Link>
</View> </View>
)} )}
</ContentHider>
<PostCtrls <PostCtrls
itemUri={itemUri} itemUri={itemUri}
itemCid={itemCid} itemCid={itemCid}
@ -515,6 +527,9 @@ const styles = StyleSheet.create({
paddingRight: 5, paddingRight: 5,
maxWidth: 240, maxWidth: 240,
}, },
alert: {
marginBottom: 6,
},
postTextContainer: { postTextContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -531,7 +546,10 @@ const styles = StyleSheet.create({
marginBottom: 6, marginBottom: 6,
}, },
contentHider: { contentHider: {
marginTop: 4, marginBottom: 6,
},
contentHiderChild: {
marginTop: 6,
}, },
expandedInfo: { expandedInfo: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -19,9 +19,8 @@ import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds' import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider' import {PostAlerts} from '../util/moderation/PostAlerts'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -206,10 +205,7 @@ const PostLoaded = observer(
}, [item, setDeleted, store]) }, [item, setDeleted, store])
return ( return (
<PostHider <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
href={itemHref}
style={[styles.outer, pal.view, pal.border, style]}
moderation={item.moderation.list}>
{showReplyLine && <View style={styles.replyLine} />} {showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
@ -251,8 +247,13 @@ const PostLoaded = observer(
</View> </View>
)} )}
<ContentHider <ContentHider
moderation={item.moderation.list} moderation={item.moderation.content}
containerStyle={styles.contentHider}> style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
style={styles.alert}
/>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
@ -264,9 +265,9 @@ const PostLoaded = observer(
/> />
</View> </View>
) : undefined} ) : undefined}
<ImageHider moderation={item.moderation.list} style={s.mb10}> <ContentHider moderation={item.moderation.embed} style={s.mb10}>
<PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostEmbeds embed={item.post.embed} style={s.mb10} />
</ImageHider> </ContentHider>
{needsTranslation && ( {needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}> <View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate"> <Link href={translatorUrl} title="Translate">
@ -302,7 +303,7 @@ const PostLoaded = observer(
/> />
</View> </View>
</View> </View>
</PostHider> </Link>
) )
}, },
) )
@ -323,6 +324,9 @@ const styles = StyleSheet.create({
layoutContent: { layoutContent: {
flex: 1, flex: 1,
}, },
alert: {
marginBottom: 6,
},
postTextContainer: { postTextContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -341,6 +345,9 @@ const styles = StyleSheet.create({
borderLeftColor: colors.gray2, borderLeftColor: colors.gray2,
}, },
contentHider: { contentHider: {
marginTop: 4, marginBottom: 6,
},
contentHiderChild: {
marginTop: 6,
}, },
}) })

View File

@ -8,16 +8,14 @@ import {
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {PostsFeedItemModel} from 'state/models/feeds/post' import {PostsFeedItemModel} from 'state/models/feeds/post'
import {ModerationBehaviorCode} from 'lib/labeling/types'
import {Link, DesktopWebTextLink} from '../util/Link' import {Link, DesktopWebTextLink} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostEmbeds} from '../util/post-embeds' import {PostEmbeds} from '../util/post-embeds'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' 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 {RichText} from '../util/text/RichText'
import {PostSandboxWarning} from '../util/PostSandboxWarning' import {PostSandboxWarning} from '../util/PostSandboxWarning'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -35,13 +33,11 @@ export const FeedItem = observer(function ({
item, item,
isThreadChild, isThreadChild,
isThreadParent, isThreadParent,
ignoreMuteFor,
}: { }: {
item: PostsFeedItemModel item: PostsFeedItemModel
isThreadChild?: boolean isThreadChild?: boolean
isThreadParent?: boolean isThreadParent?: boolean
showReplyLine?: boolean showReplyLine?: boolean
ignoreMuteFor?: string
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -147,26 +143,17 @@ export const FeedItem = observer(function ({
isThreadParent ? styles.outerNoBottom : undefined, 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) { if (!record || deleted) {
return <View /> return <View />
} }
return ( return (
<PostHider <Link
testID={`feedItem-by-${item.post.author.handle}`} testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles} style={outerStyles}
href={itemHref} href={itemHref}
moderation={moderation}> noFeedback
accessible={false}>
{isThreadChild && ( {isThreadChild && (
<View <View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@ -255,8 +242,14 @@ export const FeedItem = observer(function ({
</View> </View>
)} )}
<ContentHider <ContentHider
moderation={moderation} moderation={item.moderation.content}
containerStyle={styles.contentHider}> ignoreMute
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
style={styles.alert}
/>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
@ -267,9 +260,11 @@ export const FeedItem = observer(function ({
/> />
</View> </View>
) : undefined} ) : undefined}
<ImageHider moderation={item.moderation.list} style={styles.embed}> <ContentHider
moderation={item.moderation.embed}
style={styles.embed}>
<PostEmbeds embed={item.post.embed} style={styles.embed} /> <PostEmbeds embed={item.post.embed} style={styles.embed} />
</ImageHider> </ContentHider>
{needsTranslation && ( {needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}> <View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate"> <Link href={translatorUrl} title="Translate">
@ -306,7 +301,7 @@ export const FeedItem = observer(function ({
/> />
</View> </View>
</View> </View>
</PostHider> </Link>
) )
}) })
@ -358,6 +353,10 @@ const styles = StyleSheet.create({
layoutContent: { layoutContent: {
flex: 1, flex: 1,
}, },
alert: {
marginTop: 6,
marginBottom: 6,
},
postTextContainer: { postTextContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -365,7 +364,10 @@ const styles = StyleSheet.create({
paddingBottom: 4, paddingBottom: 4,
}, },
contentHider: { contentHider: {
marginTop: 4, marginBottom: 6,
},
contentHiderChild: {
marginTop: 6,
}, },
embed: { embed: {
marginBottom: 6, marginBottom: 6,

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
@ -7,21 +8,20 @@ import {Text} from '../util/text/Text'
import Svg, {Circle, Line} from 'react-native-svg' import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {ModerationBehaviorCode} from 'lib/labeling/types'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
export function FeedSlice({ export const FeedSlice = observer(
({
slice, slice,
ignoreMuteFor, ignoreFilterFor,
}: { }: {
slice: PostsFeedSliceModel slice: PostsFeedSliceModel
ignoreMuteFor?: string ignoreFilterFor?: string
}) { }) => {
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { if (slice.shouldFilter(ignoreFilterFor)) {
if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
return null return null
} }
}
if (slice.isThread && slice.items.length > 3) { if (slice.isThread && slice.items.length > 3) {
const last = slice.items.length - 1 const last = slice.items.length - 1
return ( return (
@ -31,14 +31,12 @@ export function FeedSlice({
item={slice.items[0]} item={slice.items[0]}
isThreadParent={slice.isThreadParentAt(0)} isThreadParent={slice.isThreadParentAt(0)}
isThreadChild={slice.isThreadChildAt(0)} isThreadChild={slice.isThreadChildAt(0)}
ignoreMuteFor={ignoreMuteFor}
/> />
<FeedItem <FeedItem
key={slice.items[1]._reactKey} key={slice.items[1]._reactKey}
item={slice.items[1]} item={slice.items[1]}
isThreadParent={slice.isThreadParentAt(1)} isThreadParent={slice.isThreadParentAt(1)}
isThreadChild={slice.isThreadChildAt(1)} isThreadChild={slice.isThreadChildAt(1)}
ignoreMuteFor={ignoreMuteFor}
/> />
<ViewFullThread slice={slice} /> <ViewFullThread slice={slice} />
<FeedItem <FeedItem
@ -46,7 +44,6 @@ export function FeedSlice({
item={slice.items[last]} item={slice.items[last]}
isThreadParent={slice.isThreadParentAt(last)} isThreadParent={slice.isThreadParentAt(last)}
isThreadChild={slice.isThreadChildAt(last)} isThreadChild={slice.isThreadChildAt(last)}
ignoreMuteFor={ignoreMuteFor}
/> />
</> </>
) )
@ -60,12 +57,12 @@ export function FeedSlice({
item={item} item={item}
isThreadParent={slice.isThreadParentAt(i)} isThreadParent={slice.isThreadParentAt(i)}
isThreadChild={slice.isThreadChildAt(i)} isThreadChild={slice.isThreadChildAt(i)}
ignoreMuteFor={ignoreMuteFor}
/> />
))} ))}
</> </>
) )
} },
)
function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -1,7 +1,11 @@
import * as React from 'react' import * as React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api' import {
AppBskyActorDefs,
moderateProfile,
ProfileModeration,
} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@ -11,12 +15,11 @@ import {useStores} from 'state/index'
import {FollowButton} from './FollowButton' import {FollowButton} from './FollowButton'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' 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 {makeProfileLink} from 'lib/routes/links'
import {
describeModerationCause,
getProfileModerationCauses,
} from 'lib/moderation'
export const ProfileCard = observer( export const ProfileCard = observer(
({ ({
@ -25,7 +28,6 @@ export const ProfileCard = observer(
noBg, noBg,
noBorder, noBorder,
followers, followers,
overrideModeration,
renderButton, renderButton,
}: { }: {
testID?: string testID?: string
@ -33,7 +35,6 @@ export const ProfileCard = observer(
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
overrideModeration?: boolean
renderButton?: ( renderButton?: (
profile: AppBskyActorDefs.ProfileViewBasic, profile: AppBskyActorDefs.ProfileViewBasic,
) => React.ReactNode ) => React.ReactNode
@ -41,18 +42,11 @@ export const ProfileCard = observer(
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const moderation = getProfileModeration( const moderation = moderateProfile(
store, profile,
getProfileViewBasicLabelInfo(profile), store.preferences.moderationOpts,
) )
if (
moderation.list.behavior === ModerationBehaviorCode.Hide &&
!overrideModeration
) {
return null
}
return ( return (
<Link <Link
testID={testID} testID={testID}
@ -82,20 +76,17 @@ export const ProfileCard = observer(
lineHeight={1.2}> lineHeight={1.2}>
{sanitizeDisplayName( {sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)} )}
</Text> </Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')} {sanitizeHandle(profile.handle, '@')}
</Text> </Text>
{!!profile.viewer?.followedBy && ( <ProfileCardPills
<View style={s.flexRow}> followedBy={!!profile.viewer?.followedBy}
<View style={[s.mt5, pal.btn, styles.pill]}> moderation={moderation}
<Text type="xs" style={pal.text}> />
Follows You {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</Text>
</View>
</View>
)}
</View> </View>
{renderButton ? ( {renderButton ? (
<View style={styles.layoutButton}>{renderButton(profile)}</View> <View style={styles.layoutButton}>{renderButton(profile)}</View>
@ -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 (
<View style={styles.pills}>
{followedBy && (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Follows You
</Text>
</View>
)}
{causes.map(cause => {
const desc = describeModerationCause(cause, 'account')
return (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
{cause?.type === 'label' ? '⚠' : ''}
{desc.name}
</Text>
</View>
)
})}
</View>
)
}
const FollowersList = observer( const FollowersList = observer(
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
const store = useStores() const store = useStores()
@ -125,9 +154,9 @@ const FollowersList = observer(
const followersWithMods = followers const followersWithMods = followers
.map(f => ({ .map(f => ({
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 ( return (
<View style={styles.followedBy}> <View style={styles.followedBy}>
@ -218,6 +247,12 @@ const styles = StyleSheet.create({
paddingRight: 10, paddingRight: 10,
paddingBottom: 10, paddingBottom: 10,
}, },
pills: {
flexDirection: 'row',
flexWrap: 'wrap',
columnGap: 6,
rowGap: 2,
},
pill: { pill: {
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 6, paddingHorizontal: 6,

View File

@ -21,15 +21,13 @@ import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ThemedText} from '../util/text/ThemedText' import {ThemedText} from '../util/text/ThemedText'
import {TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {listUriToHref} from 'lib/strings/url-helpers'
import {isDesktopWeb, isNative} from 'platform/detection' import {isDesktopWeb, isNative} from 'platform/detection'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer(
}, [navigation]) }, [navigation])
const onPressAvi = React.useCallback(() => { 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.shell.openLightbox(new ProfileImageLightbox(view))
} }
}, [store, view]) }, [store, view])
@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer(
style={[pal.text, styles.title]}> style={[pal.text, styles.title]}>
{sanitizeDisplayName( {sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle), view.displayName || sanitizeHandle(view.handle),
view.moderation.profile,
)} )}
</Text> </Text>
</View> </View>
@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer(
</Text> </Text>
</Text> </Text>
</View> </View>
{view.descriptionRichText ? ( {view.description &&
view.descriptionRichText &&
!view.moderation.profile.blur ? (
<RichText <RichText
testID="profileHeaderDescription" testID="profileHeaderDescription"
style={[styles.description, pal.text]} style={[styles.description, pal.text]}
@ -504,52 +508,7 @@ const ProfileHeaderLoaded = observer(
) : undefined} ) : undefined}
</> </>
)} )}
<ProfileHeaderWarnings moderation={view.moderation.view} /> <ProfileHeaderAlerts moderation={view.moderation} />
<View style={styles.moderationLines}>
{view.viewer.blocking ? (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon icon="ban" style={[pal.text]} />
<Text type="lg-medium" style={pal.text}>
Account blocked
</Text>
</View>
) : view.viewer.muted ? (
<View
testID="profileHeaderMutedNotice"
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text]}
/>
<Text type="lg-medium" style={pal.text}>
Account muted{' '}
{view.viewer.mutedByList && (
<Text type="lg-medium" style={pal.text}>
by{' '}
<TextLink
type="lg-medium"
style={pal.link}
href={listUriToHref(view.viewer.mutedByList.uri)}
text={view.viewer.mutedByList.name}
/>
</Text>
)}
</Text>
</View>
) : undefined}
{view.viewer.blockedBy && (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon icon="ban" style={[pal.text]} />
<Text type="lg-medium" style={pal.text}>
This account has blocked you
</Text>
</View>
)}
</View>
</View> </View>
{!isDesktopWeb && !hideBackButton && ( {!isDesktopWeb && !hideBackButton && (
<TouchableWithoutFeedback <TouchableWithoutFeedback
@ -693,19 +652,6 @@ const styles = StyleSheet.create({
paddingVertical: 2, paddingVertical: 2,
}, },
moderationLines: {
gap: 6,
},
moderationNotice: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 8,
},
br40: {borderRadius: 40}, br40: {borderRadius: 40},
br50: {borderRadius: 50}, br50: {borderRadius: 50},
}) })

View File

@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
import Svg, {Circle, Rect, Path} from 'react-native-svg' import Svg, {Circle, Rect, Path} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HighPriorityImage} from 'view/com/util/images/Image' import {HighPriorityImage} from 'view/com/util/images/Image'
import {ModerationUI} from '@atproto/api'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
@ -13,7 +14,6 @@ import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {AvatarModeration} from 'lib/labeling/types'
import {UserPreviewLink} from './UserPreviewLink' import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
@ -23,7 +23,7 @@ interface BaseUserAvatarProps {
type?: Type type?: Type
size: number size: number
avatar?: string | null avatar?: string | null
moderation?: AvatarModeration moderation?: ModerationUI
} }
interface UserAvatarProps extends BaseUserAvatarProps { interface UserAvatarProps extends BaseUserAvatarProps {
@ -213,20 +213,20 @@ export function UserAvatar({
], ],
) )
const warning = useMemo(() => { const alert = useMemo(() => {
if (!moderation?.warn) { if (!moderation?.alert) {
return null return null
} }
return ( return (
<View style={[styles.warningIconContainer, pal.view]}> <View style={[styles.alertIconContainer, pal.view]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="exclamation-circle" icon="exclamation-circle"
style={styles.warningIcon} style={styles.alertIcon}
size={Math.floor(size / 3)} size={Math.floor(size / 3)}
/> />
</View> </View>
) )
}, [moderation?.warn, size, pal]) }, [moderation?.alert, size, pal])
// onSelectNewAvatar is only passed as prop on the EditProfile component // onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? ( return onSelectNewAvatar ? (
@ -259,12 +259,12 @@ export function UserAvatar({
source={{uri: avatar}} source={{uri: avatar}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/> />
{warning} {alert}
</View> </View>
) : ( ) : (
<View style={{width: size, height: size}}> <View style={{width: size, height: size}}>
<DefaultAvatar type={type} size={size} /> <DefaultAvatar type={type} size={size} />
{warning} {alert}
</View> </View>
) )
} }
@ -289,13 +289,13 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.gray5, backgroundColor: colors.gray5,
}, },
warningIconContainer: { alertIconContainer: {
position: 'absolute', position: 'absolute',
right: 0, right: 0,
bottom: 0, bottom: 0,
borderRadius: 100, borderRadius: 100,
}, },
warningIcon: { alertIcon: {
color: colors.red3, color: colors.red3,
}, },
}) })

View File

@ -1,6 +1,7 @@
import React, {useMemo} from 'react' import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ModerationUI} from '@atproto/api'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
@ -10,7 +11,6 @@ import {
useCameraPermission, useCameraPermission,
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
@ -21,7 +21,7 @@ export function UserBanner({
onSelectNewBanner, onSelectNewBanner,
}: { }: {
banner?: string | null banner?: string | null
moderation?: AvatarModeration moderation?: ModerationUI
onSelectNewBanner?: (img: RNImage | null) => void onSelectNewBanner?: (img: RNImage | null) => void
}) { }) {
const store = useStores() const store = useStores()

View File

@ -1,36 +1,32 @@
import React from 'react' import React from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {ModerationUI} from '@atproto/api'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {addStyle} from 'lib/styles' import {InfoCircleIcon} from 'lib/icons'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' import {describeModerationCause} from 'lib/moderation'
import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection'
export function ContentHider({ export function ContentHider({
testID, testID,
moderation, moderation,
ignoreMute,
style, style,
containerStyle, childContainerStyle,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
testID?: string testID?: string
moderation: ModerationBehavior moderation: ModerationUI
ignoreMute?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle>
}>) { }>) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const onPressShow = React.useCallback(() => {
setOverride(true)
}, [setOverride])
const onPressHide = React.useCallback(() => {
setOverride(false)
}, [setOverride])
if ( if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
moderation.behavior === ModerationBehaviorCode.Show ||
moderation.behavior === ModerationBehaviorCode.Warn ||
moderation.behavior === ModerationBehaviorCode.WarnImages
) {
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{children} {children}
@ -38,73 +34,61 @@ export function ContentHider({
) )
} }
if (moderation.behavior === ModerationBehaviorCode.Hide) { const desc = describeModerationCause(moderation.cause, 'content')
return null
}
return ( return (
<View style={[styles.container, pal.view, pal.border, containerStyle]}> <View testID={testID} style={style}>
<Pressable <Pressable
onPress={override ? onPressHide : onPressShow} onPress={() => {
accessibilityLabel={override ? 'Hide post' : 'Show post'} if (!moderation.noOverride) {
// TODO: The text labelling should be split up so controls have unique roles setOverride(v => !v)
accessibilityHint={
override
? 'Re-hide post'
: 'Shows post hidden based on your moderation settings'
} }
style={[ }}
styles.description, accessibilityRole="button"
pal.viewLight, accessibilityHint={override ? 'Hide the content' : 'Show the content'}
override && styles.descriptionOpen, accessibilityLabel=""
]}> style={[styles.cover, pal.viewLight]}>
<Text type="md" style={pal.textLight}> <Pressable
{moderation.reason || 'Content warning'} onPress={() => {
store.shell.openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint="">
<InfoCircleIcon size={18} style={pal.text} />
</Pressable>
<Text type="lg" style={pal.text}>
{desc.name}
</Text> </Text>
{!moderation.noOverride && (
<View style={styles.showBtn}> <View style={styles.showBtn}>
<Text type="md-medium" style={pal.link}> <Text type="xl" style={pal.link}>
{override ? 'Hide' : 'Show'} {override ? 'Hide' : 'Show'}
</Text> </Text>
</View> </View>
</Pressable>
{override && (
<View style={[styles.childrenContainer, pal.border]}>
<View testID={testID} style={addStyle(style, styles.child)}>
{children}
</View>
</View>
)} )}
</Pressable>
{override && <View style={childContainerStyle}>{children}</View>}
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { cover: {
marginBottom: 10,
borderWidth: 1,
borderRadius: 12,
},
description: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4,
borderRadius: 8,
marginTop: 4,
paddingVertical: 14, paddingVertical: 14,
paddingLeft: 14, paddingLeft: 14,
paddingRight: 18, paddingRight: isDesktopWeb ? 18 : 22,
borderRadius: 12,
},
descriptionOpen: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
icon: {
marginRight: 10,
}, },
showBtn: { showBtn: {
marginLeft: 'auto', marginLeft: 'auto',
alignSelf: 'center',
}, },
childrenContainer: {
paddingHorizontal: 12,
paddingTop: 8,
},
child: {},
}) })

View File

@ -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<ViewStyle>
}>) {
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 (
<View testID={testID} style={style}>
{children}
</View>
)
}
return (
<View testID={testID} style={style}>
<View style={[styles.cover, pal.viewLight]}>
<Pressable
onPress={onPressToggle}
style={[styles.toggleBtn]}
accessibilityLabel="Show image"
accessibilityHint="">
<FontAwesomeIcon
icon={override ? 'eye' : ['far', 'eye-slash']}
size={24}
style={pal.text as FontAwesomeIconStyle}
/>
<Text type="lg" style={pal.text}>
{moderation.reason || 'Content warning'}
</Text>
<View style={styles.flex1} />
<Text type="xl-bold" style={pal.link}>
{override ? 'Hide' : 'Show'}
</Text>
</Pressable>
</View>
{override && children}
</View>
)
}
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,
},
})

View File

@ -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<ViewStyle>
}) {
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 (
<Pressable
onPress={() => {
store.shell.openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
<InfoCircleIcon style={pal.text} size={18} />
<Text type="lg" style={pal.text}>
{desc.name}
</Text>
<Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
Learn More
</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 8,
paddingLeft: 14,
paddingHorizontal: 16,
borderRadius: 8,
},
learnMoreBtn: {
marginLeft: 'auto',
},
})

View File

@ -1,17 +1,20 @@
import React, {ComponentProps} from 'react' import React, {ComponentProps} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, Pressable, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ModerationUI} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link' import {Link} from '../Link'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {addStyle} from 'lib/styles' 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<typeof Link> { interface Props extends ComponentProps<typeof Link> {
// testID?: string // testID?: string
// href?: string // href?: string
// style: StyleProp<ViewStyle> // style: StyleProp<ViewStyle>
moderation: ModerationBehavior moderation: ModerationUI
} }
export function PostHider({ export function PostHider({
@ -22,50 +25,11 @@ export function PostHider({
children, children,
...props ...props
}: Props) { }: Props) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view
if (moderation.behavior === ModerationBehaviorCode.Hide) { if (!moderation.blur) {
return null
}
if (moderation.behavior === ModerationBehaviorCode.Warn) {
return (
<>
<View style={[styles.description, bg, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
{moderation.reason || 'Content warning'}
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}
accessibilityRole="button">
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
)
}
// NOTE: any further label enforcement should occur in ContentContainer
return ( return (
<Link <Link
testID={testID} testID={testID}
@ -79,26 +43,77 @@ export function PostHider({
) )
} }
const desc = describeModerationCause(moderation.cause, 'content')
return (
<>
<Pressable
onPress={() => {
if (!moderation.noOverride) {
setOverride(v => !v)
}
}}
accessibilityRole="button"
accessibilityHint={override ? 'Hide the content' : 'Show the content'}
accessibilityLabel=""
style={[styles.description, pal.viewLight]}>
<Pressable
onPress={() => {
store.shell.openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint="">
<InfoCircleIcon size={18} style={pal.text} />
</Pressable>
<Text type="lg" style={pal.text}>
{desc.name}
</Text>
{!moderation.noOverride && (
<Text type="xl" style={[styles.showBtn, pal.link]}>
{override ? 'Hide' : 'Show'}
</Text>
)}
</Pressable>
{override && (
<View style={[styles.childrenContainer, pal.border, pal.viewLight]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
description: { description: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4,
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 18, paddingLeft: 18,
borderTopWidth: 1, paddingRight: isDesktopWeb ? 18 : 22,
}, marginTop: 1,
icon: {
marginRight: 10,
}, },
showBtn: { showBtn: {
marginLeft: 'auto', marginLeft: 'auto',
alignSelf: 'center',
}, },
childrenContainer: { childrenContainer: {
paddingHorizontal: 6, paddingHorizontal: 4,
paddingBottom: 6, paddingBottom: 6,
}, },
child: { child: {
borderWidth: 1, borderWidth: 0,
borderRadius: 12, borderTopWidth: 0,
borderRadius: 8,
}, },
}) })

View File

@ -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<ViewStyle>
}) {
const store = useStores()
const pal = usePalette('default')
const causes = getProfileModerationCauses(moderation)
if (!causes.length) {
return null
}
return (
<View style={styles.grid}>
{causes.map(cause => {
const desc = describeModerationCause(cause, 'account')
return (
<Pressable
testID="profileHeaderAlert"
key={desc.name}
onPress={() => {
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]}>
<InfoCircleIcon style={pal.text} size={24} />
<Text type="lg" style={pal.text}>
{desc.name}
</Text>
<Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
Learn More
</Text>
</Pressable>
)
})}
</View>
)
}
const styles = StyleSheet.create({
grid: {
gap: 4,
},
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
},
learnMoreBtn: {
marginLeft: 'auto',
},
})

View File

@ -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 (
<View style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged: {moderation.reason}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View File

@ -1,16 +1,24 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {
TouchableWithoutFeedback,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {ModerationUI} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {Button} from '../forms/Button' import {Button} from '../forms/Button'
import {isDesktopWeb} from 'platform/detection' 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({ export function ScreenHider({
testID, testID,
@ -22,24 +30,17 @@ export function ScreenHider({
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
testID?: string testID?: string
screenDescription: string screenDescription: string
moderation: ModerationBehavior moderation: ModerationUI
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle>
}>) { }>) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const palInverted = usePalette('inverted') const palInverted = usePalette('inverted')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => { if (!moderation.blur || override) {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{children} {children}
@ -47,6 +48,7 @@ export function ScreenHider({
) )
} }
const desc = describeModerationCause(moderation.cause, 'account')
return ( return (
<View style={[styles.container, pal.view, containerStyle]}> <View style={[styles.container, pal.view, containerStyle]}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
@ -63,11 +65,38 @@ export function ScreenHider({
</Text> </Text>
<Text type="2xl" style={[styles.description, pal.textLight]}> <Text type="2xl" style={[styles.description, pal.textLight]}>
This {screenDescription} has been flagged:{' '} This {screenDescription} has been flagged:{' '}
{moderation.reason || 'Content warning'} <Text type="2xl-medium" style={pal.text}>
{desc.name}
</Text>
.{' '}
<TouchableWithoutFeedback
onPress={() => {
store.shell.openModal({
name: 'moderation-details',
context: 'account',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint="">
<Text type="2xl" style={pal.link}>
Learn More
</Text>
</TouchableWithoutFeedback>
</Text> </Text>
{!isDesktopWeb && <View style={styles.spacer} />} {!isDesktopWeb && <View style={styles.spacer} />}
<View style={styles.btnContainer}> <View style={styles.btnContainer}>
<Button type="inverted" onPress={onPressBack} style={styles.btn}> <Button
type="inverted"
onPress={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}}
style={styles.btn}>
<Text type="button-lg" style={pal.textInverted}> <Text type="button-lg" style={pal.textInverted}>
Go back Go back
</Text> </Text>

View File

@ -1,6 +1,11 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' import {
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
} from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {PostMeta} from '../PostMeta' import {PostMeta} from '../PostMeta'
import {Link} from '../Link' import {Link} from '../Link'
@ -9,6 +14,55 @@ import {usePalette} from 'lib/hooks/usePalette'
import {ComposerOptsQuote} from 'state/models/ui/shell' import {ComposerOptsQuote} from 'state/models/ui/shell'
import {PostEmbeds} from '.' import {PostEmbeds} from '.'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {InfoCircleIcon} from 'lib/icons'
export function MaybeQuoteEmbed({
embed,
style,
}: {
embed: AppBskyEmbedRecord.View
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
if (
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.value).success
) {
return (
<QuoteEmbed
quote={{
author: embed.record.author,
cid: embed.record.cid,
uri: embed.record.uri,
indexedAt: embed.record.indexedAt,
text: embed.record.value.text,
embeds: embed.record.embeds,
}}
style={style}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
return (
<View style={[styles.errorContainer, pal.borderDark]}>
<InfoCircleIcon size={18} style={pal.text} />
<Text type="lg" style={pal.text}>
Blocked
</Text>
</View>
)
} else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
return (
<View style={[styles.errorContainer, pal.borderDark]}>
<InfoCircleIcon size={18} style={pal.text} />
<Text type="lg" style={pal.text}>
Deleted
</Text>
</View>
)
}
return null
}
export function QuoteEmbed({ export function QuoteEmbed({
quote, quote,
@ -76,4 +130,14 @@ const styles = StyleSheet.create({
paddingLeft: 13, paddingLeft: 13,
paddingRight: 8, paddingRight: 8,
}, },
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
borderRadius: 8,
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 14,
borderWidth: 1,
},
}) })

View File

@ -12,7 +12,6 @@ import {
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyGraphDefs, AppBskyGraphDefs,
} from '@atproto/api' } from '@atproto/api'
@ -24,7 +23,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {YoutubeEmbed} from './YoutubeEmbed' import {YoutubeEmbed} from './YoutubeEmbed'
import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import QuoteEmbed from './QuoteEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage' import {AutoSizedImage} from '../images/AutoSizedImage'
import {CustomFeedEmbed} from './CustomFeedEmbed' import {CustomFeedEmbed} from './CustomFeedEmbed'
import {ListEmbed} from './ListEmbed' import {ListEmbed} from './ListEmbed'
@ -49,25 +48,11 @@ export function PostEmbeds({
// quote post with media // quote post with media
// = // =
if ( if (AppBskyEmbedRecordWithMedia.isView(embed)) {
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.record.value).success
) {
return ( return (
<View style={[styles.stackContainer, style]}> <View style={[styles.stackContainer, style]}>
<PostEmbeds embed={embed.media} /> <PostEmbeds embed={embed.media} />
<QuoteEmbed <MaybeQuoteEmbed embed={embed.record} />
quote={{
author: embed.record.record.author,
cid: embed.record.record.cid,
uri: embed.record.record.uri,
indexedAt: embed.record.record.indexedAt,
text: embed.record.record.value.text,
embeds: embed.record.record.embeds,
}}
/>
</View> </View>
) )
} }
@ -75,25 +60,7 @@ export function PostEmbeds({
// quote post // quote post
// = // =
if (AppBskyEmbedRecord.isView(embed)) { if (AppBskyEmbedRecord.isView(embed)) {
if ( return <MaybeQuoteEmbed embed={embed} style={style} />
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.value).success
) {
return (
<QuoteEmbed
quote={{
author: embed.record.author,
cid: embed.record.cid,
uri: embed.record.uri,
indexedAt: embed.record.indexedAt,
text: embed.record.value.text,
embeds: embed.record.embeds,
}}
style={style}
/>
)
}
} }
// image embed // image embed

View File

@ -66,7 +66,6 @@ export const ModerationBlockedAccounts = withAuthRequired(
testID={`blockedAccount-${index}`} testID={`blockedAccount-${index}`}
key={item.did} key={item.did}
profile={item} profile={item}
overrideModeration
/> />
) )
return ( return (

View File

@ -63,7 +63,6 @@ export const ModerationMutedAccounts = withAuthRequired(
testID={`mutedAccount-${index}`} testID={`mutedAccount-${index}`}
key={item.did} key={item.did}
profile={item} profile={item}
overrideModeration
/> />
) )
return ( return (

View File

@ -232,7 +232,7 @@ export const ProfileScreen = withAuthRequired(
) )
} else if (item instanceof PostsFeedSliceModel) { } else if (item instanceof PostsFeedSliceModel) {
return ( return (
<FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} />
) )
} }
} }
@ -252,7 +252,7 @@ export const ProfileScreen = withAuthRequired(
testID="profileView" testID="profileView"
style={styles.container} style={styles.container}
screenDescription="profile" screenDescription="profile"
moderation={uiState.profile.moderation.view}> moderation={uiState.profile.moderation.account}>
{uiState.profile.hasError ? ( {uiState.profile.hasError ? (
<ErrorScreen <ErrorScreen
testID="profileErrorScreen" testID="profileErrorScreen"

View File

@ -40,10 +40,10 @@
tlds "^1.234.0" tlds "^1.234.0"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
"@atproto/api@^0.4.3": "@atproto/api@^0.5.2":
version "0.4.3" version "0.5.2"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.4.3.tgz#d7e478bf7009df2adaf1ac6051eb3e3fea185c90" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.5.2.tgz#4ba5c57a8737092216e8274ef7ef63384f03d3c6"
integrity sha512-8LdREwmoA58YQDrLS0rohd7cHokhoiXfyYEeNtNlkdO0w/2QpkUCQ1PgPBP2kIRM9PhOEkKp7W3Sn8Te9Qq8jg== integrity sha512-AAYz/IL52efZzaIx6Q8wV20MPK3/P5Dpm2qrFeh+rZHaP4KGPJaqLLYnK+SG9ZPQAHtvc18ZftNQJHB2CZsEbw==
dependencies: dependencies:
"@atproto/common-web" "*" "@atproto/common-web" "*"
"@atproto/uri" "*" "@atproto/uri" "*"