Refactor moderation to apply to accounts, profiles, and posts correctly (#548)

* Add ScreenHider component

* Add blur attribute to UserAvatar and UserBanner

* Remove dead suggested posts component and model

* Bump @atproto/api@0.2.10

* Rework moderation tooling to give a more precise DSL

* Add label mocks

* Apply finer grained moderation controls

* Refactor ProfileCard to just take the profile object

* Apply moderation to user listings and banner

* Apply moderation to notifications

* Fix lint

* Tune avatar & banner blur settings per platform

* 1.24
This commit is contained in:
Paul Frazee 2023-04-27 12:38:23 -05:00 committed by GitHub
parent 51be8474db
commit 1d50ddb378
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1195 additions and 763 deletions

View file

@ -1,66 +0,0 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedPostsModel} from 'state/models/discovery/suggested-posts'
import {s} from 'lib/styles'
import {FeedItem as Post} from '../posts/FeedItem'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedPosts = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedPostsView = React.useMemo<SuggestedPostsModel>(
() => new SuggestedPostsModel(store),
[store],
)
React.useEffect(() => {
if (!suggestedPostsView.hasLoaded) {
suggestedPostsView.setup()
}
}, [store, suggestedPostsView])
return (
<>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Recently, on Bluesky...
</Text>
)}
{suggestedPostsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedPostsView.posts.map(item => (
<Post item={item} key={item._reactKey} showFollowBtn />
))}
</View>
</>
)}
{suggestedPostsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
})

View file

