Implement blocks (#554)

* Quick fix to prompt

* Add blocked accounts screen

* Add blocking tools to profile

* Blur avis/banners of blocked users

* Factor blocking state into moderation dsl

* Filter post slices from the feed if any are hidden

* Handle various block UIs

* Filter in the client on blockedBy

* Implement block list

* Fix some copy

* Bump deps

* Fix lint
This commit is contained in:
Paul Frazee 2023-04-28 20:03:13 -05:00 committed by GitHub
parent e68aa75429
commit a95c03e280
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 974 additions and 291 deletions

View file

@ -190,11 +190,7 @@ export const ComposePost = observer(function ComposePost({
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
const selectTextInputPlaceholder = replyTo
? 'Write your reply'
: gallery.isEmpty
? 'Write a comment'
: "What's up?"
const selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?"
const canSelectImages = gallery.size < 4
const viewStyles = {

View file

@ -11,6 +11,7 @@ import {s, colors} from 'lib/styles'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
export const snapPoints = [300]
@ -77,7 +78,7 @@ const styles = StyleSheet.create({
container: {
flex: 1,
padding: 10,
paddingBottom: 60,
paddingBottom: isDesktopWeb ? 0 : 60,
},
title: {
textAlign: 'center',

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {
PostThreadModel,
@ -27,11 +28,17 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
const BOTTOM_COMPONENT = {
_reactKey: '__bottom_component__',
_isHighlightedPost: false,
}
type YieldedItem = PostThreadItemModel | typeof REPLY_PROMPT
type YieldedItem =
| PostThreadItemModel
| typeof REPLY_PROMPT
| typeof DELETED
| typeof BLOCKED
export const PostThread = observer(function PostThread({
uri,
@ -103,6 +110,22 @@ export const PostThread = observer(function PostThread({
({item}: {item: YieldedItem}) => {
if (item === REPLY_PROMPT) {
return <ComposePrompt onPressCompose={onPressReply} />
} else if (item === DELETED) {
return (
<View style={[pal.border, pal.viewLight, styles.missingItem]}>
<Text type="lg-bold" style={pal.textLight}>
Deleted post.
</Text>
</View>
)
} else if (item === BLOCKED) {
return (
<View style={[pal.border, pal.viewLight, styles.missingItem]}>
<Text type="lg-bold" style={pal.textLight}>
Blocked post.
</Text>
</View>
)
} else if (item === BOTTOM_COMPONENT) {
// HACK
// due to some complexities with how flatlist works, this is the easiest way
@ -177,6 +200,30 @@ export const PostThread = observer(function PostThread({
</CenteredView>
)
}
if (view.isBlocked) {
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb5]}>
Post hidden
</Text>
<Text type="md" style={[pal.text, s.mb10]}>
You have blocked the author or you have been blocked by the author.
</Text>
<TouchableOpacity onPress={onPressBack}>
<Text type="2xl" style={pal.link}>
<FontAwesomeIcon
icon="angle-left"
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
size={14}
/>
Back
</Text>
</TouchableOpacity>
</View>
</CenteredView>
)
}
// loaded
// =
@ -208,8 +255,10 @@ function* flattenThread(
isAscending = false,
): Generator<YieldedItem, void> {
if (post.parent) {
if ('notFound' in post.parent && post.parent.notFound) {
// TODO render not found
if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
yield DELETED
} else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
yield BLOCKED
} else {
yield* flattenThread(post.parent as PostThreadItemModel, true)
}
@ -220,8 +269,8 @@ function* flattenThread(
}
if (post.replies?.length) {
for (const reply of post.replies) {
if ('notFound' in reply && reply.notFound) {
// TODO render not found
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
yield DELETED
} else {
yield* flattenThread(reply as PostThreadItemModel)
}
@ -238,6 +287,11 @@ const styles = StyleSheet.create({
paddingVertical: 14,
borderRadius: 6,
},
missingItem: {
borderTop: 1,
paddingHorizontal: 18,
paddingVertical: 18,
},
bottomBorder: {
borderBottomWidth: 1,
},

View file

@ -7,6 +7,7 @@ import {Text} from '../util/text/Text'
import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette'
import {ModerationBehaviorCode} from 'lib/labeling/types'
export function FeedSlice({
slice,
@ -17,6 +18,9 @@ export function FeedSlice({
showFollowBtn?: boolean
ignoreMuteFor?: string
}) {
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
return null
}
if (slice.isThread && slice.items.length > 3) {
const last = slice.items.length - 1
return (

View file

@ -23,6 +23,7 @@ export const ProfileCard = observer(
noBg,
noBorder,
followers,
overrideModeration,
renderButton,
}: {
testID?: string
@ -30,6 +31,7 @@ export const ProfileCard = observer(
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
overrideModeration?: boolean
renderButton?: () => JSX.Element
}) => {
const store = useStores()
@ -40,7 +42,10 @@ export const ProfileCard = observer(
getProfileViewBasicLabelInfo(profile),
)
if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
if (
moderation.list.behavior === ModerationBehaviorCode.Hide &&
!overrideModeration
) {
return null
}

View file

@ -96,281 +96,377 @@ export const ProfileHeader = observer(
},
)
const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
view,
onRefreshAll,
hideBackButton = false,
}: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const ProfileHeaderLoaded = observer(
({view, onRefreshAll, hideBackButton = false}: Props) => {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressAvi = React.useCallback(() => {
if (view.avatar) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}, [store, view])
const onPressAvi = React.useCallback(() => {
if (view.avatar) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}, [store, view])
const onPressToggleFollow = React.useCallback(() => {
view?.toggleFollowing().then(
() => {
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
)
},
err => store.log.error('Failed to toggle follow', err),
const onPressToggleFollow = React.useCallback(() => {
view?.toggleFollowing().then(
() => {
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
)
},
err => store.log.error('Failed to toggle follow', err),
)
}, [view, store])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
const onPressFollowers = React.useCallback(() => {
track('ProfileHeader:FollowersButtonClicked')
navigation.push('ProfileFollowers', {name: view.handle})
}, [track, navigation, view])
const onPressFollows = React.useCallback(() => {
track('ProfileHeader:FollowsButtonClicked')
navigation.push('ProfileFollows', {name: view.handle})
}, [track, navigation, view])
const onPressShare = React.useCallback(async () => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(`/profile/${view.handle}`)
shareUrl(url)
}, [track, view])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await view.muteAccount()
Toast.show('Account muted')
} catch (e: any) {
store.log.error('Failed to mute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await view.unmuteAccount()
Toast.show('Account unmuted')
} catch (e: any) {
store.log.error('Failed to unmute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
store.shell.openModal({
name: 'confirm',
title: 'Block Account',
message:
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours.',
onPressConfirm: async () => {
try {
await view.blockAccount()
onRefreshAll()
Toast.show('Account blocked')
} catch (e: any) {
store.log.error('Failed to block account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
},
})
}, [track, view, store, onRefreshAll])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
store.shell.openModal({
name: 'confirm',
title: 'Unblock Account',
message:
'The account will be able to interact with you after unblocking. (You can always block again in the future.)',
onPressConfirm: async () => {
try {
await view.unblockAccount()
onRefreshAll()
Toast.show('Account unblocked')
} catch (e: any) {
store.log.error('Failed to block unaccount', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
},
})
}, [track, view, store, onRefreshAll])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal({
name: 'report-account',
did: view.did,
})
}, [track, store, view])
const isMe = React.useMemo(
() => store.me.did === view.did,
[store.me.did, view.did],
)
}, [view, store])
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: 'Share',
onPress: onPressShare,
},
]
if (!isMe) {
items.push({sep: true})
if (!view.viewer.blocking) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.viewer.muted
? onPressUnmuteAccount
: onPressMuteAccount,
})
}
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
onPress: view.viewer.blocking
? onPressUnblockAccount
: onPressBlockAccount,
})
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
})
}
return items
}, [
isMe,
view.viewer.muted,
view.viewer.blocking,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
const onPressFollowers = React.useCallback(() => {
track('ProfileHeader:FollowersButtonClicked')
navigation.push('ProfileFollowers', {name: view.handle})
}, [track, navigation, view])
const onPressFollows = React.useCallback(() => {
track('ProfileHeader:FollowsButtonClicked')
navigation.push('ProfileFollows', {name: view.handle})
}, [track, navigation, view])
const onPressShare = React.useCallback(async () => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(`/profile/${view.handle}`)
shareUrl(url)
}, [track, view])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await view.muteAccount()
Toast.show('Account muted')
} catch (e: any) {
store.log.error('Failed to mute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await view.unmuteAccount()
Toast.show('Account unmuted')
} catch (e: any) {
store.log.error('Failed to unmute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal({
name: 'report-account',
did: view.did,
})
}, [track, store, view])
const isMe = React.useMemo(
() => store.me.did === view.did,
[store.me.did, view.did],
)
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownSahreBtn',
label: 'Share',
onPress: onPressShare,
},
]
if (!isMe) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
})
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
})
}
return items
}, [
isMe,
view.viewer.muted,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressReportAccount,
])
return (
<View style={pal.view}>
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Edit Profile
</Text>
</TouchableOpacity>
) : (
return (
<View style={pal.view}>
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Edit Profile
</Text>
</TouchableOpacity>
) : view.viewer.blocking ? (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<Text type="button" style={[pal.text, s.bold]}>
Unblock
</Text>
</TouchableOpacity>
) : !view.viewer.blockedBy ? (
<>
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
<TouchableOpacity
testID="unfollowBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
size={14}
/>
<Text type="button" style={pal.text}>
Following
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
testID="followBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.primaryBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white as FontAwesomeIconStyle, s.mr5]}
/>
<Text type="button" style={[s.white, s.bold]}>
Follow
</Text>
</TouchableOpacity>
)}
</>
) : null}
{dropdownItems?.length ? (
<DropdownButton
testID="profileHeaderDropdownBtn"
type="bare"
items={dropdownItems}
style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" style={[pal.text]} />
</DropdownButton>
) : undefined}
</View>
<View>
<Text
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
{sanitizeDisplayName(view.displayName || view.handle)}
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy && !blockHide ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text>
</View>
{!blockHide && (
<>
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
<View style={styles.metricsLine}>
<TouchableOpacity
testID="unfollowBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
size={14}
/>
<Text type="button" style={pal.text}>
Following
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.followersCount}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
testID="followBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.primaryBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white as FontAwesomeIconStyle, s.mr5]}
/>
<Text type="button" style={[s.white, s.bold]}>
Follow
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.followsCount}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</TouchableOpacity>
)}
<View style={[s.flexRow, s.mr10]}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.postsCount}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')}
</Text>
</View>
</View>
{view.descriptionRichText ? (
<RichText
testID="profileHeaderDescription"
style={[styles.description, pal.text]}
numberOfLines={15}
richText={view.descriptionRichText}
/>
) : undefined}
</>
)}
{dropdownItems?.length ? (
<DropdownButton
testID="profileHeaderDropdownBtn"
type="bare"
items={dropdownItems}
style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" style={[pal.text]} />
</DropdownButton>
) : undefined}
<ProfileHeaderWarnings moderation={view.moderation.view} />
<View style={styles.moderationLines}>
{view.viewer.blocking ? (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
<Text type="md" style={[s.mr2, pal.text]}>
Account blocked
</Text>
</View>
) : view.viewer.muted ? (
<View
testID="profileHeaderMutedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]}
/>
<Text type="md" style={[s.mr2, pal.text]}>
Account muted
</Text>
</View>
) : undefined}
{view.viewer.blockedBy && (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
<Text type="md" style={[s.mr2, pal.text]}>
This account has blocked you
</Text>
</View>
)}
</View>
</View>
<View>
<Text
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
{sanitizeDisplayName(view.displayName || view.handle)}
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
{!isDesktopWeb && !hideBackButton && (
<TouchableWithoutFeedback
onPress={onPressBack}
hitSlop={BACK_HITSLOP}>
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
</BlurView>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
<TouchableOpacity
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.followersCount}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.followsCount}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</TouchableOpacity>
<View style={[s.flexRow, s.mr10]}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}>
{view.postsCount}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')}
</Text>
</View>
</View>
{view.descriptionRichText ? (
<RichText
testID="profileHeaderDescription"
style={[styles.description, pal.text]}
numberOfLines={15}
richText={view.descriptionRichText}
/>
) : undefined}
<ProfileHeaderWarnings moderation={view.moderation.view} />
{view.viewer.muted ? (
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}>
<View
testID="profileHeaderMutedNotice"
style={[styles.detailLine, pal.btn, s.p5]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]}
style={[
pal.view,
{borderColor: pal.colors.background},
styles.avi,
]}>
<UserAvatar
size={80}
avatar={view.avatar}
moderation={view.moderation.avatar}
/>
<Text type="md" style={[s.mr2, pal.text]}>
Account muted
</Text>
</View>
) : undefined}
</View>
{!isDesktopWeb && !hideBackButton && (
<TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}>
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
</BlurView>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}>
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<UserAvatar
size={80}
avatar={view.avatar}
moderation={view.moderation.avatar}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
})
</View>
)
},
)
const styles = StyleSheet.create({
banner: {
@ -460,6 +556,19 @@ const styles = StyleSheet.create({
paddingVertical: 2,
},
moderationLines: {
gap: 6,
},
moderationNotice: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
br40: {borderRadius: 40},
br50: {borderRadius: 50},
})