diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/Confirm.tsx | 3 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 64 | ||||
-rw-r--r-- | src/view/com/posts/FeedSlice.tsx | 4 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 7 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 625 | ||||
-rw-r--r-- | src/view/index.ts | 2 | ||||
-rw-r--r-- | src/view/screens/AppPasswords.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/BlockedAccounts.tsx | 172 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 25 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 22 |
11 files changed, 659 insertions, 273 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index c30d881ec..5ccc229d6 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 = { diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 63877fe5d..6f7b062cf 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -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', diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 6e387b8d0..fe1822acb 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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, }, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 651b69bff..5a191ac10 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -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 ( diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 154344388..66c172141 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -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 } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index d1104d184..719b84e20 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -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 onPressBack = React.useCallback(() => { - navigation.goBack() - }, [navigation]) - - 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), - ) - }, [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 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, +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 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), + ) + }, [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()}`) + } + }, }) - items.push({ - testID: 'profileHeaderDropdownReportBtn', - label: 'Report Account', - onPress: onPressReportAccount, + }, [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()}`) + } + }, }) - } - 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> - ) : ( + }, [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], + ) + 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 blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) + + 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} - </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> - </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> + <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.descriptionRichText ? ( - <RichText - testID="profileHeaderDescription" - style={[styles.description, pal.text]} - numberOfLines={15} - richText={view.descriptionRichText} - /> - ) : undefined} - <ProfileHeaderWarnings moderation={view.moderation.view} /> - {view.viewer.muted ? ( + {!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 - 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}, }) diff --git a/src/view/index.ts b/src/view/index.ts index 93c6fccc5..8de035868 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -15,6 +15,7 @@ import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotate import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' +import {faBan} from '@fortawesome/free-solid-svg-icons/faBan' import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark' @@ -90,6 +91,7 @@ export function setup() { faArrowRotateLeft, faArrowsRotate, faAt, + faBan, faBars, faBell, farBell, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index f957a45e0..4e20558b7 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -27,7 +27,7 @@ export const AppPasswords = withAuthRequired( useFocusEffect( React.useCallback(() => { - screen('Settings') + screen('AppPasswords') store.shell.setMinimalShellMode(false) }, [screen, store]), ) diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/BlockedAccounts.tsx new file mode 100644 index 000000000..195068510 --- /dev/null +++ b/src/view/screens/BlockedAccounts.tsx @@ -0,0 +1,172 @@ +import React, {useMemo} from 'react' +import { + ActivityIndicator, + FlatList, + RefreshControl, + StyleSheet, + View, +} from 'react-native' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' +import {Text} from '../com/util/text/Text' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {CommonNavigatorParams} from 'lib/routes/types' +import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts' +import {useAnalytics} from 'lib/analytics' +import {useFocusEffect} from '@react-navigation/native' +import {ViewHeader} from '../com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {ProfileCard} from 'view/com/profile/ProfileCard' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'> +export const BlockedAccounts = withAuthRequired( + observer(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const {screen} = useAnalytics() + const blockedAccounts = useMemo( + () => new BlockedAccountsModel(store), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + screen('BlockedAccounts') + store.shell.setMinimalShellMode(false) + blockedAccounts.refresh() + }, [screen, store, blockedAccounts]), + ) + + const onRefresh = React.useCallback(() => { + blockedAccounts.refresh() + }, [blockedAccounts]) + const onEndReached = React.useCallback(() => { + blockedAccounts + .loadMore() + .catch(err => + store.log.error('Failed to load more blocked accounts', err), + ) + }, [blockedAccounts, store]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`blockedAccount-${index}`} + key={item.did} + profile={item} + overrideModeration + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isDesktopWeb && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="blockedAccountsScreen"> + <ViewHeader title="Blocked Accounts" showOnDesktop /> + <Text + type="sm" + style={[ + styles.description, + pal.text, + isDesktopWeb && styles.descriptionDesktop, + ]}> + 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. + </Text> + {!blockedAccounts.hasContent ? ( + <View style={[pal.border, !isDesktopWeb && styles.flex1]}> + <View style={[styles.empty, pal.viewLight]}> + <Text type="lg" style={[pal.text, styles.emptyText]}> + You have not blocked any accounts yet. To block an account, go + to their profile and selected "Block account" from the menu on + their account. + </Text> + </View> + </View> + ) : ( + <FlatList + style={[!isDesktopWeb && styles.flex1]} + data={blockedAccounts.blocks} + keyExtractor={(item: ActorDefs.ProfileView) => item.did} + refreshControl={ + <RefreshControl + refreshing={blockedAccounts.isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + ListFooterComponent={() => ( + <View style={styles.footer}> + {blockedAccounts.isLoading && <ActivityIndicator />} + </View> + )} + extraData={blockedAccounts.isLoading} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + title: { + textAlign: 'center', + marginTop: 12, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 30, + marginBottom: 14, + }, + descriptionDesktop: { + marginTop: 14, + }, + + flex1: { + flex: 1, + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + emptyText: { + textAlign: 'center', + }, + + footer: { + height: 200, + paddingTop: 20, + }, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 4be117932..5fb212554 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -116,6 +116,24 @@ export const ProfileScreen = withAuthRequired( } else if (item === ProfileUiModel.LOADING_ITEM) { return <PostFeedLoadingPlaceholder /> } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + <EmptyState + icon="ban" + message="Posts hidden" + style={styles.emptyState} + /> + ) + } + if (uiState.feed.isBlockedBy) { + return ( + <EmptyState + icon="ban" + message="Posts hidden" + style={styles.emptyState} + /> + ) + } return ( <View style={s.p5}> <ErrorMessage @@ -137,7 +155,12 @@ export const ProfileScreen = withAuthRequired( } return <View /> }, - [onPressTryAgain, uiState.profile.did], + [ + onPressTryAgain, + uiState.profile.did, + uiState.feed.isBlocking, + uiState.feed.isBlockedBy, + ], ) return ( diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 89e2d78b4..ef02e8189 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -255,7 +255,7 @@ export const SettingsScreen = withAuthRequired( <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> - Advanced + Moderation </Text> <TouchableOpacity testID="contentFilteringBtn" @@ -272,6 +272,26 @@ export const SettingsScreen = withAuthRequired( </Text> </TouchableOpacity> <Link + testID="blockedAccountsBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + href="/settings/blocked-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="ban" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Blocked accounts + </Text> + </Link> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Advanced + </Text> + <Link testID="appPasswordBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} href="/settings/app-passwords"> |