@ -8,7 +8,7 @@ import {
View,
} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
import {AtUri, ComAtprotoLabelDefs} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@ -26,8 +26,14 @@ import {UserAvatar} from '../util/UserAvatar'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {Post} from '../post/Post'
import {Link, TextLink} from '../util/Link'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {
getProfileViewBasicLabelInfo,
getProfileModeration,
} from 'lib/labeling/helpers'
import {ProfileModeration} from 'lib/labeling/types'
const MAX_AUTHORS = 5
@ -38,14 +44,15 @@ interface Author {
handle: string
displayName?: string
avatar?: string
labels?: ComAtprotoLabelDefs.Label[]
moderation: ProfileModeration
}
export const FeedItem = observer(function FeedItem({
export const FeedItem = observer(function ({
item,
}: {
item: NotificationsFeedItemModel
}) {
const store = useStores()
const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
@ -81,27 +88,25 @@ export const FeedItem = observer(function FeedItem({
handle: item.author.handle,
displayName: item.author.displayName,
avatar: item.author.avatar,
labels: item.author.labels,
moderation: getProfileModeration(
store,
getProfileViewBasicLabelInfo(item.author),
),
},
...(item.additional?.map(
({author: {avatar, labels, handle, displayName}}) => {
return {
href: `/profile/${handle}`,
handle,
displayName,
avatar,
labels,
}
},
) || []),
...(item.additional?.map(({author}) => {
return {
href: `/profile/${author.handle}`,
handle: author.handle,
displayName: author.displayName,
avatar: author.avatar,
moderation: getProfileModeration(
store,
getProfileViewBasicLabelInfo(author),
),
}
}) || []),
]
}, [
item.additional,
item.author.avatar,
item.author.displayName,
item.author.handle,
item.author.labels,
])
}, [store, item.additional, item.author])
if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable
@ -264,7 +269,7 @@ function CondensedAuthorsList({
<UserAvatar
size={35}
avatar={authors[0].avatar}
hasWarning={!!authors[0].labels?.length}
moderation={authors[0].moderation.avatar}
/>
</Link>
</View>
@ -277,7 +282,7 @@ function CondensedAuthorsList({
<UserAvatar
size={35}
avatar={author.avatar}
hasWarning={!!author.labels?.length}
moderation={author.moderation.avatar}
/>
</View>
))}
@ -335,7 +340,7 @@ function ExpandedAuthorsList({
<UserAvatar
size={35}
avatar={author.avatar}
hasWarning={!!author.labels?.length}
moderation={author.moderation.avatar}
/>
</View>
<View style={s.flex1}>

View file

@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
// loaded
// =
const renderItem = ({item}: {item: LikeItem}) => (
<ProfileCardWithFollowBtn
key={item.actor.did}
did={item.actor.did}
handle={item.actor.handle}
displayName={item.actor.displayName}
avatar={item.actor.avatar}
labels={item.actor.labels}
isFollowedBy={!!item.actor.viewer?.followedBy}
/>
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
)
return (
<FlatList

View file

@ -58,15 +58,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
// loaded
// =
const renderItem = ({item}: {item: RepostedByItem}) => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return (
<FlatList

View file

@ -145,21 +145,17 @@ export const PostThreadItem = observer(function PostThreadItem({
if (item._isHighlightedPost) {
return (
<View
<PostHider
testID={`postThreadItem-by-${item.post.author.handle}`}
style={[
styles.outer,
styles.outerHighlighted,
{borderTopColor: pal.colors.border},
pal.view,
]}>
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
moderation={item.moderation.thread}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle} asAnchor>
<UserAvatar
size={52}
avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length}
moderation={item.moderation.avatar}
/>
</Link>
</View>
@ -218,9 +214,7 @@ export const PostThreadItem = observer(function PostThreadItem({
</View>
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider
isMuted={item.post.author.viewer?.muted === true}
labels={item.post.labels}>
<ContentHider moderation={item.moderation.view}>
{item.richText?.text ? (
<View
style={[
@ -300,7 +294,7 @@ export const PostThreadItem = observer(function PostThreadItem({
/>
</View>
</View>
</View>
</PostHider>
)
} else {
return (
@ -309,8 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({
testID={`postThreadItem-by-${item.post.author.handle}`}
href={itemHref}
style={[styles.outer, {borderColor: pal.colors.border}, pal.view]}
isMuted={item.post.author.viewer?.muted === true}
labels={item.post.labels}>
moderation={item.moderation.thread}>
{item._showParentReplyLine && (
<View
style={[
@ -333,7 +326,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<UserAvatar
size={52}
avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length}
moderation={item.moderation.avatar}
/>
</Link>
</View>
@ -347,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({
did={item.post.author.did}
/>
<ContentHider
labels={item.post.labels}
moderation={item.moderation.thread}
containerStyle={styles.contentHider}>
{item.richText?.text ? (
<View style={styles.postTextContainer}>

View file

@ -206,8 +206,7 @@ const PostLoaded = observer(
<PostHider
href={itemHref}
style={[styles.outer, pal.view, pal.border, style]}
isMuted={item.post.author.viewer?.muted === true}
labels={item.post.labels}>
moderation={item.moderation.list}>
{showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
@ -215,7 +214,7 @@ const PostLoaded = observer(
<UserAvatar
size={52}
avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length}
moderation={item.moderation.avatar}
/>
</Link>
</View>
@ -247,7 +246,7 @@ const PostLoaded = observer(
</View>
)}
<ContentHider
labels={item.post.labels}
moderation={item.moderation.list}
containerStyle={styles.contentHider}>
{item.richText?.text ? (
<View style={styles.postTextContainer}>

View file

@ -1,62 +0,0 @@
import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {PostModel} from 'state/models/content/post'
import {useStores} from 'state/index'
export const PostText = observer(function PostText({
uri,
style,
}: {
uri: string
style?: StyleProp<TextStyle>
}) {
const store = useStores()
const [model, setModel] = useState<PostModel | undefined>()
useEffect(() => {
if (model?.uri === uri) {
return // no change needed? or trigger refresh?
}
const newModel = new PostModel(store, uri)
setModel(newModel)
newModel.setup().catch(err => store.log.error('Failed to fetch post', err))
}, [uri, model?.uri, store])
// loading
// =
if (!model || model.isLoading || model.uri !== uri) {
return (
<View>
<LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
<LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
<LoadingPlaceholder width={100} height={8} style={styles.mt6} />
</View>
)
}
// error
// =
if (model.hasError) {
return (
<View>
<ErrorMessage style={style} message={model.error} />
</View>
)
}
// loaded
// =
return (
<View>
<Text style={style}>{model.text}</Text>
</View>
)
})
const styles = StyleSheet.create({
mt6: {marginTop: 6},
})

View file

@ -30,14 +30,13 @@ export const FeedItem = observer(function ({
isThreadChild,
isThreadParent,
showFollowBtn,
ignoreMuteFor,
}: {
item: PostsFeedItemModel
isThreadChild?: boolean
isThreadParent?: boolean
showReplyLine?: boolean
showFollowBtn?: boolean
ignoreMuteFor?: string
ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
}) {
const store = useStores()
const pal = usePalette('default')
@ -134,8 +133,6 @@ export const FeedItem = observer(function ({
}
const isSmallTop = isThreadChild
const isMuted =
item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
const outerStyles = [
styles.outer,
pal.view,
@ -149,8 +146,7 @@ export const FeedItem = observer(function ({
testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles}
href={itemHref}
isMuted={isMuted}
labels={item.post.labels}>
moderation={item.moderation.list}>
{isThreadChild && (
<View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@ -200,7 +196,7 @@ export const FeedItem = observer(function ({
<UserAvatar
size={52}
avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length}
moderation={item.moderation.avatar}
/>
</Link>
</View>
@ -236,7 +232,7 @@ export const FeedItem = observer(function ({
</View>
)}
<ContentHider
labels={item.post.labels}
moderation={item.moderation.list}
containerStyle={styles.contentHider}>
{item.richText?.text ? (
<View style={styles.postTextContainer}>

View file

@ -1,7 +1,7 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api'
import {AppBskyActorDefs} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
@ -10,143 +10,159 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {FollowButton} from './FollowButton'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {
getProfileViewBasicLabelInfo,
getProfileModeration,
} from 'lib/labeling/helpers'
import {ModerationBehaviorCode} from 'lib/labeling/types'
export function ProfileCard({
testID,
handle,
displayName,
avatar,
description,
labels,
isFollowedBy,
noBg,
noBorder,
followers,
renderButton,
}: {
testID?: string
handle: string
displayName?: string
avatar?: string
description?: string
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
return (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${handle}`}
title={handle}
asAnchor>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} />
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(displayName || handle)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{handle}
</Text>
{isFollowedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Follows You
</Text>
export const ProfileCard = observer(
({
testID,
profile,
noBg,
noBorder,
followers,
renderButton,
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: () => JSX.Element
}) => {
const store = useStores()
const pal = usePalette('default')
const moderation = getProfileModeration(
store,
getProfileViewBasicLabelInfo(profile),
)
if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
return null
}
return (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${profile.handle}`}
title={profile.handle}
asAnchor>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(profile.displayName || profile.handle)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{profile.handle}
</Text>
{!!profile.viewer?.followedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Follows You
</Text>
</View>
</View>
</View>
)}
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
{profile.description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{profile.description}
</Text>
</View>
) : undefined}
</View>
{description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{description}
</Text>
</View>
) : undefined}
{followers?.length ? (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followers.map(f => f.displayName || f.handle).join(', ')}
</Text>
{followers.slice(0, 3).map(f => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} />
</View>
<FollowersList followers={followers} />
</Link>
)
},
)
const FollowersList = observer(
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
const store = useStores()
const pal = usePalette('default')
if (!followers?.length) {
return null
}
const followersWithMods = followers
.map(f => ({
f,
mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
}))
.filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
return (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View>
))}
</View>
) : undefined}
</Link>
)
}
</View>
))}
</View>
)
},
)
export const ProfileCardWithFollowBtn = observer(
({
did,
handle,
displayName,
avatar,
description,
labels,
isFollowedBy,
profile,
noBg,
noBorder,
followers,
}: {
did: string
handle: string
displayName?: string
avatar?: string
description?: string
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => {
const store = useStores()
const isMe = store.me.handle === handle
const isMe = store.me.handle === profile.handle
return (
<ProfileCard
handle={handle}
displayName={displayName}
avatar={avatar}
description={description}
labels={labels}
isFollowedBy={isFollowedBy}
profile={profile}
noBg={noBg}
noBorder={noBorder}
followers={followers}
renderButton={isMe ? undefined : () => <FollowButton did={did} />}
renderButton={
isMe ? undefined : () => <FollowButton did={profile.did} />
}
/>
)
},

View file

@ -61,15 +61,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return (
<FlatList

View file

@ -58,15 +58,7 @@ export const ProfileFollows = observer(function ProfileFollows({
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return (
<FlatList

View file

@ -26,7 +26,7 @@ import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels'
import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
@ -219,7 +219,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
])
return (
<View style={pal.view}>
<UserBanner banner={view.banner} />
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
@ -332,7 +332,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
richText={view.descriptionRichText}
/>
) : undefined}
<ProfileHeaderLabels labels={view.labels} />
<ProfileHeaderWarnings moderation={view.moderation.view} />
{view.viewer.muted ? (
<View
testID="profileHeaderMutedNotice"
@ -364,7 +364,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
<UserAvatar
size={80}
avatar={view.avatar}
hasWarning={!!view.labels?.length}
moderation={view.moderation.avatar}
/>
</View>
</TouchableWithoutFeedback>

View file

@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
return (
<ScrollView style={pal.view}>
{model.profiles.map(item => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
labels={item.labels}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
))}
<View style={s.footerSpacer} />
<View style={s.footerSpacer} />

View file

@ -144,18 +144,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.ref.did}
did={item.ref.did}
handle={item.ref.handle}
displayName={item.ref.displayName}
avatar={item.ref.avatar}
labels={item.ref.labels}
profile={item.ref}
noBg
noBorder
description={
item.ref.description
? (item.ref as AppBskyActorDefs.ProfileView).description
: ''
}
followers={
item.ref.followers
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
@ -170,18 +161,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.view.did}
did={item.view.did}
handle={item.view.handle}
displayName={item.view.displayName}
avatar={item.view.avatar}
labels={item.view.labels}
profile={item.view}
noBg
noBorder
description={
item.view.description
? (item.view as AppBskyActorDefs.ProfileView).description
: ''
}
/>
</View>
)
@ -191,19 +173,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.suggested.did}
did={item.suggested.did}
handle={item.suggested.handle}
displayName={item.suggested.displayName}
avatar={item.suggested.avatar}
labels={item.suggested.labels}
profile={item.suggested}
noBg
noBorder
description={
item.suggested.description
? (item.suggested as AppBskyActorDefs.ProfileView)
.description
: ''
}
/>
</View>
)

View file

@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<UserAvatar
avatar={opts.authorAvatar}
size={16}
hasWarning={opts.authorHasWarning}
// TODO moderation
/>
</View>
)}

View file

@ -13,8 +13,11 @@ import {useStores} from 'state/index'
import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {AvatarModeration} from 'lib/labeling/types'
const BLUR_AMOUNT = isWeb ? 5 : 100
function DefaultAvatar({size}: {size: number}) {
return (
@ -40,12 +43,12 @@ function DefaultAvatar({size}: {size: number}) {
export function UserAvatar({
size,
avatar,
hasWarning,
moderation,
onSelectNewAvatar,
}: {
size: number
avatar?: string | null
hasWarning?: boolean
moderation?: AvatarModeration
onSelectNewAvatar?: (img: RNImage | null) => void
}) {
const store = useStores()
@ -114,7 +117,7 @@ export function UserAvatar({
)
const warning = useMemo(() => {
if (!hasWarning) {
if (!moderation?.warn) {
return null
}
return (
@ -126,7 +129,7 @@ export function UserAvatar({
/>
</View>
)
}, [hasWarning, size, pal])
}, [moderation?.warn, size, pal])
// onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? (
@ -159,13 +162,15 @@ export function UserAvatar({
/>
</View>
</DropdownButton>
) : avatar ? (
) : avatar &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<View style={{width: size, height: size}}>
<HighPriorityImage
testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
contentFit="cover"
source={{uri: avatar}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/>
{warning}
</View>

View file

@ -13,13 +13,16 @@ import {
} from 'lib/hooks/usePermissions'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection'
export function UserBanner({
banner,
moderation,
onSelectNewBanner,
}: {
banner?: string | null
moderation?: AvatarModeration
onSelectNewBanner?: (img: TImage | null) => void
}) {
const store = useStores()
@ -107,12 +110,14 @@ export function UserBanner({
/>
</View>
</DropdownButton>
) : banner ? (
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image
testID="userBannerImage"
style={styles.bannerImage}
resizeMode="cover"
source={{uri: banner}}
blurRadius={moderation?.blur ? 100 : 0}
/>
) : (
<View

View file

@ -35,7 +35,7 @@ export function ErrorScreen({
]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textInverted}
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>

View file

@ -6,32 +6,31 @@ import {
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
export function ContentHider({
testID,
isMuted,
labels,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
isMuted?: boolean
labels: ComAtprotoLabelDefs.Label[] | undefined
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const store = useStores()
const labelPref = store.preferences.getLabelPreference(labels)
if (!isMuted && labelPref.pref === 'show') {
if (
moderation.behavior === ModerationBehaviorCode.Show ||
moderation.behavior === ModerationBehaviorCode.Warn ||
moderation.behavior === ModerationBehaviorCode.WarnImages
) {
return (
<View testID={testID} style={style}>
{children}
@ -39,7 +38,7 @@ export function ContentHider({
)
}
if (labelPref.pref === 'hide') {
if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null
}
@ -52,11 +51,7 @@ export function ContentHider({
override && styles.descriptionOpen,
]}>
<Text type="md" style={pal.textLight}>
{isMuted ? (
<>Post from an account you muted.</>
) : (
<>Warning: {labelPref.desc.warning || labelPref.desc.title}</>
)}
{moderation.reason || 'Content warning'}
</Text>
<TouchableOpacity
style={styles.showBtn}

View file

@ -6,77 +6,72 @@ import {
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {useStores} from 'state/index'
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function PostHider({
testID,
href,
isMuted,
labels,
moderation,
style,
children,
}: React.PropsWithChildren<{
testID?: string
href: string
isMuted: boolean | undefined
labels: ComAtprotoLabelDefs.Label[] | undefined
href?: string
moderation: ModerationBehavior
style: StyleProp<ViewStyle>
}>) {
const store = useStores()
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view
const labelPref = store.preferences.getLabelPreference(labels)
if (labelPref.pref === 'hide') {
return <></>
if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null
}
if (!isMuted) {
// NOTE: any further label enforcement should occur in ContentContainer
if (moderation.behavior === ModerationBehaviorCode.Warn) {
return (
<Link testID={testID} style={style} href={href} noFeedback>
{children}
</Link>
<>
<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)}>
<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 (
<>
<View style={[styles.description, bg, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
Post from an account you muted.
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<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>
)}
</>
<Link testID={testID} style={style} href={href} noFeedback>
{children}
</Link>
)
}

View file

@ -1,55 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {getLabelValueGroup} from 'lib/labeling/helpers'
export function ProfileHeaderLabels({
labels,
}: {
labels: ComAtprotoLabelDefs.Label[] | undefined
}) {
const palErr = usePalette('error')
if (!labels?.length) {
return null
}
return (
<>
{labels.map((label, i) => {
const labelGroup = getLabelValueGroup(label?.val || '')
return (
<View
key={`${label.val}-${i}`}
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 for{' '}
{(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}.
</Text>
</View>
)
})}
</>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View file

@ -0,0 +1,44 @@
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

@ -0,0 +1,129 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {NavigationProp} from 'lib/routes/types'
import {Text} from '../text/Text'
import {Button} from '../forms/Button'
import {isDesktopWeb} from 'platform/detection'
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function ScreenHider({
testID,
screenDescription,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
screenDescription: string
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const [override, setOverride] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
return (
<View style={[styles.container, pal.view, containerStyle]}>
<View style={styles.iconContainer}>
<View style={[styles.icon, palInverted.view]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>
</View>
<Text type="title-2xl" style={[styles.title, pal.text]}>
Content Warning
</Text>
<Text type="2xl" style={[styles.description, pal.textLight]}>
This {screenDescription} has been flagged:{' '}
{moderation.reason || 'Content warning'}
</Text>
{!isDesktopWeb && <View style={styles.spacer} />}
<View style={styles.btnContainer}>
<Button type="inverted" onPress={onPressBack} style={styles.btn}>
<Text type="button-lg" style={pal.textInverted}>
Go back
</Text>
</Button>
{!moderation.noOverride && (
<Button
type="default"
onPress={() => setOverride(v => !v)}
style={styles.btn}>
<Text type="button-lg" style={pal.text}>
Show anyway
</Text>
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
spacer: {
flex: 1,
},
container: {
flex: 1,
paddingTop: 100,
paddingBottom: 150,
},
iconContainer: {
alignItems: 'center',
marginBottom: 10,
},
icon: {
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
title: {
textAlign: 'center',
marginBottom: 10,
},
description: {
marginBottom: 10,
paddingHorizontal: 20,
textAlign: 'center',
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginVertical: 10,
gap: 10,
},
btn: {
paddingHorizontal: 20,
paddingVertical: 14,
},
})