Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
a95c03e2 e68aa754

+967 -284
+1
bskyweb/cmd/bskyweb/server.go
··· 93 93 e.GET("/notifications", server.WebGeneric) 94 94 e.GET("/settings", server.WebGeneric) 95 95 e.GET("/settings/app-passwords", server.WebGeneric) 96 + e.GET("/settings/blocked-accounts", server.WebGeneric) 96 97 e.GET("/sys/debug", server.WebGeneric) 97 98 e.GET("/sys/log", server.WebGeneric) 98 99 e.GET("/support", server.WebGeneric)
+2 -2
package.json
··· 22 22 "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" 23 23 }, 24 24 "dependencies": { 25 - "@atproto/api": "0.2.10", 25 + "@atproto/api": "0.2.11", 26 26 "@bam.tech/react-native-image-resizer": "^3.0.4", 27 27 "@braintree/sanitize-url": "^6.0.2", 28 28 "@expo/webpack-config": "^18.0.1", ··· 130 130 "zod": "^3.20.2" 131 131 }, 132 132 "devDependencies": { 133 - "@atproto/pds": "^0.1.4", 133 + "@atproto/pds": "^0.1.5", 134 134 "@babel/core": "^7.20.0", 135 135 "@babel/preset-env": "^7.20.0", 136 136 "@babel/runtime": "^7.20.0",
+4 -2
src/Navigation.tsx
··· 27 27 import {isNative} from 'platform/detection' 28 28 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 29 29 import {router} from './routes' 30 + import {usePalette} from 'lib/hooks/usePalette' 31 + import {useStores} from './state' 30 32 31 33 import {HomeScreen} from './view/screens/Home' 32 34 import {SearchScreen} from './view/screens/Search' ··· 46 48 import {TermsOfServiceScreen} from './view/screens/TermsOfService' 47 49 import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' 48 50 import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' 49 - import {usePalette} from 'lib/hooks/usePalette' 50 - import {useStores} from './state' 51 51 import {AppPasswords} from 'view/screens/AppPasswords' 52 + import {BlockedAccounts} from 'view/screens/BlockedAccounts' 52 53 53 54 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 54 55 ··· 88 89 /> 89 90 <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} /> 90 91 <Stack.Screen name="AppPasswords" component={AppPasswords} /> 92 + <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} /> 91 93 </> 92 94 ) 93 95 }
+89 -5
src/lib/labeling/helpers.ts
··· 57 57 let avatar = { 58 58 warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', 59 59 blur: 60 + postInfo.isBlocking || 60 61 accountPref.pref === 'hide' || 61 62 accountPref.pref === 'warn' || 62 63 profilePref.pref === 'hide' || ··· 75 76 } 76 77 77 78 // hide cases 79 + if (postInfo.isBlocking) { 80 + return { 81 + avatar, 82 + list: hide('Post from an account you blocked.'), 83 + thread: hide('Post from an account you blocked.'), 84 + view: warn('Post from an account you blocked.'), 85 + } 86 + } 87 + if (postInfo.isBlockedBy) { 88 + return { 89 + avatar, 90 + list: hide('Post from an account that has blocked you.'), 91 + thread: hide('Post from an account that has blocked you.'), 92 + view: warn('Post from an account that has blocked you.'), 93 + } 94 + } 78 95 if (accountPref.pref === 'hide') { 79 96 return { 80 97 avatar, ··· 144 161 } 145 162 } 146 163 164 + export function mergePostModerations( 165 + moderations: PostModeration[], 166 + ): PostModeration { 167 + const merged: PostModeration = { 168 + avatar: {warn: false, blur: false}, 169 + list: show(), 170 + thread: show(), 171 + view: show(), 172 + } 173 + for (const mod of moderations) { 174 + if (mod.list.behavior === ModerationBehaviorCode.Hide) { 175 + merged.list = mod.list 176 + } 177 + if (mod.thread.behavior === ModerationBehaviorCode.Hide) { 178 + merged.thread = mod.thread 179 + } 180 + if (mod.view.behavior === ModerationBehaviorCode.Hide) { 181 + merged.view = mod.view 182 + } 183 + } 184 + return merged 185 + } 186 + 147 187 export function getProfileModeration( 148 188 store: RootStoreModel, 149 - profileLabels: ProfileLabelInfo, 189 + profileInfo: ProfileLabelInfo, 150 190 ): ProfileModeration { 151 191 const accountPref = store.preferences.getLabelPreference( 152 - profileLabels.accountLabels, 192 + profileInfo.accountLabels, 153 193 ) 154 194 const profilePref = store.preferences.getLabelPreference( 155 - profileLabels.profileLabels, 195 + profileInfo.profileLabels, 156 196 ) 157 197 158 198 // avatar 159 199 let avatar = { 160 200 warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', 161 201 blur: 202 + profileInfo.isBlocking || 162 203 accountPref.pref === 'hide' || 163 204 accountPref.pref === 'warn' || 164 205 profilePref.pref === 'hide' || ··· 193 234 if (accountPref.pref === 'warn') { 194 235 return { 195 236 avatar, 196 - list: warn(accountPref.desc.warning), 237 + list: 238 + profileInfo.isBlocking || profileInfo.isBlockedBy 239 + ? hide('Blocked account') 240 + : warn(accountPref.desc.warning), 197 241 view: warn(accountPref.desc.warning), 198 242 } 199 243 } ··· 208 252 209 253 return { 210 254 avatar, 211 - list: show(), 255 + list: profileInfo.isBlocking ? hide('Blocked account') : show(), 212 256 view: show(), 213 257 } 214 258 } ··· 220 264 accountLabels: filterAccountLabels(profile.labels), 221 265 profileLabels: filterProfileLabels(profile.labels), 222 266 isMuted: profile.viewer?.muted || false, 267 + isBlocking: !!profile.viewer?.blocking || false, 223 268 } 224 269 } 225 270 ··· 234 279 return embed.record.labels || [] 235 280 } 236 281 return [] 282 + } 283 + 284 + export function getEmbedMuted(embed?: Embed): boolean { 285 + if (!embed) { 286 + return false 287 + } 288 + if ( 289 + AppBskyEmbedRecord.isView(embed) && 290 + AppBskyEmbedRecord.isViewRecord(embed.record) 291 + ) { 292 + return !!embed.record.author.viewer?.muted 293 + } 294 + return false 295 + } 296 + 297 + export function getEmbedBlocking(embed?: Embed): boolean { 298 + if (!embed) { 299 + return false 300 + } 301 + if ( 302 + AppBskyEmbedRecord.isView(embed) && 303 + AppBskyEmbedRecord.isViewRecord(embed.record) 304 + ) { 305 + return !!embed.record.author.viewer?.blocking 306 + } 307 + return false 308 + } 309 + 310 + export function getEmbedBlockedBy(embed?: Embed): boolean { 311 + if (!embed) { 312 + return false 313 + } 314 + if ( 315 + AppBskyEmbedRecord.isView(embed) && 316 + AppBskyEmbedRecord.isViewRecord(embed.record) 317 + ) { 318 + return !!embed.record.author.viewer?.blockedBy 319 + } 320 + return false 237 321 } 238 322 239 323 export function filterAccountLabels(labels?: Label[]): Label[] {
+4
src/lib/labeling/types.ts
··· 17 17 accountLabels: Label[] 18 18 profileLabels: Label[] 19 19 isMuted: boolean 20 + isBlocking: boolean 21 + isBlockedBy: boolean 20 22 } 21 23 22 24 export interface ProfileLabelInfo { 23 25 accountLabels: Label[] 24 26 profileLabels: Label[] 25 27 isMuted: boolean 28 + isBlocking: boolean 29 + isBlockedBy: boolean 26 30 } 27 31 28 32 export enum ModerationBehaviorCode {
+1
src/lib/routes/types.ts
··· 20 20 CommunityGuidelines: undefined 21 21 CopyrightPolicy: undefined 22 22 AppPasswords: undefined 23 + BlockedAccounts: undefined 23 24 } 24 25 25 26 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/routes.ts
··· 14 14 Debug: '/sys/debug', 15 15 Log: '/sys/log', 16 16 AppPasswords: '/settings/app-passwords', 17 + BlockedAccounts: '/settings/blocked-accounts', 17 18 Support: '/support', 18 19 PrivacyPolicy: '/support/privacy', 19 20 TermsOfService: '/support/tos',
+41 -2
src/state/models/content/post-thread.ts
··· 13 13 import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 14 14 import { 15 15 getEmbedLabels, 16 + getEmbedMuted, 17 + getEmbedBlocking, 18 + getEmbedBlockedBy, 16 19 filterAccountLabels, 17 20 filterProfileLabels, 18 21 getPostModeration, ··· 30 33 // data 31 34 post: AppBskyFeedDefs.PostView 32 35 postRecord?: FeedPost.Record 33 - parent?: PostThreadItemModel | AppBskyFeedDefs.NotFoundPost 36 + parent?: 37 + | PostThreadItemModel 38 + | AppBskyFeedDefs.NotFoundPost 39 + | AppBskyFeedDefs.BlockedPost 34 40 replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[] 35 41 richText?: RichText 36 42 ··· 60 66 ), 61 67 accountLabels: filterAccountLabels(this.post.author.labels), 62 68 profileLabels: filterProfileLabels(this.post.author.labels), 63 - isMuted: this.post.author.viewer?.muted || false, 69 + isMuted: 70 + this.post.author.viewer?.muted || 71 + getEmbedMuted(this.post.embed) || 72 + false, 73 + isBlocking: 74 + !!this.post.author.viewer?.blocking || 75 + getEmbedBlocking(this.post.embed) || 76 + false, 77 + isBlockedBy: 78 + !!this.post.author.viewer?.blockedBy || 79 + getEmbedBlockedBy(this.post.embed) || 80 + false, 64 81 } 65 82 } 66 83 ··· 114 131 this.parent = parentModel 115 132 } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { 116 133 this.parent = v.parent 134 + } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) { 135 + this.parent = v.parent 117 136 } 118 137 } 119 138 // replies ··· 218 237 219 238 // data 220 239 thread?: PostThreadItemModel 240 + isBlocked = false 221 241 222 242 constructor( 223 243 public rootStore: RootStoreModel, ··· 377 397 this._replaceAll(res) 378 398 this._xIdle() 379 399 } catch (e: any) { 400 + console.log(e) 380 401 this._xIdle(e) 381 402 } 382 403 } 383 404 384 405 _replaceAll(res: GetPostThread.Response) { 406 + this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread) 407 + if (this.isBlocked) { 408 + return 409 + } 410 + pruneReplies(res.data.thread) 385 411 sortThread(res.data.thread) 386 412 const thread = new PostThreadItemModel( 387 413 this.rootStore, ··· 399 425 type MaybePost = 400 426 | AppBskyFeedDefs.ThreadViewPost 401 427 | AppBskyFeedDefs.NotFoundPost 428 + | AppBskyFeedDefs.BlockedPost 402 429 | {[k: string]: unknown; $type: string} 430 + function pruneReplies(post: MaybePost) { 431 + if (post.replies) { 432 + post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => { 433 + if (reply.blocked) { 434 + return false 435 + } 436 + pruneReplies(reply) 437 + return true 438 + }) 439 + } 440 + } 441 + 403 442 function sortThread(post: MaybePost) { 404 443 if (post.notFound) { 405 444 return
+32
src/state/models/content/profile.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import { 3 + AtUri, 3 4 ComAtprotoLabelDefs, 4 5 AppBskyActorGetProfile as GetProfile, 5 6 AppBskyActorProfile, ··· 23 24 muted?: boolean 24 25 following?: string 25 26 followedBy?: string 27 + blockedBy?: boolean 28 + blocking?: string 26 29 27 30 constructor() { 28 31 makeAutoObservable(this) ··· 86 89 accountLabels: filterAccountLabels(this.labels), 87 90 profileLabels: filterProfileLabels(this.labels), 88 91 isMuted: this.viewer?.muted || false, 92 + isBlocking: !!this.viewer?.blocking || false, 93 + isBlockedBy: !!this.viewer?.blockedBy || false, 89 94 } 90 95 } 91 96 ··· 182 187 async unmuteAccount() { 183 188 await this.rootStore.agent.unmute(this.did) 184 189 this.viewer.muted = false 190 + await this.refresh() 191 + } 192 + 193 + async blockAccount() { 194 + const res = await this.rootStore.agent.app.bsky.graph.block.create( 195 + { 196 + repo: this.rootStore.me.did, 197 + }, 198 + { 199 + subject: this.did, 200 + createdAt: new Date().toISOString(), 201 + }, 202 + ) 203 + this.viewer.blocking = res.uri 204 + await this.refresh() 205 + } 206 + 207 + async unblockAccount() { 208 + if (!this.viewer.blocking) { 209 + return 210 + } 211 + const {rkey} = new AtUri(this.viewer.blocking) 212 + await this.rootStore.agent.app.bsky.graph.block.delete({ 213 + repo: this.rootStore.me.did, 214 + rkey, 215 + }) 216 + this.viewer.blocking = undefined 185 217 await this.refresh() 186 218 } 187 219
+4
src/state/models/feeds/notifications.ts
··· 111 111 addedInfo?.profileLabels || [], 112 112 ), 113 113 isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, 114 + isBlocking: 115 + !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, 116 + isBlockedBy: 117 + !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false, 114 118 } 115 119 } 116 120
+24 -1
src/state/models/feeds/posts.ts
··· 23 23 import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 24 24 import { 25 25 getEmbedLabels, 26 + getEmbedMuted, 27 + getEmbedBlocking, 28 + getEmbedBlockedBy, 26 29 getPostModeration, 30 + mergePostModerations, 27 31 filterAccountLabels, 28 32 filterProfileLabels, 29 33 } from 'lib/labeling/helpers' ··· 97 101 ), 98 102 accountLabels: filterAccountLabels(this.post.author.labels), 99 103 profileLabels: filterProfileLabels(this.post.author.labels), 100 - isMuted: this.post.author.viewer?.muted || false, 104 + isMuted: 105 + this.post.author.viewer?.muted || 106 + getEmbedMuted(this.post.embed) || 107 + false, 108 + isBlocking: 109 + !!this.post.author.viewer?.blocking || 110 + getEmbedBlocking(this.post.embed) || 111 + false, 112 + isBlockedBy: 113 + !!this.post.author.viewer?.blockedBy || 114 + getEmbedBlockedBy(this.post.embed) || 115 + false, 101 116 } 102 117 } 103 118 ··· 240 255 return this.items[0] 241 256 } 242 257 258 + get moderation() { 259 + return mergePostModerations(this.items.map(item => item.moderation)) 260 + } 261 + 243 262 containsUri(uri: string) { 244 263 return !!this.items.find(item => item.post.uri === uri) 245 264 } ··· 265 284 isRefreshing = false 266 285 hasNewLatest = false 267 286 hasLoaded = false 287 + isBlocking = false 288 + isBlockedBy = false 268 289 error = '' 269 290 loadMoreError = '' 270 291 params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams ··· 553 574 this.isLoading = false 554 575 this.isRefreshing = false 555 576 this.hasLoaded = true 577 + this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError 578 + this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError 556 579 this.error = cleanError(error) 557 580 this.loadMoreError = cleanError(loadMoreError) 558 581 if (error) {
+106
src/state/models/lists/blocked-accounts.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import { 3 + AppBskyGraphGetBlocks as GetBlocks, 4 + AppBskyActorDefs as ActorDefs, 5 + } from '@atproto/api' 6 + import {RootStoreModel} from '../root-store' 7 + import {cleanError} from 'lib/strings/errors' 8 + import {bundleAsync} from 'lib/async/bundle' 9 + 10 + const PAGE_SIZE = 30 11 + 12 + export class BlockedAccountsModel { 13 + // state 14 + isLoading = false 15 + isRefreshing = false 16 + hasLoaded = false 17 + error = '' 18 + hasMore = true 19 + loadMoreCursor?: string 20 + 21 + // data 22 + blocks: ActorDefs.ProfileView[] = [] 23 + 24 + constructor(public rootStore: RootStoreModel) { 25 + makeAutoObservable( 26 + this, 27 + { 28 + rootStore: false, 29 + }, 30 + {autoBind: true}, 31 + ) 32 + } 33 + 34 + get hasContent() { 35 + return this.blocks.length > 0 36 + } 37 + 38 + get hasError() { 39 + return this.error !== '' 40 + } 41 + 42 + get isEmpty() { 43 + return this.hasLoaded && !this.hasContent 44 + } 45 + 46 + // public api 47 + // = 48 + 49 + async refresh() { 50 + return this.loadMore(true) 51 + } 52 + 53 + loadMore = bundleAsync(async (replace: boolean = false) => { 54 + if (!replace && !this.hasMore) { 55 + return 56 + } 57 + this._xLoading(replace) 58 + try { 59 + const res = await this.rootStore.agent.app.bsky.graph.getBlocks({ 60 + limit: PAGE_SIZE, 61 + cursor: replace ? undefined : this.loadMoreCursor, 62 + }) 63 + if (replace) { 64 + this._replaceAll(res) 65 + } else { 66 + this._appendAll(res) 67 + } 68 + this._xIdle() 69 + } catch (e: any) { 70 + this._xIdle(e) 71 + } 72 + }) 73 + 74 + // state transitions 75 + // = 76 + 77 + _xLoading(isRefreshing = false) { 78 + this.isLoading = true 79 + this.isRefreshing = isRefreshing 80 + this.error = '' 81 + } 82 + 83 + _xIdle(err?: any) { 84 + this.isLoading = false 85 + this.isRefreshing = false 86 + this.hasLoaded = true 87 + this.error = cleanError(err) 88 + if (err) { 89 + this.rootStore.log.error('Failed to fetch user followers', err) 90 + } 91 + } 92 + 93 + // helper functions 94 + // = 95 + 96 + _replaceAll(res: GetBlocks.Response) { 97 + this.blocks = [] 98 + this._appendAll(res) 99 + } 100 + 101 + _appendAll(res: GetBlocks.Response) { 102 + this.loadMoreCursor = res.data.cursor 103 + this.hasMore = !!this.loadMoreCursor 104 + this.blocks = this.blocks.concat(res.data.blocks) 105 + } 106 + }
+1 -5
src/view/com/composer/Composer.tsx
··· 190 190 191 191 const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH 192 192 193 - const selectTextInputPlaceholder = replyTo 194 - ? 'Write your reply' 195 - : gallery.isEmpty 196 - ? 'Write a comment' 197 - : "What's up?" 193 + const selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?" 198 194 199 195 const canSelectImages = gallery.size < 4 200 196 const viewStyles = {
+2 -1
src/view/com/modals/Confirm.tsx
··· 11 11 import {ErrorMessage} from '../util/error/ErrorMessage' 12 12 import {cleanError} from 'lib/strings/errors' 13 13 import {usePalette} from 'lib/hooks/usePalette' 14 + import {isDesktopWeb} from 'platform/detection' 14 15 15 16 export const snapPoints = [300] 16 17 ··· 77 78 container: { 78 79 flex: 1, 79 80 padding: 10, 80 - paddingBottom: 60, 81 + paddingBottom: isDesktopWeb ? 0 : 60, 81 82 }, 82 83 title: { 83 84 textAlign: 'center',
+59 -5
src/view/com/post-thread/PostThread.tsx
··· 7 7 TouchableOpacity, 8 8 View, 9 9 } from 'react-native' 10 + import {AppBskyFeedDefs} from '@atproto/api' 10 11 import {CenteredView, FlatList} from '../util/Views' 11 12 import { 12 13 PostThreadModel, ··· 27 28 import {NavigationProp} from 'lib/routes/types' 28 29 29 30 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} 31 + const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} 32 + const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} 30 33 const BOTTOM_COMPONENT = { 31 34 _reactKey: '__bottom_component__', 32 35 _isHighlightedPost: false, 33 36 } 34 - type YieldedItem = PostThreadItemModel | typeof REPLY_PROMPT 37 + type YieldedItem = 38 + | PostThreadItemModel 39 + | typeof REPLY_PROMPT 40 + | typeof DELETED 41 + | typeof BLOCKED 35 42 36 43 export const PostThread = observer(function PostThread({ 37 44 uri, ··· 103 110 ({item}: {item: YieldedItem}) => { 104 111 if (item === REPLY_PROMPT) { 105 112 return <ComposePrompt onPressCompose={onPressReply} /> 113 + } else if (item === DELETED) { 114 + return ( 115 + <View style={[pal.border, pal.viewLight, styles.missingItem]}> 116 + <Text type="lg-bold" style={pal.textLight}> 117 + Deleted post. 118 + </Text> 119 + </View> 120 + ) 121 + } else if (item === BLOCKED) { 122 + return ( 123 + <View style={[pal.border, pal.viewLight, styles.missingItem]}> 124 + <Text type="lg-bold" style={pal.textLight}> 125 + Blocked post. 126 + </Text> 127 + </View> 128 + ) 106 129 } else if (item === BOTTOM_COMPONENT) { 107 130 // HACK 108 131 // due to some complexities with how flatlist works, this is the easiest way ··· 177 200 </CenteredView> 178 201 ) 179 202 } 203 + if (view.isBlocked) { 204 + return ( 205 + <CenteredView> 206 + <View style={[pal.view, pal.border, styles.notFoundContainer]}> 207 + <Text type="title-lg" style={[pal.text, s.mb5]}> 208 + Post hidden 209 + </Text> 210 + <Text type="md" style={[pal.text, s.mb10]}> 211 + You have blocked the author or you have been blocked by the author. 212 + </Text> 213 + <TouchableOpacity onPress={onPressBack}> 214 + <Text type="2xl" style={pal.link}> 215 + <FontAwesomeIcon 216 + icon="angle-left" 217 + style={[pal.link as FontAwesomeIconStyle, s.mr5]} 218 + size={14} 219 + /> 220 + Back 221 + </Text> 222 + </TouchableOpacity> 223 + </View> 224 + </CenteredView> 225 + ) 226 + } 180 227 181 228 // loaded 182 229 // = ··· 208 255 isAscending = false, 209 256 ): Generator<YieldedItem, void> { 210 257 if (post.parent) { 211 - if ('notFound' in post.parent && post.parent.notFound) { 212 - // TODO render not found 258 + if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { 259 + yield DELETED 260 + } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) { 261 + yield BLOCKED 213 262 } else { 214 263 yield* flattenThread(post.parent as PostThreadItemModel, true) 215 264 } ··· 220 269 } 221 270 if (post.replies?.length) { 222 271 for (const reply of post.replies) { 223 - if ('notFound' in reply && reply.notFound) { 224 - // TODO render not found 272 + if (AppBskyFeedDefs.isNotFoundPost(reply)) { 273 + yield DELETED 225 274 } else { 226 275 yield* flattenThread(reply as PostThreadItemModel) 227 276 } ··· 237 286 paddingHorizontal: 18, 238 287 paddingVertical: 14, 239 288 borderRadius: 6, 289 + }, 290 + missingItem: { 291 + borderTop: 1, 292 + paddingHorizontal: 18, 293 + paddingVertical: 18, 240 294 }, 241 295 bottomBorder: { 242 296 borderBottomWidth: 1,
+4
src/view/com/posts/FeedSlice.tsx
··· 7 7 import Svg, {Circle, Line} from 'react-native-svg' 8 8 import {FeedItem} from './FeedItem' 9 9 import {usePalette} from 'lib/hooks/usePalette' 10 + import {ModerationBehaviorCode} from 'lib/labeling/types' 10 11 11 12 export function FeedSlice({ 12 13 slice, ··· 17 18 showFollowBtn?: boolean 18 19 ignoreMuteFor?: string 19 20 }) { 21 + if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { 22 + return null 23 + } 20 24 if (slice.isThread && slice.items.length > 3) { 21 25 const last = slice.items.length - 1 22 26 return (
+6 -1
src/view/com/profile/ProfileCard.tsx
··· 23 23 noBg, 24 24 noBorder, 25 25 followers, 26 + overrideModeration, 26 27 renderButton, 27 28 }: { 28 29 testID?: string ··· 30 31 noBg?: boolean 31 32 noBorder?: boolean 32 33 followers?: AppBskyActorDefs.ProfileView[] | undefined 34 + overrideModeration?: boolean 33 35 renderButton?: () => JSX.Element 34 36 }) => { 35 37 const store = useStores() ··· 40 42 getProfileViewBasicLabelInfo(profile), 41 43 ) 42 44 43 - if (moderation.list.behavior === ModerationBehaviorCode.Hide) { 45 + if ( 46 + moderation.list.behavior === ModerationBehaviorCode.Hide && 47 + !overrideModeration 48 + ) { 44 49 return null 45 50 } 46 51
+356 -247
src/view/com/profile/ProfileHeader.tsx
··· 96 96 }, 97 97 ) 98 98 99 - const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ 100 - view, 101 - onRefreshAll, 102 - hideBackButton = false, 103 - }: Props) { 104 - const pal = usePalette('default') 105 - const store = useStores() 106 - const navigation = useNavigation<NavigationProp>() 107 - const {track} = useAnalytics() 99 + const ProfileHeaderLoaded = observer( 100 + ({view, onRefreshAll, hideBackButton = false}: Props) => { 101 + const pal = usePalette('default') 102 + const store = useStores() 103 + const navigation = useNavigation<NavigationProp>() 104 + const {track} = useAnalytics() 108 105 109 - const onPressBack = React.useCallback(() => { 110 - navigation.goBack() 111 - }, [navigation]) 106 + const onPressBack = React.useCallback(() => { 107 + navigation.goBack() 108 + }, [navigation]) 112 109 113 - const onPressAvi = React.useCallback(() => { 114 - if (view.avatar) { 115 - store.shell.openLightbox(new ProfileImageLightbox(view)) 116 - } 117 - }, [store, view]) 110 + const onPressAvi = React.useCallback(() => { 111 + if (view.avatar) { 112 + store.shell.openLightbox(new ProfileImageLightbox(view)) 113 + } 114 + }, [store, view]) 118 115 119 - const onPressToggleFollow = React.useCallback(() => { 120 - view?.toggleFollowing().then( 121 - () => { 122 - Toast.show( 123 - `${ 124 - view.viewer.following ? 'Following' : 'No longer following' 125 - } ${sanitizeDisplayName(view.displayName || view.handle)}`, 126 - ) 127 - }, 128 - err => store.log.error('Failed to toggle follow', err), 129 - ) 130 - }, [view, store]) 116 + const onPressToggleFollow = React.useCallback(() => { 117 + view?.toggleFollowing().then( 118 + () => { 119 + Toast.show( 120 + `${ 121 + view.viewer.following ? 'Following' : 'No longer following' 122 + } ${sanitizeDisplayName(view.displayName || view.handle)}`, 123 + ) 124 + }, 125 + err => store.log.error('Failed to toggle follow', err), 126 + ) 127 + }, [view, store]) 131 128 132 - const onPressEditProfile = React.useCallback(() => { 133 - track('ProfileHeader:EditProfileButtonClicked') 134 - store.shell.openModal({ 135 - name: 'edit-profile', 136 - profileView: view, 137 - onUpdate: onRefreshAll, 138 - }) 139 - }, [track, store, view, onRefreshAll]) 129 + const onPressEditProfile = React.useCallback(() => { 130 + track('ProfileHeader:EditProfileButtonClicked') 131 + store.shell.openModal({ 132 + name: 'edit-profile', 133 + profileView: view, 134 + onUpdate: onRefreshAll, 135 + }) 136 + }, [track, store, view, onRefreshAll]) 140 137 141 - const onPressFollowers = React.useCallback(() => { 142 - track('ProfileHeader:FollowersButtonClicked') 143 - navigation.push('ProfileFollowers', {name: view.handle}) 144 - }, [track, navigation, view]) 138 + const onPressFollowers = React.useCallback(() => { 139 + track('ProfileHeader:FollowersButtonClicked') 140 + navigation.push('ProfileFollowers', {name: view.handle}) 141 + }, [track, navigation, view]) 145 142 146 - const onPressFollows = React.useCallback(() => { 147 - track('ProfileHeader:FollowsButtonClicked') 148 - navigation.push('ProfileFollows', {name: view.handle}) 149 - }, [track, navigation, view]) 143 + const onPressFollows = React.useCallback(() => { 144 + track('ProfileHeader:FollowsButtonClicked') 145 + navigation.push('ProfileFollows', {name: view.handle}) 146 + }, [track, navigation, view]) 150 147 151 - const onPressShare = React.useCallback(async () => { 152 - track('ProfileHeader:ShareButtonClicked') 153 - const url = toShareUrl(`/profile/${view.handle}`) 154 - shareUrl(url) 155 - }, [track, view]) 148 + const onPressShare = React.useCallback(async () => { 149 + track('ProfileHeader:ShareButtonClicked') 150 + const url = toShareUrl(`/profile/${view.handle}`) 151 + shareUrl(url) 152 + }, [track, view]) 156 153 157 - const onPressMuteAccount = React.useCallback(async () => { 158 - track('ProfileHeader:MuteAccountButtonClicked') 159 - try { 160 - await view.muteAccount() 161 - Toast.show('Account muted') 162 - } catch (e: any) { 163 - store.log.error('Failed to mute account', e) 164 - Toast.show(`There was an issue! ${e.toString()}`) 165 - } 166 - }, [track, view, store]) 154 + const onPressMuteAccount = React.useCallback(async () => { 155 + track('ProfileHeader:MuteAccountButtonClicked') 156 + try { 157 + await view.muteAccount() 158 + Toast.show('Account muted') 159 + } catch (e: any) { 160 + store.log.error('Failed to mute account', e) 161 + Toast.show(`There was an issue! ${e.toString()}`) 162 + } 163 + }, [track, view, store]) 167 164 168 - const onPressUnmuteAccount = React.useCallback(async () => { 169 - track('ProfileHeader:UnmuteAccountButtonClicked') 170 - try { 171 - await view.unmuteAccount() 172 - Toast.show('Account unmuted') 173 - } catch (e: any) { 174 - store.log.error('Failed to unmute account', e) 175 - Toast.show(`There was an issue! ${e.toString()}`) 176 - } 177 - }, [track, view, store]) 165 + const onPressUnmuteAccount = React.useCallback(async () => { 166 + track('ProfileHeader:UnmuteAccountButtonClicked') 167 + try { 168 + await view.unmuteAccount() 169 + Toast.show('Account unmuted') 170 + } catch (e: any) { 171 + store.log.error('Failed to unmute account', e) 172 + Toast.show(`There was an issue! ${e.toString()}`) 173 + } 174 + }, [track, view, store]) 178 175 179 - const onPressReportAccount = React.useCallback(() => { 180 - track('ProfileHeader:ReportAccountButtonClicked') 181 - store.shell.openModal({ 182 - name: 'report-account', 183 - did: view.did, 184 - }) 185 - }, [track, store, view]) 176 + const onPressBlockAccount = React.useCallback(async () => { 177 + track('ProfileHeader:BlockAccountButtonClicked') 178 + store.shell.openModal({ 179 + name: 'confirm', 180 + title: 'Block Account', 181 + message: 182 + '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.', 183 + onPressConfirm: async () => { 184 + try { 185 + await view.blockAccount() 186 + onRefreshAll() 187 + Toast.show('Account blocked') 188 + } catch (e: any) { 189 + store.log.error('Failed to block account', e) 190 + Toast.show(`There was an issue! ${e.toString()}`) 191 + } 192 + }, 193 + }) 194 + }, [track, view, store, onRefreshAll]) 186 195 187 - const isMe = React.useMemo( 188 - () => store.me.did === view.did, 189 - [store.me.did, view.did], 190 - ) 191 - const dropdownItems: DropdownItem[] = React.useMemo(() => { 192 - let items: DropdownItem[] = [ 193 - { 194 - testID: 'profileHeaderDropdownSahreBtn', 195 - label: 'Share', 196 - onPress: onPressShare, 197 - }, 198 - ] 199 - if (!isMe) { 200 - items.push({ 201 - testID: 'profileHeaderDropdownMuteBtn', 202 - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', 203 - onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, 196 + const onPressUnblockAccount = React.useCallback(async () => { 197 + track('ProfileHeader:UnblockAccountButtonClicked') 198 + store.shell.openModal({ 199 + name: 'confirm', 200 + title: 'Unblock Account', 201 + message: 202 + 'The account will be able to interact with you after unblocking. (You can always block again in the future.)', 203 + onPressConfirm: async () => { 204 + try { 205 + await view.unblockAccount() 206 + onRefreshAll() 207 + Toast.show('Account unblocked') 208 + } catch (e: any) { 209 + store.log.error('Failed to block unaccount', e) 210 + Toast.show(`There was an issue! ${e.toString()}`) 211 + } 212 + }, 204 213 }) 205 - items.push({ 206 - testID: 'profileHeaderDropdownReportBtn', 207 - label: 'Report Account', 208 - onPress: onPressReportAccount, 214 + }, [track, view, store, onRefreshAll]) 215 + 216 + const onPressReportAccount = React.useCallback(() => { 217 + track('ProfileHeader:ReportAccountButtonClicked') 218 + store.shell.openModal({ 219 + name: 'report-account', 220 + did: view.did, 209 221 }) 210 - } 211 - return items 212 - }, [ 213 - isMe, 214 - view.viewer.muted, 215 - onPressShare, 216 - onPressUnmuteAccount, 217 - onPressMuteAccount, 218 - onPressReportAccount, 219 - ]) 220 - return ( 221 - <View style={pal.view}> 222 - <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> 223 - <View style={styles.content}> 224 - <View style={[styles.buttonsLine]}> 225 - {isMe ? ( 226 - <TouchableOpacity 227 - testID="profileHeaderEditProfileButton" 228 - onPress={onPressEditProfile} 229 - style={[styles.btn, styles.mainBtn, pal.btn]}> 230 - <Text type="button" style={pal.text}> 231 - Edit Profile 232 - </Text> 233 - </TouchableOpacity> 234 - ) : ( 222 + }, [track, store, view]) 223 + 224 + const isMe = React.useMemo( 225 + () => store.me.did === view.did, 226 + [store.me.did, view.did], 227 + ) 228 + const dropdownItems: DropdownItem[] = React.useMemo(() => { 229 + let items: DropdownItem[] = [ 230 + { 231 + testID: 'profileHeaderDropdownShareBtn', 232 + label: 'Share', 233 + onPress: onPressShare, 234 + }, 235 + ] 236 + if (!isMe) { 237 + items.push({sep: true}) 238 + if (!view.viewer.blocking) { 239 + items.push({ 240 + testID: 'profileHeaderDropdownMuteBtn', 241 + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', 242 + onPress: view.viewer.muted 243 + ? onPressUnmuteAccount 244 + : onPressMuteAccount, 245 + }) 246 + } 247 + items.push({ 248 + testID: 'profileHeaderDropdownBlockBtn', 249 + label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', 250 + onPress: view.viewer.blocking 251 + ? onPressUnblockAccount 252 + : onPressBlockAccount, 253 + }) 254 + items.push({ 255 + testID: 'profileHeaderDropdownReportBtn', 256 + label: 'Report Account', 257 + onPress: onPressReportAccount, 258 + }) 259 + } 260 + return items 261 + }, [ 262 + isMe, 263 + view.viewer.muted, 264 + view.viewer.blocking, 265 + onPressShare, 266 + onPressUnmuteAccount, 267 + onPressMuteAccount, 268 + onPressUnblockAccount, 269 + onPressBlockAccount, 270 + onPressReportAccount, 271 + ]) 272 + 273 + const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) 274 + 275 + return ( 276 + <View style={pal.view}> 277 + <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> 278 + <View style={styles.content}> 279 + <View style={[styles.buttonsLine]}> 280 + {isMe ? ( 281 + <TouchableOpacity 282 + testID="profileHeaderEditProfileButton" 283 + onPress={onPressEditProfile} 284 + style={[styles.btn, styles.mainBtn, pal.btn]}> 285 + <Text type="button" style={pal.text}> 286 + Edit Profile 287 + </Text> 288 + </TouchableOpacity> 289 + ) : view.viewer.blocking ? ( 290 + <TouchableOpacity 291 + testID="unblockBtn" 292 + onPress={onPressUnblockAccount} 293 + style={[styles.btn, styles.mainBtn, pal.btn]}> 294 + <Text type="button" style={[pal.text, s.bold]}> 295 + Unblock 296 + </Text> 297 + </TouchableOpacity> 298 + ) : !view.viewer.blockedBy ? ( 299 + <> 300 + {store.me.follows.getFollowState(view.did) === 301 + FollowState.Following ? ( 302 + <TouchableOpacity 303 + testID="unfollowBtn" 304 + onPress={onPressToggleFollow} 305 + style={[styles.btn, styles.mainBtn, pal.btn]}> 306 + <FontAwesomeIcon 307 + icon="check" 308 + style={[pal.text, s.mr5]} 309 + size={14} 310 + /> 311 + <Text type="button" style={pal.text}> 312 + Following 313 + </Text> 314 + </TouchableOpacity> 315 + ) : ( 316 + <TouchableOpacity 317 + testID="followBtn" 318 + onPress={onPressToggleFollow} 319 + style={[styles.btn, styles.primaryBtn]}> 320 + <FontAwesomeIcon 321 + icon="plus" 322 + style={[s.white as FontAwesomeIconStyle, s.mr5]} 323 + /> 324 + <Text type="button" style={[s.white, s.bold]}> 325 + Follow 326 + </Text> 327 + </TouchableOpacity> 328 + )} 329 + </> 330 + ) : null} 331 + {dropdownItems?.length ? ( 332 + <DropdownButton 333 + testID="profileHeaderDropdownBtn" 334 + type="bare" 335 + items={dropdownItems} 336 + style={[styles.btn, styles.secondaryBtn, pal.btn]}> 337 + <FontAwesomeIcon icon="ellipsis" style={[pal.text]} /> 338 + </DropdownButton> 339 + ) : undefined} 340 + </View> 341 + <View> 342 + <Text 343 + testID="profileHeaderDisplayName" 344 + type="title-2xl" 345 + style={[pal.text, styles.title]}> 346 + {sanitizeDisplayName(view.displayName || view.handle)} 347 + </Text> 348 + </View> 349 + <View style={styles.handleLine}> 350 + {view.viewer.followedBy && !blockHide ? ( 351 + <View style={[styles.pill, pal.btn, s.mr5]}> 352 + <Text type="xs" style={[pal.text]}> 353 + Follows you 354 + </Text> 355 + </View> 356 + ) : undefined} 357 + <Text style={pal.textLight}>@{view.handle}</Text> 358 + </View> 359 + {!blockHide && ( 235 360 <> 236 - {store.me.follows.getFollowState(view.did) === 237 - FollowState.Following ? ( 361 + <View style={styles.metricsLine}> 238 362 <TouchableOpacity 239 - testID="unfollowBtn" 240 - onPress={onPressToggleFollow} 241 - style={[styles.btn, styles.mainBtn, pal.btn]}> 242 - <FontAwesomeIcon 243 - icon="check" 244 - style={[pal.text, s.mr5]} 245 - size={14} 246 - /> 247 - <Text type="button" style={pal.text}> 248 - Following 363 + testID="profileHeaderFollowersButton" 364 + style={[s.flexRow, s.mr10]} 365 + onPress={onPressFollowers}> 366 + <Text type="md" style={[s.bold, s.mr2, pal.text]}> 367 + {view.followersCount} 368 + </Text> 369 + <Text type="md" style={[pal.textLight]}> 370 + {pluralize(view.followersCount, 'follower')} 249 371 </Text> 250 372 </TouchableOpacity> 251 - ) : ( 252 373 <TouchableOpacity 253 - testID="followBtn" 254 - onPress={onPressToggleFollow} 255 - style={[styles.btn, styles.primaryBtn]}> 256 - <FontAwesomeIcon 257 - icon="plus" 258 - style={[s.white as FontAwesomeIconStyle, s.mr5]} 259 - /> 260 - <Text type="button" style={[s.white, s.bold]}> 261 - Follow 374 + testID="profileHeaderFollowsButton" 375 + style={[s.flexRow, s.mr10]} 376 + onPress={onPressFollows}> 377 + <Text type="md" style={[s.bold, s.mr2, pal.text]}> 378 + {view.followsCount} 379 + </Text> 380 + <Text type="md" style={[pal.textLight]}> 381 + following 262 382 </Text> 263 383 </TouchableOpacity> 264 - )} 384 + <View style={[s.flexRow, s.mr10]}> 385 + <Text type="md" style={[s.bold, s.mr2, pal.text]}> 386 + {view.postsCount} 387 + </Text> 388 + <Text type="md" style={[pal.textLight]}> 389 + {pluralize(view.postsCount, 'post')} 390 + </Text> 391 + </View> 392 + </View> 393 + {view.descriptionRichText ? ( 394 + <RichText 395 + testID="profileHeaderDescription" 396 + style={[styles.description, pal.text]} 397 + numberOfLines={15} 398 + richText={view.descriptionRichText} 399 + /> 400 + ) : undefined} 265 401 </> 266 402 )} 267 - {dropdownItems?.length ? ( 268 - <DropdownButton 269 - testID="profileHeaderDropdownBtn" 270 - type="bare" 271 - items={dropdownItems} 272 - style={[styles.btn, styles.secondaryBtn, pal.btn]}> 273 - <FontAwesomeIcon icon="ellipsis" style={[pal.text]} /> 274 - </DropdownButton> 275 - ) : undefined} 276 - </View> 277 - <View> 278 - <Text 279 - testID="profileHeaderDisplayName" 280 - type="title-2xl" 281 - style={[pal.text, styles.title]}> 282 - {sanitizeDisplayName(view.displayName || view.handle)} 283 - </Text> 284 - </View> 285 - <View style={styles.handleLine}> 286 - {view.viewer.followedBy ? ( 287 - <View style={[styles.pill, pal.btn, s.mr5]}> 288 - <Text type="xs" style={[pal.text]}> 289 - Follows you 290 - </Text> 291 - </View> 292 - ) : undefined} 293 - <Text style={pal.textLight}>@{view.handle}</Text> 294 - </View> 295 - <View style={styles.metricsLine}> 296 - <TouchableOpacity 297 - testID="profileHeaderFollowersButton" 298 - style={[s.flexRow, s.mr10]} 299 - onPress={onPressFollowers}> 300 - <Text type="md" style={[s.bold, s.mr2, pal.text]}> 301 - {view.followersCount} 302 - </Text> 303 - <Text type="md" style={[pal.textLight]}> 304 - {pluralize(view.followersCount, 'follower')} 305 - </Text> 306 - </TouchableOpacity> 307 - <TouchableOpacity 308 - testID="profileHeaderFollowsButton" 309 - style={[s.flexRow, s.mr10]} 310 - onPress={onPressFollows}> 311 - <Text type="md" style={[s.bold, s.mr2, pal.text]}> 312 - {view.followsCount} 313 - </Text> 314 - <Text type="md" style={[pal.textLight]}> 315 - following 316 - </Text> 317 - </TouchableOpacity> 318 - <View style={[s.flexRow, s.mr10]}> 319 - <Text type="md" style={[s.bold, s.mr2, pal.text]}> 320 - {view.postsCount} 321 - </Text> 322 - <Text type="md" style={[pal.textLight]}> 323 - {pluralize(view.postsCount, 'post')} 324 - </Text> 403 + <ProfileHeaderWarnings moderation={view.moderation.view} /> 404 + <View style={styles.moderationLines}> 405 + {view.viewer.blocking ? ( 406 + <View 407 + testID="profileHeaderBlockedNotice" 408 + style={[styles.moderationNotice, pal.view, pal.border]}> 409 + <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> 410 + <Text type="md" style={[s.mr2, pal.text]}> 411 + Account blocked 412 + </Text> 413 + </View> 414 + ) : view.viewer.muted ? ( 415 + <View 416 + testID="profileHeaderMutedNotice" 417 + style={[styles.moderationNotice, pal.view, pal.border]}> 418 + <FontAwesomeIcon 419 + icon={['far', 'eye-slash']} 420 + style={[pal.text, s.mr5]} 421 + /> 422 + <Text type="md" style={[s.mr2, pal.text]}> 423 + Account muted 424 + </Text> 425 + </View> 426 + ) : undefined} 427 + {view.viewer.blockedBy && ( 428 + <View 429 + testID="profileHeaderBlockedNotice" 430 + style={[styles.moderationNotice, pal.view, pal.border]}> 431 + <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> 432 + <Text type="md" style={[s.mr2, pal.text]}> 433 + This account has blocked you 434 + </Text> 435 + </View> 436 + )} 325 437 </View> 326 438 </View> 327 - {view.descriptionRichText ? ( 328 - <RichText 329 - testID="profileHeaderDescription" 330 - style={[styles.description, pal.text]} 331 - numberOfLines={15} 332 - richText={view.descriptionRichText} 333 - /> 334 - ) : undefined} 335 - <ProfileHeaderWarnings moderation={view.moderation.view} /> 336 - {view.viewer.muted ? ( 439 + {!isDesktopWeb && !hideBackButton && ( 440 + <TouchableWithoutFeedback 441 + onPress={onPressBack} 442 + hitSlop={BACK_HITSLOP}> 443 + <View style={styles.backBtnWrapper}> 444 + <BlurView style={styles.backBtn} blurType="dark"> 445 + <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> 446 + </BlurView> 447 + </View> 448 + </TouchableWithoutFeedback> 449 + )} 450 + <TouchableWithoutFeedback 451 + testID="profileHeaderAviButton" 452 + onPress={onPressAvi}> 337 453 <View 338 - testID="profileHeaderMutedNotice" 339 - style={[styles.detailLine, pal.btn, s.p5]}> 340 - <FontAwesomeIcon 341 - icon={['far', 'eye-slash']} 342 - style={[pal.text, s.mr5]} 454 + style={[ 455 + pal.view, 456 + {borderColor: pal.colors.background}, 457 + styles.avi, 458 + ]}> 459 + <UserAvatar 460 + size={80} 461 + avatar={view.avatar} 462 + moderation={view.moderation.avatar} 343 463 /> 344 - <Text type="md" style={[s.mr2, pal.text]}> 345 - Account muted 346 - </Text> 347 - </View> 348 - ) : undefined} 349 - </View> 350 - {!isDesktopWeb && !hideBackButton && ( 351 - <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}> 352 - <View style={styles.backBtnWrapper}> 353 - <BlurView style={styles.backBtn} blurType="dark"> 354 - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> 355 - </BlurView> 356 464 </View> 357 465 </TouchableWithoutFeedback> 358 - )} 359 - <TouchableWithoutFeedback 360 - testID="profileHeaderAviButton" 361 - onPress={onPressAvi}> 362 - <View 363 - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 364 - <UserAvatar 365 - size={80} 366 - avatar={view.avatar} 367 - moderation={view.moderation.avatar} 368 - /> 369 - </View> 370 - </TouchableWithoutFeedback> 371 - </View> 372 - ) 373 - }) 466 + </View> 467 + ) 468 + }, 469 + ) 374 470 375 471 const styles = StyleSheet.create({ 376 472 banner: { ··· 458 554 borderRadius: 4, 459 555 paddingHorizontal: 6, 460 556 paddingVertical: 2, 557 + }, 558 + 559 + moderationLines: { 560 + gap: 6, 561 + }, 562 + 563 + moderationNotice: { 564 + flexDirection: 'row', 565 + alignItems: 'center', 566 + borderWidth: 1, 567 + borderRadius: 8, 568 + paddingHorizontal: 12, 569 + paddingVertical: 10, 461 570 }, 462 571 463 572 br40: {borderRadius: 40},
+2
src/view/index.ts
··· 15 15 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' 16 16 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' 17 17 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' 18 + import {faBan} from '@fortawesome/free-solid-svg-icons/faBan' 18 19 import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' 19 20 import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' 20 21 import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark' ··· 90 91 faArrowRotateLeft, 91 92 faArrowsRotate, 92 93 faAt, 94 + faBan, 93 95 faBars, 94 96 faBell, 95 97 farBell,
+1 -1
src/view/screens/AppPasswords.tsx
··· 27 27 28 28 useFocusEffect( 29 29 React.useCallback(() => { 30 - screen('Settings') 30 + screen('AppPasswords') 31 31 store.shell.setMinimalShellMode(false) 32 32 }, [screen, store]), 33 33 )
+172
src/view/screens/BlockedAccounts.tsx
··· 1 + import React, {useMemo} from 'react' 2 + import { 3 + ActivityIndicator, 4 + FlatList, 5 + RefreshControl, 6 + StyleSheet, 7 + View, 8 + } from 'react-native' 9 + import {AppBskyActorDefs as ActorDefs} from '@atproto/api' 10 + import {Text} from '../com/util/text/Text' 11 + import {useStores} from 'state/index' 12 + import {usePalette} from 'lib/hooks/usePalette' 13 + import {isDesktopWeb} from 'platform/detection' 14 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 15 + import {observer} from 'mobx-react-lite' 16 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 17 + import {CommonNavigatorParams} from 'lib/routes/types' 18 + import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts' 19 + import {useAnalytics} from 'lib/analytics' 20 + import {useFocusEffect} from '@react-navigation/native' 21 + import {ViewHeader} from '../com/util/ViewHeader' 22 + import {CenteredView} from 'view/com/util/Views' 23 + import {ProfileCard} from 'view/com/profile/ProfileCard' 24 + 25 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'> 26 + export const BlockedAccounts = withAuthRequired( 27 + observer(({}: Props) => { 28 + const pal = usePalette('default') 29 + const store = useStores() 30 + const {screen} = useAnalytics() 31 + const blockedAccounts = useMemo( 32 + () => new BlockedAccountsModel(store), 33 + [store], 34 + ) 35 + 36 + useFocusEffect( 37 + React.useCallback(() => { 38 + screen('BlockedAccounts') 39 + store.shell.setMinimalShellMode(false) 40 + blockedAccounts.refresh() 41 + }, [screen, store, blockedAccounts]), 42 + ) 43 + 44 + const onRefresh = React.useCallback(() => { 45 + blockedAccounts.refresh() 46 + }, [blockedAccounts]) 47 + const onEndReached = React.useCallback(() => { 48 + blockedAccounts 49 + .loadMore() 50 + .catch(err => 51 + store.log.error('Failed to load more blocked accounts', err), 52 + ) 53 + }, [blockedAccounts, store]) 54 + 55 + const renderItem = ({ 56 + item, 57 + index, 58 + }: { 59 + item: ActorDefs.ProfileView 60 + index: number 61 + }) => ( 62 + <ProfileCard 63 + testID={`blockedAccount-${index}`} 64 + key={item.did} 65 + profile={item} 66 + overrideModeration 67 + /> 68 + ) 69 + return ( 70 + <CenteredView 71 + style={[ 72 + styles.container, 73 + isDesktopWeb && styles.containerDesktop, 74 + pal.view, 75 + pal.border, 76 + ]} 77 + testID="blockedAccountsScreen"> 78 + <ViewHeader title="Blocked Accounts" showOnDesktop /> 79 + <Text 80 + type="sm" 81 + style={[ 82 + styles.description, 83 + pal.text, 84 + isDesktopWeb && styles.descriptionDesktop, 85 + ]}> 86 + Blocked accounts cannot reply in your threads, mention you, or 87 + otherwise interact with you. You will not see their content and they 88 + will be prevented from seeing yours. 89 + </Text> 90 + {!blockedAccounts.hasContent ? ( 91 + <View style={[pal.border, !isDesktopWeb && styles.flex1]}> 92 + <View style={[styles.empty, pal.viewLight]}> 93 + <Text type="lg" style={[pal.text, styles.emptyText]}> 94 + You have not blocked any accounts yet. To block an account, go 95 + to their profile and selected "Block account" from the menu on 96 + their account. 97 + </Text> 98 + </View> 99 + </View> 100 + ) : ( 101 + <FlatList 102 + style={[!isDesktopWeb && styles.flex1]} 103 + data={blockedAccounts.blocks} 104 + keyExtractor={(item: ActorDefs.ProfileView) => item.did} 105 + refreshControl={ 106 + <RefreshControl 107 + refreshing={blockedAccounts.isRefreshing} 108 + onRefresh={onRefresh} 109 + tintColor={pal.colors.text} 110 + titleColor={pal.colors.text} 111 + /> 112 + } 113 + onEndReached={onEndReached} 114 + renderItem={renderItem} 115 + initialNumToRender={15} 116 + ListFooterComponent={() => ( 117 + <View style={styles.footer}> 118 + {blockedAccounts.isLoading && <ActivityIndicator />} 119 + </View> 120 + )} 121 + extraData={blockedAccounts.isLoading} 122 + // @ts-ignore our .web version only -prf 123 + desktopFixedHeight 124 + /> 125 + )} 126 + </CenteredView> 127 + ) 128 + }), 129 + ) 130 + 131 + const styles = StyleSheet.create({ 132 + container: { 133 + flex: 1, 134 + paddingBottom: isDesktopWeb ? 0 : 100, 135 + }, 136 + containerDesktop: { 137 + borderLeftWidth: 1, 138 + borderRightWidth: 1, 139 + }, 140 + title: { 141 + textAlign: 'center', 142 + marginTop: 12, 143 + marginBottom: 12, 144 + }, 145 + description: { 146 + textAlign: 'center', 147 + paddingHorizontal: 30, 148 + marginBottom: 14, 149 + }, 150 + descriptionDesktop: { 151 + marginTop: 14, 152 + }, 153 + 154 + flex1: { 155 + flex: 1, 156 + }, 157 + empty: { 158 + paddingHorizontal: 20, 159 + paddingVertical: 20, 160 + borderRadius: 16, 161 + marginHorizontal: 24, 162 + marginTop: 10, 163 + }, 164 + emptyText: { 165 + textAlign: 'center', 166 + }, 167 + 168 + footer: { 169 + height: 200, 170 + paddingTop: 20, 171 + }, 172 + })
+24 -1
src/view/screens/Profile.tsx
··· 116 116 } else if (item === ProfileUiModel.LOADING_ITEM) { 117 117 return <PostFeedLoadingPlaceholder /> 118 118 } else if (item._reactKey === '__error__') { 119 + if (uiState.feed.isBlocking) { 120 + return ( 121 + <EmptyState 122 + icon="ban" 123 + message="Posts hidden" 124 + style={styles.emptyState} 125 + /> 126 + ) 127 + } 128 + if (uiState.feed.isBlockedBy) { 129 + return ( 130 + <EmptyState 131 + icon="ban" 132 + message="Posts hidden" 133 + style={styles.emptyState} 134 + /> 135 + ) 136 + } 119 137 return ( 120 138 <View style={s.p5}> 121 139 <ErrorMessage ··· 137 155 } 138 156 return <View /> 139 157 }, 140 - [onPressTryAgain, uiState.profile.did], 158 + [ 159 + onPressTryAgain, 160 + uiState.profile.did, 161 + uiState.feed.isBlocking, 162 + uiState.feed.isBlockedBy, 163 + ], 141 164 ) 142 165 143 166 return (
+21 -1
src/view/screens/Settings.tsx
··· 255 255 <View style={styles.spacer20} /> 256 256 257 257 <Text type="xl-bold" style={[pal.text, styles.heading]}> 258 - Advanced 258 + Moderation 259 259 </Text> 260 260 <TouchableOpacity 261 261 testID="contentFilteringBtn" ··· 271 271 Content moderation 272 272 </Text> 273 273 </TouchableOpacity> 274 + <Link 275 + testID="blockedAccountsBtn" 276 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 277 + href="/settings/blocked-accounts"> 278 + <View style={[styles.iconContainer, pal.btn]}> 279 + <FontAwesomeIcon 280 + icon="ban" 281 + style={pal.text as FontAwesomeIconStyle} 282 + /> 283 + </View> 284 + <Text type="lg" style={pal.text}> 285 + Blocked accounts 286 + </Text> 287 + </Link> 288 + 289 + <View style={styles.spacer20} /> 290 + 291 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 292 + Advanced 293 + </Text> 274 294 <Link 275 295 testID="appPasswordBtn" 276 296 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+10 -10
yarn.lock
··· 30 30 tlds "^1.234.0" 31 31 typed-emitter "^2.1.0" 32 32 33 - "@atproto/api@0.2.10": 34 - version "0.2.10" 35 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.10.tgz#19c4d695f88ab4e45e4c9f2f4db5fad61590a3d2" 36 - integrity sha512-97UBtvIXhsgNO7bXhHk0JwDNwyqTcL1N0JT2rnXjUeLKNf2hDvomFtI50Y4RFU942uUS5W5VtM+JJuZO5Ryw5w== 33 + "@atproto/api@0.2.11": 34 + version "0.2.11" 35 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf" 36 + integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ== 37 37 dependencies: 38 38 "@atproto/common-web" "*" 39 39 "@atproto/uri" "*" ··· 122 122 resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" 123 123 integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== 124 124 125 - "@atproto/pds@^0.1.4": 126 - version "0.1.4" 127 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.4.tgz#43379912e127d6d4f79a514e785dab9b54fd7810" 128 - integrity sha512-vrFYL+2nNm/0fJyUIgFK9h9FRuEf4rHjU/LJV7/nBO+HA3hP3U/mTgvVxuuHHvcRsRL5AVpAJR0xWFUoYsFmmg== 125 + "@atproto/pds@^0.1.5": 126 + version "0.1.5" 127 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.5.tgz#59411497f2d85b6706ab793e8f7f618bdb8c51a3" 128 + integrity sha512-QtTf2mbqO5MEsrXPTFU43dSb0WT3TzaLw5mL++9w18CZDMvdmv2uJXKeaSiU+u3WJEtRpRs5hoLSdfrJ2i3PuA== 129 129 dependencies: 130 130 "@atproto/api" "*" 131 131 "@atproto/common" "*" ··· 154 154 nodemailer "^6.8.0" 155 155 nodemailer-html-to-text "^3.2.0" 156 156 p-queue "^6.6.2" 157 - pg "^8.8.0" 157 + pg "^8.10.0" 158 158 pino "^8.6.1" 159 159 pino-http "^8.2.1" 160 160 sharp "^0.31.2" ··· 13419 13419 postgres-date "~1.0.4" 13420 13420 postgres-interval "^1.1.0" 13421 13421 13422 - pg@^8.8.0, pg@^8.9.0: 13422 + pg@^8.10.0, pg@^8.9.0: 13423 13423 version "8.10.0" 13424 13424 resolved "https://registry.yarnpkg.com/pg/-/pg-8.10.0.tgz#5b8379c9b4a36451d110fc8cd98fc325fe62ad24" 13425 13425 integrity sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==