From a95c03e280ca153ba4a98d6b81ff9d743d4adcaa Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 28 Apr 2023 20:03:13 -0500 Subject: 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 --- src/Navigation.tsx | 6 +- src/lib/labeling/helpers.ts | 94 ++++- src/lib/labeling/types.ts | 4 + src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/state/models/content/post-thread.ts | 43 +- src/state/models/content/profile.ts | 32 ++ src/state/models/feeds/notifications.ts | 4 + src/state/models/feeds/posts.ts | 25 +- src/state/models/lists/blocked-accounts.ts | 106 +++++ src/view/com/composer/Composer.tsx | 6 +- src/view/com/modals/Confirm.tsx | 3 +- src/view/com/post-thread/PostThread.tsx | 64 ++- src/view/com/posts/FeedSlice.tsx | 4 + src/view/com/profile/ProfileCard.tsx | 7 +- src/view/com/profile/ProfileHeader.tsx | 625 +++++++++++++++++------------ src/view/index.ts | 2 + src/view/screens/AppPasswords.tsx | 2 +- src/view/screens/BlockedAccounts.tsx | 172 ++++++++ src/view/screens/Profile.tsx | 25 +- src/view/screens/Settings.tsx | 22 +- 21 files changed, 965 insertions(+), 283 deletions(-) create mode 100644 src/state/models/lists/blocked-accounts.ts create mode 100644 src/view/screens/BlockedAccounts.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index d5ffb1539..3a9392fb8 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -27,6 +27,8 @@ import {colors} from 'lib/styles' import {isNative} from 'platform/detection' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {router} from './routes' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' @@ -46,9 +48,8 @@ import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' import {TermsOfServiceScreen} from './view/screens/TermsOfService' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' -import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from './state' import {AppPasswords} from 'view/screens/AppPasswords' +import {BlockedAccounts} from 'view/screens/BlockedAccounts' const navigationRef = createNavigationContainerRef() @@ -88,6 +89,7 @@ function commonScreens(Stack: typeof HomeTab) { /> + ) } diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index 0092b99e4..5ec591cfb 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -57,6 +57,7 @@ export function getPostModeration( let avatar = { warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', blur: + postInfo.isBlocking || accountPref.pref === 'hide' || accountPref.pref === 'warn' || profilePref.pref === 'hide' || @@ -75,6 +76,22 @@ export function getPostModeration( } // hide cases + if (postInfo.isBlocking) { + return { + avatar, + list: hide('Post from an account you blocked.'), + thread: hide('Post from an account you blocked.'), + view: warn('Post from an account you blocked.'), + } + } + if (postInfo.isBlockedBy) { + return { + avatar, + list: hide('Post from an account that has blocked you.'), + thread: hide('Post from an account that has blocked you.'), + view: warn('Post from an account that has blocked you.'), + } + } if (accountPref.pref === 'hide') { return { avatar, @@ -144,21 +161,45 @@ export function getPostModeration( } } +export function mergePostModerations( + moderations: PostModeration[], +): PostModeration { + const merged: PostModeration = { + avatar: {warn: false, blur: false}, + list: show(), + thread: show(), + view: show(), + } + for (const mod of moderations) { + if (mod.list.behavior === ModerationBehaviorCode.Hide) { + merged.list = mod.list + } + if (mod.thread.behavior === ModerationBehaviorCode.Hide) { + merged.thread = mod.thread + } + if (mod.view.behavior === ModerationBehaviorCode.Hide) { + merged.view = mod.view + } + } + return merged +} + export function getProfileModeration( store: RootStoreModel, - profileLabels: ProfileLabelInfo, + profileInfo: ProfileLabelInfo, ): ProfileModeration { const accountPref = store.preferences.getLabelPreference( - profileLabels.accountLabels, + profileInfo.accountLabels, ) const profilePref = store.preferences.getLabelPreference( - profileLabels.profileLabels, + profileInfo.profileLabels, ) // avatar let avatar = { warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', blur: + profileInfo.isBlocking || accountPref.pref === 'hide' || accountPref.pref === 'warn' || profilePref.pref === 'hide' || @@ -193,7 +234,10 @@ export function getProfileModeration( if (accountPref.pref === 'warn') { return { avatar, - list: warn(accountPref.desc.warning), + list: + profileInfo.isBlocking || profileInfo.isBlockedBy + ? hide('Blocked account') + : warn(accountPref.desc.warning), view: warn(accountPref.desc.warning), } } @@ -208,7 +252,7 @@ export function getProfileModeration( return { avatar, - list: show(), + list: profileInfo.isBlocking ? hide('Blocked account') : show(), view: show(), } } @@ -220,6 +264,7 @@ export function getProfileViewBasicLabelInfo( accountLabels: filterAccountLabels(profile.labels), profileLabels: filterProfileLabels(profile.labels), isMuted: profile.viewer?.muted || false, + isBlocking: !!profile.viewer?.blocking || false, } } @@ -236,6 +281,45 @@ export function getEmbedLabels(embed?: Embed): Label[] { return [] } +export function getEmbedMuted(embed?: Embed): boolean { + if (!embed) { + return false + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return !!embed.record.author.viewer?.muted + } + return false +} + +export function getEmbedBlocking(embed?: Embed): boolean { + if (!embed) { + return false + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return !!embed.record.author.viewer?.blocking + } + return false +} + +export function getEmbedBlockedBy(embed?: Embed): boolean { + if (!embed) { + return false + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return !!embed.record.author.viewer?.blockedBy + } + return false +} + export function filterAccountLabels(labels?: Label[]): Label[] { if (!labels) { return [] diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts index d4efb499a..20ecaa5b5 100644 --- a/src/lib/labeling/types.ts +++ b/src/lib/labeling/types.ts @@ -17,12 +17,16 @@ export interface PostLabelInfo { accountLabels: Label[] profileLabels: Label[] isMuted: boolean + isBlocking: boolean + isBlockedBy: boolean } export interface ProfileLabelInfo { accountLabels: Label[] profileLabels: Label[] isMuted: boolean + isBlocking: boolean + isBlockedBy: boolean } export enum ModerationBehaviorCode { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index eeb97ba7a..3aff82117 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -20,6 +20,7 @@ export type CommonNavigatorParams = { CommunityGuidelines: undefined CopyrightPolicy: undefined AppPasswords: undefined + BlockedAccounts: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 6762cde9d..15595775e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -14,6 +14,7 @@ export const router = new Router({ Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', + BlockedAccounts: '/settings/blocked-accounts', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 8f9a55032..18a42732c 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -13,6 +13,9 @@ import {updateDataOptimistically} from 'lib/async/revertible' import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, + getEmbedMuted, + getEmbedBlocking, + getEmbedBlockedBy, filterAccountLabels, filterProfileLabels, getPostModeration, @@ -30,7 +33,10 @@ export class PostThreadItemModel { // data post: AppBskyFeedDefs.PostView postRecord?: FeedPost.Record - parent?: PostThreadItemModel | AppBskyFeedDefs.NotFoundPost + parent?: + | PostThreadItemModel + | AppBskyFeedDefs.NotFoundPost + | AppBskyFeedDefs.BlockedPost replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[] richText?: RichText @@ -60,7 +66,18 @@ export class PostThreadItemModel { ), accountLabels: filterAccountLabels(this.post.author.labels), profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: this.post.author.viewer?.muted || false, + isMuted: + this.post.author.viewer?.muted || + getEmbedMuted(this.post.embed) || + false, + isBlocking: + !!this.post.author.viewer?.blocking || + getEmbedBlocking(this.post.embed) || + false, + isBlockedBy: + !!this.post.author.viewer?.blockedBy || + getEmbedBlockedBy(this.post.embed) || + false, } } @@ -114,6 +131,8 @@ export class PostThreadItemModel { this.parent = parentModel } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { this.parent = v.parent + } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) { + this.parent = v.parent } } // replies @@ -218,6 +237,7 @@ export class PostThreadModel { // data thread?: PostThreadItemModel + isBlocked = false constructor( public rootStore: RootStoreModel, @@ -377,11 +397,17 @@ export class PostThreadModel { this._replaceAll(res) this._xIdle() } catch (e: any) { + console.log(e) this._xIdle(e) } } _replaceAll(res: GetPostThread.Response) { + this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread) + if (this.isBlocked) { + return + } + pruneReplies(res.data.thread) sortThread(res.data.thread) const thread = new PostThreadItemModel( this.rootStore, @@ -399,7 +425,20 @@ export class PostThreadModel { type MaybePost = | AppBskyFeedDefs.ThreadViewPost | AppBskyFeedDefs.NotFoundPost + | AppBskyFeedDefs.BlockedPost | {[k: string]: unknown; $type: string} +function pruneReplies(post: MaybePost) { + if (post.replies) { + post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => { + if (reply.blocked) { + return false + } + pruneReplies(reply) + return true + }) + } +} + function sortThread(post: MaybePost) { if (post.notFound) { return diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index ea75d19c6..dddf488a3 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -1,5 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import { + AtUri, ComAtprotoLabelDefs, AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, @@ -23,6 +24,8 @@ export class ProfileViewerModel { muted?: boolean following?: string followedBy?: string + blockedBy?: boolean + blocking?: string constructor() { makeAutoObservable(this) @@ -86,6 +89,8 @@ export class ProfileModel { accountLabels: filterAccountLabels(this.labels), profileLabels: filterProfileLabels(this.labels), isMuted: this.viewer?.muted || false, + isBlocking: !!this.viewer?.blocking || false, + isBlockedBy: !!this.viewer?.blockedBy || false, } } @@ -185,6 +190,33 @@ export class ProfileModel { await this.refresh() } + async blockAccount() { + const res = await this.rootStore.agent.app.bsky.graph.block.create( + { + repo: this.rootStore.me.did, + }, + { + subject: this.did, + createdAt: new Date().toISOString(), + }, + ) + this.viewer.blocking = res.uri + await this.refresh() + } + + async unblockAccount() { + if (!this.viewer.blocking) { + return + } + const {rkey} = new AtUri(this.viewer.blocking) + await this.rootStore.agent.app.bsky.graph.block.delete({ + repo: this.rootStore.me.did, + rkey, + }) + this.viewer.blocking = undefined + await this.refresh() + } + // state transitions // = diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 02f58819f..3ffd10b99 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -111,6 +111,10 @@ export class NotificationsFeedItemModel { addedInfo?.profileLabels || [], ), isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, + isBlocking: + !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, + isBlockedBy: + !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false, } } diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 62c6da3de..62047acba 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -23,7 +23,11 @@ import {updateDataOptimistically} from 'lib/async/revertible' import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, + getEmbedMuted, + getEmbedBlocking, + getEmbedBlockedBy, getPostModeration, + mergePostModerations, filterAccountLabels, filterProfileLabels, } from 'lib/labeling/helpers' @@ -97,7 +101,18 @@ export class PostsFeedItemModel { ), accountLabels: filterAccountLabels(this.post.author.labels), profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: this.post.author.viewer?.muted || false, + isMuted: + this.post.author.viewer?.muted || + getEmbedMuted(this.post.embed) || + false, + isBlocking: + !!this.post.author.viewer?.blocking || + getEmbedBlocking(this.post.embed) || + false, + isBlockedBy: + !!this.post.author.viewer?.blockedBy || + getEmbedBlockedBy(this.post.embed) || + false, } } @@ -240,6 +255,10 @@ export class PostsFeedSliceModel { return this.items[0] } + get moderation() { + return mergePostModerations(this.items.map(item => item.moderation)) + } + containsUri(uri: string) { return !!this.items.find(item => item.post.uri === uri) } @@ -265,6 +284,8 @@ export class PostsFeedModel { isRefreshing = false hasNewLatest = false hasLoaded = false + isBlocking = false + isBlockedBy = false error = '' loadMoreError = '' params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams @@ -553,6 +574,8 @@ export class PostsFeedModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true + this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError + this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError this.error = cleanError(error) this.loadMoreError = cleanError(loadMoreError) if (error) { diff --git a/src/state/models/lists/blocked-accounts.ts b/src/state/models/lists/blocked-accounts.ts new file mode 100644 index 000000000..20eef8aff --- /dev/null +++ b/src/state/models/lists/blocked-accounts.ts @@ -0,0 +1,106 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetBlocks as GetBlocks, + AppBskyActorDefs as ActorDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class BlockedAccountsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + blocks: ActorDefs.ProfileView[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.blocks.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.graph.getBlocks({ + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetBlocks.Response) { + this.blocks = [] + this._appendAll(res) + } + + _appendAll(res: GetBlocks.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.blocks = this.blocks.concat(res.data.blocks) + } +} 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 + } else if (item === DELETED) { + return ( + + + Deleted post. + + + ) + } else if (item === BLOCKED) { + return ( + + + Blocked post. + + + ) } 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({ ) } + if (view.isBlocked) { + return ( + + + + Post hidden + + + You have blocked the author or you have been blocked by the author. + + + + + Back + + + + + ) + } // loaded // = @@ -208,8 +255,10 @@ function* flattenThread( isAscending = false, ): Generator { 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() - 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() + 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 ( - - - - - {isMe ? ( - - - Edit Profile - - - ) : ( + }, [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 ( + + + + + {isMe ? ( + + + Edit Profile + + + ) : view.viewer.blocking ? ( + + + Unblock + + + ) : !view.viewer.blockedBy ? ( + <> + {store.me.follows.getFollowState(view.did) === + FollowState.Following ? ( + + + + Following + + + ) : ( + + + + Follow + + + )} + + ) : null} + {dropdownItems?.length ? ( + + + + ) : undefined} + + + + {sanitizeDisplayName(view.displayName || view.handle)} + + + + {view.viewer.followedBy && !blockHide ? ( + + + Follows you + + + ) : undefined} + @{view.handle} + + {!blockHide && ( <> - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( + - - - Following + testID="profileHeaderFollowersButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollowers}> + + {view.followersCount} + + + {pluralize(view.followersCount, 'follower')} - ) : ( - - - Follow + testID="profileHeaderFollowsButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollows}> + + {view.followsCount} + + + following - )} + + + {view.postsCount} + + + {pluralize(view.postsCount, 'post')} + + + + {view.descriptionRichText ? ( + + ) : undefined} )} - {dropdownItems?.length ? ( - - - - ) : undefined} - - - - {sanitizeDisplayName(view.displayName || view.handle)} - - - - {view.viewer.followedBy ? ( - - - Follows you - - - ) : undefined} - @{view.handle} - - - - - {view.followersCount} - - - {pluralize(view.followersCount, 'follower')} - - - - - {view.followsCount} - - - following - - - - - {view.postsCount} - - - {pluralize(view.postsCount, 'post')} - + + + {view.viewer.blocking ? ( + + + + Account blocked + + + ) : view.viewer.muted ? ( + + + + Account muted + + + ) : undefined} + {view.viewer.blockedBy && ( + + + + This account has blocked you + + + )} - {view.descriptionRichText ? ( - - ) : undefined} - - {view.viewer.muted ? ( + {!isDesktopWeb && !hideBackButton && ( + + + + + + + + )} + - + - - Account muted - - - ) : undefined} - - {!isDesktopWeb && !hideBackButton && ( - - - - - - )} - - - - - - - ) -}) + + ) + }, +) 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 +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 + }) => ( + + ) + return ( + + + + 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. + + {!blockedAccounts.hasContent ? ( + + + + 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. + + + + ) : ( + item.did} + refreshControl={ + + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + ListFooterComponent={() => ( + + {blockedAccounts.isLoading && } + + )} + extraData={blockedAccounts.isLoading} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + + ) + }), +) + +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 } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + + ) + } + if (uiState.feed.isBlockedBy) { + return ( + + ) + } return ( }, - [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( - Advanced + Moderation + + + + + + Blocked accounts + + + + + + + Advanced +