mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

[APP-635] Mutelists (#601)

* Add lists and profilelist screens

* Implement lists screen and lists-list in profiles

* Add empty states to the lists screen

* Switch (mostly) from blocklists to mutelists

* Rework: create a new moderation screen and move everything related under it

* Fix moderation screen on desktop web

* Tune the empty state code

* Change content moderation modal to content filtering

* Add CreateMuteList modal

* Implement mutelist creation

* Add lists listings

* Add the ability to create new mutelists

* Add 'add to list' tool

* Satisfy the hashtag hyphen haters

* Add update/delete/subscribe/unsubscribe to lists

* Show which list caused a mute

* Add list un/subscribe

* Add the mute override when viewing a profile's posts

* Update to latest backend

* Add simulation tests and tune some behaviors

* Fix lint

* Bump deps

* Fix list refresh after creation

* Mute list subscriptions -> Mute lists

authored by

Paul Frazee and committed by
GitHub
ebcd6333 34d8fa59

+2984 -151
+23 -1
__e2e__/mock-server.ts
··· 91 91 'always-warn-profile', 92 92 'always-warn-posts', 93 93 'muted-account', 94 + 'muted-by-list-account', 94 95 ]) { 95 96 await server.mocker.createUser(user) 96 97 await server.mocker.follow('alice', user) ··· 258 259 await server.mocker.createPost('muted-account', 'muted post') 259 260 await server.mocker.createQuotePost( 260 261 'muted-account', 262 + 'muted quote post', 263 + anchorPost, 264 + ) 265 + await server.mocker.createReply( 266 + 'muted-account', 267 + 'muted reply', 268 + anchorPost, 269 + ) 270 + 271 + const list = await server.mocker.createMuteList( 272 + 'alice', 273 + 'Muted Users', 274 + ) 275 + await server.mocker.addToMuteList( 276 + 'alice', 277 + list, 278 + server.mocker.users['muted-by-list-account'].did, 279 + ) 280 + await server.mocker.createPost('muted-by-list-account', 'muted post') 281 + await server.mocker.createQuotePost( 282 + 'muted-by-list-account', 261 283 'account quote post', 262 284 anchorPost, 263 285 ) 264 286 await server.mocker.createReply( 265 - 'muted-account', 287 + 'muted-by-list-account', 266 288 'account reply', 267 289 anchorPost, 268 290 )
+141
__e2e__/tests/mute-lists.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {openApp, login, createServer, sleep} from '../util' 4 + 5 + describe('Profile screen', () => { 6 + let service: string 7 + beforeAll(async () => { 8 + service = await createServer('?users&follows&labels') 9 + await openApp({ 10 + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 + }) 12 + }) 13 + 14 + it('Login and view my mutelists', async () => { 15 + await expect(element(by.id('signInButton'))).toBeVisible() 16 + await login(service, 'alice', 'hunter2') 17 + await element(by.id('viewHeaderDrawerBtn')).tap() 18 + await expect(element(by.id('drawer'))).toBeVisible() 19 + await element(by.id('menuItemButton-Moderation')).tap() 20 + await element(by.id('mutelistsBtn')).tap() 21 + await expect(element(by.id('list-Muted Users'))).toBeVisible() 22 + await element(by.id('list-Muted Users')).tap() 23 + await expect( 24 + element(by.id('user-muted-by-list-account.test')), 25 + ).toBeVisible() 26 + }) 27 + 28 + it('Toggle subscription', async () => { 29 + await element(by.id('unsubscribeListBtn')).tap() 30 + await element(by.id('subscribeListBtn')).tap() 31 + }) 32 + 33 + it('Edit display name and description via the edit mutelist modal', async () => { 34 + await element(by.id('editListBtn')).tap() 35 + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 36 + await element(by.id('editNameInput')).clearText() 37 + await element(by.id('editNameInput')).typeText('Bad Ppl') 38 + await element(by.id('editDescriptionInput')).clearText() 39 + await element(by.id('editDescriptionInput')).typeText('They bad') 40 + await element(by.id('saveBtn')).tap() 41 + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 42 + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') 43 + await expect(element(by.id('listDescription'))).toHaveText('They bad') 44 + // have to wait for the toast to clear 45 + await waitFor(element(by.id('editListBtn'))) 46 + .toBeVisible() 47 + .withTimeout(5000) 48 + }) 49 + 50 + it('Remove description via the edit mutelist modal', async () => { 51 + await element(by.id('editListBtn')).tap() 52 + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 53 + await element(by.id('editDescriptionInput')).clearText() 54 + await element(by.id('saveBtn')).tap() 55 + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 56 + await expect(element(by.id('listDescription'))).not.toBeVisible() 57 + // have to wait for the toast to clear 58 + await waitFor(element(by.id('editListBtn'))) 59 + .toBeVisible() 60 + .withTimeout(5000) 61 + }) 62 + 63 + it('Set avi via the edit mutelist modal', async () => { 64 + await expect(element(by.id('userAvatarFallback'))).toExist() 65 + await element(by.id('editListBtn')).tap() 66 + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 67 + await element(by.id('changeAvatarBtn')).tap() 68 + await element(by.id('changeAvatarLibraryBtn')).tap() 69 + await sleep(3e3) 70 + await element(by.id('saveBtn')).tap() 71 + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 72 + await expect(element(by.id('userAvatarImage'))).toExist() 73 + // have to wait for the toast to clear 74 + await waitFor(element(by.id('editListBtn'))) 75 + .toBeVisible() 76 + .withTimeout(5000) 77 + }) 78 + 79 + it('Remove avi via the edit mutelist modal', async () => { 80 + await expect(element(by.id('userAvatarImage'))).toExist() 81 + await element(by.id('editListBtn')).tap() 82 + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 83 + await element(by.id('changeAvatarBtn')).tap() 84 + await element(by.id('changeAvatarRemoveBtn')).tap() 85 + await element(by.id('saveBtn')).tap() 86 + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 87 + await expect(element(by.id('userAvatarFallback'))).toExist() 88 + // have to wait for the toast to clear 89 + await waitFor(element(by.id('editListBtn'))) 90 + .toBeVisible() 91 + .withTimeout(5000) 92 + }) 93 + 94 + it('Delete the mutelist', async () => { 95 + await element(by.id('deleteListBtn')).tap() 96 + await element(by.id('confirmBtn')).tap() 97 + await expect(element(by.id('emptyMuteLists'))).toBeVisible() 98 + }) 99 + 100 + it('Create a new mutelist', async () => { 101 + await element(by.id('emptyMuteLists-button')).tap() 102 + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 103 + await element(by.id('editNameInput')).typeText('Bad Ppl') 104 + await element(by.id('editDescriptionInput')).typeText('They bad') 105 + await element(by.id('saveBtn')).tap() 106 + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 107 + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') 108 + await expect(element(by.id('listDescription'))).toHaveText('They bad') 109 + // have to wait for the toast to clear 110 + await waitFor(element(by.id('editListBtn'))) 111 + .toBeVisible() 112 + .withTimeout(5000) 113 + }) 114 + 115 + it('Shows the mutelist on my profile', async () => { 116 + await element(by.id('bottomBarProfileBtn')).tap() 117 + await element(by.id('selector-2')).tap() 118 + await element(by.id('list-Bad Ppl')).tap() 119 + }) 120 + 121 + it('Adds and removes users on mutelists', async () => { 122 + await element(by.id('bottomBarSearchBtn')).tap() 123 + await element(by.id('searchTextInput')).typeText('bob') 124 + await element(by.id('searchAutoCompleteResult-bob.test')).tap() 125 + await expect(element(by.id('profileView'))).toBeVisible() 126 + 127 + await element(by.id('profileHeaderDropdownBtn')).tap() 128 + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() 129 + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() 130 + await element(by.id('toggleBtn-Bad Ppl')).tap() 131 + await element(by.id('saveBtn')).tap() 132 + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() 133 + 134 + await element(by.id('profileHeaderDropdownBtn')).tap() 135 + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() 136 + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() 137 + await element(by.id('toggleBtn-Bad Ppl')).tap() 138 + await element(by.id('saveBtn')).tap() 139 + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() 140 + }) 141 + })
+5 -2
bskyweb/cmd/bskyweb/server.go
··· 106 106 // generic routes 107 107 e.GET("/search", server.WebGeneric) 108 108 e.GET("/notifications", server.WebGeneric) 109 + e.GET("/moderation", server.WebGeneric) 110 + e.GET("/moderation/mute-lists", server.WebGeneric) 111 + e.GET("/moderation/muted-accounts", server.WebGeneric) 112 + e.GET("/moderation/blocked-accounts", server.WebGeneric) 109 113 e.GET("/settings", server.WebGeneric) 110 114 e.GET("/settings/app-passwords", server.WebGeneric) 111 - e.GET("/settings/muted-accounts", server.WebGeneric) 112 - e.GET("/settings/blocked-accounts", server.WebGeneric) 113 115 e.GET("/sys/debug", server.WebGeneric) 114 116 e.GET("/sys/log", server.WebGeneric) 115 117 e.GET("/support", server.WebGeneric) ··· 122 124 e.GET("/profile/:handle", server.WebProfile) 123 125 e.GET("/profile/:handle/follows", server.WebGeneric) 124 126 e.GET("/profile/:handle/followers", server.WebGeneric) 127 + e.GET("/profile/:handle/lists/:rkey", server.WebGeneric) 125 128 126 129 // post endpoints; only first populates info 127 130 e.GET("/profile/:handle/post/:rkey", server.WebPost)
+26
jest/test-pds.ts
··· 337 337 ]) 338 338 .execute() 339 339 } 340 + 341 + async createMuteList(user: string, name: string): Promise<string> { 342 + const res = await this.users[user]?.agent.app.bsky.graph.list.create( 343 + {repo: this.users[user]?.did}, 344 + { 345 + purpose: 'app.bsky.graph.defs#modlist', 346 + name, 347 + createdAt: new Date().toISOString(), 348 + }, 349 + ) 350 + await this.users[user]?.agent.app.bsky.graph.muteActorList({ 351 + list: res.uri, 352 + }) 353 + return res.uri 354 + } 355 + 356 + async addToMuteList(owner: string, list: string, subject: string) { 357 + await this.users[owner]?.agent.app.bsky.graph.listitem.create( 358 + {repo: this.users[owner]?.did}, 359 + { 360 + list, 361 + subject, 362 + createdAt: new Date().toISOString(), 363 + }, 364 + ) 365 + } 340 366 } 341 367 342 368 const checkAvailablePort = (port: number) =>
+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.11", 25 + "@atproto/api": "0.3.1", 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", ··· 140 140 "zod": "^3.20.2" 141 141 }, 142 142 "devDependencies": { 143 - "@atproto/pds": "^0.1.5", 143 + "@atproto/pds": "^0.1.6", 144 144 "@babel/core": "^7.20.0", 145 145 "@babel/preset-env": "^7.20.0", 146 146 "@babel/runtime": "^7.20.0",
+19 -4
src/Navigation.tsx
··· 33 33 import {HomeScreen} from './view/screens/Home' 34 34 import {SearchScreen} from './view/screens/Search' 35 35 import {NotificationsScreen} from './view/screens/Notifications' 36 + import {ModerationScreen} from './view/screens/Moderation' 37 + import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' 36 38 import {NotFoundScreen} from './view/screens/NotFound' 37 39 import {SettingsScreen} from './view/screens/Settings' 38 40 import {ProfileScreen} from './view/screens/Profile' 39 41 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' 40 42 import {ProfileFollowsScreen} from './view/screens/ProfileFollows' 43 + import {ProfileListScreen} from './view/screens/ProfileList' 41 44 import {PostThreadScreen} from './view/screens/PostThread' 42 45 import {PostLikedByScreen} from './view/screens/PostLikedBy' 43 46 import {PostRepostedByScreen} from './view/screens/PostRepostedBy' ··· 49 52 import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' 50 53 import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' 51 54 import {AppPasswords} from 'view/screens/AppPasswords' 52 - import {MutedAccounts} from 'view/screens/MutedAccounts' 53 - import {BlockedAccounts} from 'view/screens/BlockedAccounts' 55 + import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' 56 + import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' 54 57 import {getRoutingInstrumentation} from 'lib/sentry' 55 58 56 59 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() ··· 70 73 return ( 71 74 <> 72 75 <Stack.Screen name="NotFound" component={NotFoundScreen} /> 76 + <Stack.Screen name="Moderation" component={ModerationScreen} /> 77 + <Stack.Screen 78 + name="ModerationMuteLists" 79 + component={ModerationMuteListsScreen} 80 + /> 81 + <Stack.Screen 82 + name="ModerationMutedAccounts" 83 + component={ModerationMutedAccounts} 84 + /> 85 + <Stack.Screen 86 + name="ModerationBlockedAccounts" 87 + component={ModerationBlockedAccounts} 88 + /> 73 89 <Stack.Screen name="Settings" component={SettingsScreen} /> 74 90 <Stack.Screen name="Profile" component={ProfileScreen} /> 75 91 <Stack.Screen ··· 77 93 component={ProfileFollowersScreen} 78 94 /> 79 95 <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} /> 96 + <Stack.Screen name="ProfileList" component={ProfileListScreen} /> 80 97 <Stack.Screen name="PostThread" component={PostThreadScreen} /> 81 98 <Stack.Screen name="PostLikedBy" component={PostLikedByScreen} /> 82 99 <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} /> ··· 91 108 /> 92 109 <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} /> 93 110 <Stack.Screen name="AppPasswords" component={AppPasswords} /> 94 - <Stack.Screen name="MutedAccounts" component={MutedAccounts} /> 95 - <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} /> 96 111 </> 97 112 ) 98 113 }
+30 -3
src/lib/labeling/helpers.ts
··· 1 1 import { 2 2 AppBskyActorDefs, 3 + AppBskyGraphDefs, 3 4 AppBskyEmbedRecordWithMedia, 4 5 AppBskyEmbedRecord, 5 6 AppBskyEmbedImages, ··· 16 17 Label, 17 18 LabelValGroup, 18 19 ModerationBehaviorCode, 20 + ModerationBehavior, 19 21 PostModeration, 20 22 ProfileModeration, 21 23 PostLabelInfo, ··· 127 129 128 130 // muting 129 131 if (postInfo.isMuted) { 132 + let msg = 'Post from an account you muted.' 133 + if (postInfo.mutedByList) { 134 + msg = `Muted by ${postInfo.mutedByList.name}` 135 + } 130 136 return { 131 137 avatar, 132 - list: hide('Post from an account you muted.'), 133 - thread: warn('Post from an account you muted.'), 134 - view: warn('Post from an account you muted.'), 138 + list: isMute(hide(msg)), 139 + thread: isMute(warn(msg)), 140 + view: isMute(warn(msg)), 135 141 } 136 142 } 137 143 ··· 273 279 profileLabels: filterProfileLabels(profile.labels), 274 280 isMuted: profile.viewer?.muted || false, 275 281 isBlocking: !!profile.viewer?.blocking || false, 282 + isBlockedBy: !!profile.viewer?.blockedBy || false, 276 283 } 277 284 } 278 285 ··· 302 309 return false 303 310 } 304 311 312 + export function getEmbedMutedByList( 313 + embed?: Embed, 314 + ): AppBskyGraphDefs.ListViewBasic | undefined { 315 + if (!embed) { 316 + return undefined 317 + } 318 + if ( 319 + AppBskyEmbedRecord.isView(embed) && 320 + AppBskyEmbedRecord.isViewRecord(embed.record) 321 + ) { 322 + return embed.record.author.viewer?.mutedByList 323 + } 324 + return undefined 325 + } 326 + 305 327 export function getEmbedBlocking(embed?: Embed): boolean { 306 328 if (!embed) { 307 329 return false ··· 399 421 behavior: ModerationBehaviorCode.WarnContent, 400 422 reason, 401 423 } 424 + } 425 + 426 + function isMute(behavior: ModerationBehavior): ModerationBehavior { 427 + behavior.isMute = true 428 + return behavior 402 429 } 403 430 404 431 function warnImages(reason: string) {
+3 -1
src/lib/labeling/types.ts
··· 1 - import {ComAtprotoLabelDefs} from '@atproto/api' 1 + import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api' 2 2 import {LabelPreferencesModel} from 'state/models/ui/preferences' 3 3 4 4 export type Label = ComAtprotoLabelDefs.Label ··· 22 22 accountLabels: Label[] 23 23 profileLabels: Label[] 24 24 isMuted: boolean 25 + mutedByList?: AppBskyGraphDefs.ListViewBasic 25 26 isBlocking: boolean 26 27 isBlockedBy: boolean 27 28 } ··· 44 45 45 46 export interface ModerationBehavior { 46 47 behavior: ModerationBehaviorCode 48 + isMute?: boolean 47 49 noOverride?: boolean 48 50 reason?: string 49 51 }
+5 -2
src/lib/routes/types.ts
··· 5 5 6 6 export type CommonNavigatorParams = { 7 7 NotFound: undefined 8 + Moderation: undefined 9 + ModerationMuteLists: undefined 10 + ModerationMutedAccounts: undefined 11 + ModerationBlockedAccounts: undefined 8 12 Settings: undefined 9 13 Profile: {name: string; hideBackButton?: boolean} 10 14 ProfileFollowers: {name: string} 11 15 ProfileFollows: {name: string} 16 + ProfileList: {name: string; rkey: string} 12 17 PostThread: {name: string; rkey: string} 13 18 PostLikedBy: {name: string; rkey: string} 14 19 PostRepostedBy: {name: string; rkey: string} ··· 20 25 CommunityGuidelines: undefined 21 26 CopyrightPolicy: undefined 22 27 AppPasswords: undefined 23 - MutedAccounts: undefined 24 - BlockedAccounts: undefined 25 28 } 26 29 27 30 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+9
src/lib/strings/url-helpers.ts
··· 94 94 return url 95 95 } 96 96 97 + export function listUriToHref(url: string): string { 98 + try { 99 + const {hostname, rkey} = new AtUri(url) 100 + return `/profile/${hostname}/lists/${rkey}` 101 + } catch { 102 + return '' 103 + } 104 + } 105 + 97 106 export function getYoutubeVideoId(link: string): string | undefined { 98 107 let url 99 108 try {
+5 -2
src/routes.ts
··· 5 5 Search: '/search', 6 6 Notifications: '/notifications', 7 7 Settings: '/settings', 8 + Moderation: '/moderation', 9 + ModerationMuteLists: '/moderation/mute-lists', 10 + ModerationMutedAccounts: '/moderation/muted-accounts', 11 + ModerationBlockedAccounts: '/moderation/blocked-accounts', 8 12 Profile: '/profile/:name', 9 13 ProfileFollowers: '/profile/:name/followers', 10 14 ProfileFollows: '/profile/:name/follows', 15 + ProfileList: '/profile/:name/lists/:rkey', 11 16 PostThread: '/profile/:name/post/:rkey', 12 17 PostLikedBy: '/profile/:name/post/:rkey/liked-by', 13 18 PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', 14 19 Debug: '/sys/debug', 15 20 Log: '/sys/log', 16 21 AppPasswords: '/settings/app-passwords', 17 - MutedAccounts: '/settings/muted-accounts', 18 - BlockedAccounts: '/settings/blocked-accounts', 19 22 Support: '/support', 20 23 PrivacyPolicy: '/support/privacy', 21 24 TermsOfService: '/support/tos',
+112
src/state/models/content/list-membership.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import {AtUri, AppBskyGraphListitem} from '@atproto/api' 3 + import {runInAction} from 'mobx' 4 + import {RootStoreModel} from '../root-store' 5 + 6 + const PAGE_SIZE = 100 7 + interface Membership { 8 + uri: string 9 + value: AppBskyGraphListitem.Record 10 + } 11 + 12 + export class ListMembershipModel { 13 + // data 14 + memberships: Membership[] = [] 15 + 16 + constructor(public rootStore: RootStoreModel, public subject: string) { 17 + makeAutoObservable( 18 + this, 19 + { 20 + rootStore: false, 21 + }, 22 + {autoBind: true}, 23 + ) 24 + } 25 + 26 + // public api 27 + // = 28 + 29 + async fetch() { 30 + // NOTE 31 + // this approach to determining list membership is too inefficient to work at any scale 32 + // it needs to be replaced with server side list membership queries 33 + // -prf 34 + let cursor 35 + let records = [] 36 + for (let i = 0; i < 100; i++) { 37 + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ 38 + repo: this.rootStore.me.did, 39 + cursor, 40 + limit: PAGE_SIZE, 41 + }) 42 + records = records.concat( 43 + res.records.filter(record => record.value.subject === this.subject), 44 + ) 45 + cursor = res.cursor 46 + if (!cursor) { 47 + break 48 + } 49 + } 50 + runInAction(() => { 51 + this.memberships = records 52 + }) 53 + } 54 + 55 + getMembership(listUri: string) { 56 + return this.memberships.find(m => m.value.list === listUri) 57 + } 58 + 59 + isMember(listUri: string) { 60 + return !!this.getMembership(listUri) 61 + } 62 + 63 + async add(listUri: string) { 64 + if (this.isMember(listUri)) { 65 + return 66 + } 67 + const res = await this.rootStore.agent.app.bsky.graph.listitem.create( 68 + { 69 + repo: this.rootStore.me.did, 70 + }, 71 + { 72 + subject: this.subject, 73 + list: listUri, 74 + createdAt: new Date().toISOString(), 75 + }, 76 + ) 77 + const {rkey} = new AtUri(res.uri) 78 + const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ 79 + repo: this.rootStore.me.did, 80 + rkey, 81 + }) 82 + runInAction(() => { 83 + this.memberships = this.memberships.concat([record]) 84 + }) 85 + } 86 + 87 + async remove(listUri: string) { 88 + const membership = this.getMembership(listUri) 89 + if (!membership) { 90 + return 91 + } 92 + const {rkey} = new AtUri(membership.uri) 93 + await this.rootStore.agent.app.bsky.graph.listitem.delete({ 94 + repo: this.rootStore.me.did, 95 + rkey, 96 + }) 97 + runInAction(() => { 98 + this.memberships = this.memberships.filter(m => m.value.list !== listUri) 99 + }) 100 + } 101 + 102 + async updateTo(uris: string) { 103 + for (const uri of uris) { 104 + await this.add(uri) 105 + } 106 + for (const membership of this.memberships) { 107 + if (!uris.includes(membership.value.list)) { 108 + await this.remove(membership.value.list) 109 + } 110 + } 111 + } 112 + }
+257
src/state/models/content/list.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import { 3 + AtUri, 4 + AppBskyGraphGetList as GetList, 5 + AppBskyGraphDefs as GraphDefs, 6 + AppBskyGraphList, 7 + } from '@atproto/api' 8 + import {Image as RNImage} from 'react-native-image-crop-picker' 9 + import {RootStoreModel} from '../root-store' 10 + import * as apilib from 'lib/api/index' 11 + import {cleanError} from 'lib/strings/errors' 12 + import {bundleAsync} from 'lib/async/bundle' 13 + 14 + const PAGE_SIZE = 30 15 + 16 + export class ListModel { 17 + // state 18 + isLoading = false 19 + isRefreshing = false 20 + hasLoaded = false 21 + error = '' 22 + loadMoreError = '' 23 + hasMore = true 24 + loadMoreCursor?: string 25 + 26 + // data 27 + list: GraphDefs.ListView | null = null 28 + items: GraphDefs.ListItemView[] = [] 29 + 30 + static async createModList( 31 + rootStore: RootStoreModel, 32 + { 33 + name, 34 + description, 35 + avatar, 36 + }: {name: string; description: string; avatar: RNImage | undefined}, 37 + ) { 38 + const record: AppBskyGraphList.Record = { 39 + purpose: 'app.bsky.graph.defs#modlist', 40 + name, 41 + description, 42 + avatar: undefined, 43 + createdAt: new Date().toISOString(), 44 + } 45 + if (avatar) { 46 + const blobRes = await apilib.uploadBlob( 47 + rootStore, 48 + avatar.path, 49 + avatar.mime, 50 + ) 51 + record.avatar = blobRes.data.blob 52 + } 53 + const res = await rootStore.agent.app.bsky.graph.list.create( 54 + { 55 + repo: rootStore.me.did, 56 + }, 57 + record, 58 + ) 59 + await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) 60 + return res 61 + } 62 + 63 + constructor(public rootStore: RootStoreModel, public uri: string) { 64 + makeAutoObservable( 65 + this, 66 + { 67 + rootStore: false, 68 + }, 69 + {autoBind: true}, 70 + ) 71 + } 72 + 73 + get hasContent() { 74 + return this.items.length > 0 75 + } 76 + 77 + get hasError() { 78 + return this.error !== '' 79 + } 80 + 81 + get isEmpty() { 82 + return this.hasLoaded && !this.hasContent 83 + } 84 + 85 + get isOwner() { 86 + return this.list?.creator.did === this.rootStore.me.did 87 + } 88 + 89 + // public api 90 + // = 91 + 92 + async refresh() { 93 + return this.loadMore(true) 94 + } 95 + 96 + loadMore = bundleAsync(async (replace: boolean = false) => { 97 + if (!replace && !this.hasMore) { 98 + return 99 + } 100 + this._xLoading(replace) 101 + try { 102 + const res = await this.rootStore.agent.app.bsky.graph.getList({ 103 + list: this.uri, 104 + limit: PAGE_SIZE, 105 + cursor: replace ? undefined : this.loadMoreCursor, 106 + }) 107 + if (replace) { 108 + this._replaceAll(res) 109 + } else { 110 + this._appendAll(res) 111 + } 112 + this._xIdle() 113 + } catch (e: any) { 114 + this._xIdle(replace ? e : undefined, !replace ? e : undefined) 115 + } 116 + }) 117 + 118 + async updateMetadata({ 119 + name, 120 + description, 121 + avatar, 122 + }: { 123 + name: string 124 + description: string 125 + avatar: RNImage | null | undefined 126 + }) { 127 + if (!this.isOwner) { 128 + throw new Error('Cannot edit this list') 129 + } 130 + 131 + // get the current record 132 + const {rkey} = new AtUri(this.uri) 133 + const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ 134 + repo: this.rootStore.me.did, 135 + rkey, 136 + }) 137 + 138 + // update the fields 139 + record.name = name 140 + record.description = description 141 + if (avatar) { 142 + const blobRes = await apilib.uploadBlob( 143 + this.rootStore, 144 + avatar.path, 145 + avatar.mime, 146 + ) 147 + record.avatar = blobRes.data.blob 148 + } else if (avatar === null) { 149 + record.avatar = undefined 150 + } 151 + return await this.rootStore.agent.com.atproto.repo.putRecord({ 152 + repo: this.rootStore.me.did, 153 + collection: 'app.bsky.graph.list', 154 + rkey, 155 + record, 156 + }) 157 + } 158 + 159 + async delete() { 160 + // fetch all the listitem records that belong to this list 161 + let cursor 162 + let records = [] 163 + for (let i = 0; i < 100; i++) { 164 + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ 165 + repo: this.rootStore.me.did, 166 + cursor, 167 + limit: PAGE_SIZE, 168 + }) 169 + records = records.concat( 170 + res.records.filter(record => record.value.list === this.uri), 171 + ) 172 + cursor = res.cursor 173 + if (!cursor) { 174 + break 175 + } 176 + } 177 + 178 + // batch delete the list and listitem records 179 + const createDel = (uri: string) => { 180 + const urip = new AtUri(uri) 181 + return { 182 + $type: 'com.atproto.repo.applyWrites#delete', 183 + collection: urip.collection, 184 + rkey: urip.rkey, 185 + } 186 + } 187 + await this.rootStore.agent.com.atproto.repo.applyWrites({ 188 + repo: this.rootStore.me.did, 189 + writes: [createDel(this.uri)].concat( 190 + records.map(record => createDel(record.uri)), 191 + ), 192 + }) 193 + } 194 + 195 + async subscribe() { 196 + await this.rootStore.agent.app.bsky.graph.muteActorList({ 197 + list: this.list.uri, 198 + }) 199 + await this.refresh() 200 + } 201 + 202 + async unsubscribe() { 203 + await this.rootStore.agent.app.bsky.graph.unmuteActorList({ 204 + list: this.list.uri, 205 + }) 206 + await this.refresh() 207 + } 208 + 209 + /** 210 + * Attempt to load more again after a failure 211 + */ 212 + async retryLoadMore() { 213 + this.loadMoreError = '' 214 + this.hasMore = true 215 + return this.loadMore() 216 + } 217 + 218 + // state transitions 219 + // = 220 + 221 + _xLoading(isRefreshing = false) { 222 + this.isLoading = true 223 + this.isRefreshing = isRefreshing 224 + this.error = '' 225 + } 226 + 227 + _xIdle(err?: any, loadMoreErr?: any) { 228 + this.isLoading = false 229 + this.isRefreshing = false 230 + this.hasLoaded = true 231 + this.error = cleanError(err) 232 + this.loadMoreError = cleanError(loadMoreErr) 233 + if (err) { 234 + this.rootStore.log.error('Failed to fetch user items', err) 235 + } 236 + if (loadMoreErr) { 237 + this.rootStore.log.error('Failed to fetch user items', loadMoreErr) 238 + } 239 + } 240 + 241 + // helper functions 242 + // = 243 + 244 + _replaceAll(res: GetList.Response) { 245 + this.items = [] 246 + this._appendAll(res) 247 + } 248 + 249 + _appendAll(res: GetList.Response) { 250 + this.loadMoreCursor = res.data.cursor 251 + this.hasMore = !!this.loadMoreCursor 252 + this.list = res.data.list 253 + this.items = this.items.concat( 254 + res.data.items.map(item => ({...item, _reactKey: item.subject})), 255 + ) 256 + } 257 + }
+4
src/state/models/content/post-thread.ts
··· 14 14 import { 15 15 getEmbedLabels, 16 16 getEmbedMuted, 17 + getEmbedMutedByList, 17 18 getEmbedBlocking, 18 19 getEmbedBlockedBy, 19 20 filterAccountLabels, ··· 70 71 this.post.author.viewer?.muted || 71 72 getEmbedMuted(this.post.embed) || 72 73 false, 74 + mutedByList: 75 + this.post.author.viewer?.mutedByList || 76 + getEmbedMutedByList(this.post.embed), 73 77 isBlocking: 74 78 !!this.post.author.viewer?.blocking || 75 79 getEmbedBlocking(this.post.embed) ||
+2 -2
src/state/models/content/profile.ts
··· 2 2 import { 3 3 AtUri, 4 4 ComAtprotoLabelDefs, 5 + AppBskyGraphDefs, 5 6 AppBskyActorGetProfile as GetProfile, 6 7 AppBskyActorProfile, 7 8 RichText, ··· 18 19 filterProfileLabels, 19 20 } from 'lib/labeling/helpers' 20 21 21 - export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' 22 - 23 22 export class ProfileViewerModel { 24 23 muted?: boolean 24 + mutedByList?: AppBskyGraphDefs.ListViewBasic 25 25 following?: string 26 26 followedBy?: string 27 27 blockedBy?: boolean
+1
src/state/models/feeds/notifications.ts
··· 111 111 addedInfo?.profileLabels || [], 112 112 ), 113 113 isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, 114 + mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, 114 115 isBlocking: 115 116 !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, 116 117 isBlockedBy:
+4
src/state/models/feeds/posts.ts
··· 24 24 import { 25 25 getEmbedLabels, 26 26 getEmbedMuted, 27 + getEmbedMutedByList, 27 28 getEmbedBlocking, 28 29 getEmbedBlockedBy, 29 30 getPostModeration, ··· 105 106 this.post.author.viewer?.muted || 106 107 getEmbedMuted(this.post.embed) || 107 108 false, 109 + mutedByList: 110 + this.post.author.viewer?.mutedByList || 111 + getEmbedMutedByList(this.post.embed), 108 112 isBlocking: 109 113 !!this.post.author.viewer?.blocking || 110 114 getEmbedBlocking(this.post.embed) ||
+214
src/state/models/lists/lists-list.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import { 3 + AppBskyGraphGetLists as GetLists, 4 + AppBskyGraphGetListMutes as GetListMutes, 5 + AppBskyGraphDefs as GraphDefs, 6 + } from '@atproto/api' 7 + import {RootStoreModel} from '../root-store' 8 + import {cleanError} from 'lib/strings/errors' 9 + import {bundleAsync} from 'lib/async/bundle' 10 + 11 + const PAGE_SIZE = 30 12 + 13 + export class ListsListModel { 14 + // state 15 + isLoading = false 16 + isRefreshing = false 17 + hasLoaded = false 18 + error = '' 19 + loadMoreError = '' 20 + hasMore = true 21 + loadMoreCursor?: string 22 + 23 + // data 24 + lists: GraphDefs.ListView[] = [] 25 + 26 + constructor( 27 + public rootStore: RootStoreModel, 28 + public source: 'my-modlists' | string, 29 + ) { 30 + makeAutoObservable( 31 + this, 32 + { 33 + rootStore: false, 34 + }, 35 + {autoBind: true}, 36 + ) 37 + } 38 + 39 + get hasContent() { 40 + return this.lists.length > 0 41 + } 42 + 43 + get hasError() { 44 + return this.error !== '' 45 + } 46 + 47 + get isEmpty() { 48 + return this.hasLoaded && !this.hasContent 49 + } 50 + 51 + // public api 52 + // = 53 + 54 + async refresh() { 55 + return this.loadMore(true) 56 + } 57 + 58 + loadMore = bundleAsync(async (replace: boolean = false) => { 59 + if (!replace && !this.hasMore) { 60 + return 61 + } 62 + this._xLoading(replace) 63 + try { 64 + let res 65 + if (this.source === 'my-modlists') { 66 + res = { 67 + success: true, 68 + headers: {}, 69 + data: { 70 + subject: undefined, 71 + lists: [], 72 + }, 73 + } 74 + const [res1, res2] = await Promise.all([ 75 + fetchAllUserLists(this.rootStore, this.rootStore.me.did), 76 + fetchAllMyMuteLists(this.rootStore), 77 + ]) 78 + for (let list of res1.data.lists) { 79 + if (list.purpose === 'app.bsky.graph.defs#modlist') { 80 + res.data.lists.push(list) 81 + } 82 + } 83 + for (let list of res2.data.lists) { 84 + if ( 85 + list.purpose === 'app.bsky.graph.defs#modlist' && 86 + !res.data.lists.find(l => l.uri === list.uri) 87 + ) { 88 + res.data.lists.push(list) 89 + } 90 + } 91 + } else { 92 + res = await this.rootStore.agent.app.bsky.graph.getLists({ 93 + actor: this.source, 94 + limit: PAGE_SIZE, 95 + cursor: replace ? undefined : this.loadMoreCursor, 96 + }) 97 + } 98 + if (replace) { 99 + this._replaceAll(res) 100 + } else { 101 + this._appendAll(res) 102 + } 103 + this._xIdle() 104 + } catch (e: any) { 105 + this._xIdle(replace ? e : undefined, !replace ? e : undefined) 106 + } 107 + }) 108 + 109 + /** 110 + * Attempt to load more again after a failure 111 + */ 112 + async retryLoadMore() { 113 + this.loadMoreError = '' 114 + this.hasMore = true 115 + return this.loadMore() 116 + } 117 + 118 + // state transitions 119 + // = 120 + 121 + _xLoading(isRefreshing = false) { 122 + this.isLoading = true 123 + this.isRefreshing = isRefreshing 124 + this.error = '' 125 + } 126 + 127 + _xIdle(err?: any, loadMoreErr?: any) { 128 + this.isLoading = false 129 + this.isRefreshing = false 130 + this.hasLoaded = true 131 + this.error = cleanError(err) 132 + this.loadMoreError = cleanError(loadMoreErr) 133 + if (err) { 134 + this.rootStore.log.error('Failed to fetch user lists', err) 135 + } 136 + if (loadMoreErr) { 137 + this.rootStore.log.error('Failed to fetch user lists', loadMoreErr) 138 + } 139 + } 140 + 141 + // helper functions 142 + // = 143 + 144 + _replaceAll(res: GetLists.Response | GetListMutes.Response) { 145 + this.lists = [] 146 + this._appendAll(res) 147 + } 148 + 149 + _appendAll(res: GetLists.Response | GetListMutes.Response) { 150 + this.loadMoreCursor = res.data.cursor 151 + this.hasMore = !!this.loadMoreCursor 152 + this.lists = this.lists.concat( 153 + res.data.lists.map(list => ({...list, _reactKey: list.uri})), 154 + ) 155 + } 156 + } 157 + 158 + async function fetchAllUserLists( 159 + store: RootStoreModel, 160 + did: string, 161 + ): Promise<GetLists.Response> { 162 + let acc: GetLists.Response = { 163 + success: true, 164 + headers: {}, 165 + data: { 166 + subject: undefined, 167 + lists: [], 168 + }, 169 + } 170 + 171 + let cursor 172 + for (let i = 0; i < 100; i++) { 173 + const res = await store.agent.app.bsky.graph.getLists({ 174 + actor: did, 175 + cursor, 176 + limit: 50, 177 + }) 178 + cursor = res.data.cursor 179 + acc.data.lists = acc.data.lists.concat(res.data.lists) 180 + if (!cursor) { 181 + break 182 + } 183 + } 184 + 185 + return acc 186 + } 187 + 188 + async function fetchAllMyMuteLists( 189 + store: RootStoreModel, 190 + ): Promise<GetListMutes.Response> { 191 + let acc: GetListMutes.Response = { 192 + success: true, 193 + headers: {}, 194 + data: { 195 + subject: undefined, 196 + lists: [], 197 + }, 198 + } 199 + 200 + let cursor 201 + for (let i = 0; i < 100; i++) { 202 + const res = await store.agent.app.bsky.graph.getListMutes({ 203 + cursor, 204 + limit: 50, 205 + }) 206 + cursor = res.data.cursor 207 + acc.data.lists = acc.data.lists.concat(res.data.lists) 208 + if (!cursor) { 209 + break 210 + } 211 + } 212 + 213 + return acc 214 + }
+25 -2
src/state/models/ui/profile.ts
··· 2 2 import {RootStoreModel} from '../root-store' 3 3 import {ProfileModel} from '../content/profile' 4 4 import {PostsFeedModel} from '../feeds/posts' 5 + import {ListsListModel} from '../lists/lists-list' 5 6 6 7 export enum Sections { 7 8 Posts = 'Posts', 8 9 PostsWithReplies = 'Posts & replies', 10 + Lists = 'Lists', 9 11 } 10 12 11 - const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies] 13 + const USER_SELECTOR_ITEMS = [ 14 + Sections.Posts, 15 + Sections.PostsWithReplies, 16 + Sections.Lists, 17 + ] 12 18 13 19 export interface ProfileUiParams { 14 20 user: string ··· 22 28 // data 23 29 profile: ProfileModel 24 30 feed: PostsFeedModel 31 + lists: ListsListModel 25 32 26 33 // ui state 27 34 selectedViewIndex = 0 ··· 43 50 actor: params.user, 44 51 limit: 10, 45 52 }) 53 + this.lists = new ListsListModel(rootStore, params.user) 46 54 } 47 55 48 - get currentView(): PostsFeedModel { 56 + get currentView(): PostsFeedModel | ListsListModel { 49 57 if ( 50 58 this.selectedView === Sections.Posts || 51 59 this.selectedView === Sections.PostsWithReplies 52 60 ) { 53 61 return this.feed 62 + } else if (this.selectedView === Sections.Lists) { 63 + return this.lists 54 64 } 55 65 throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) 56 66 } ··· 100 110 } else if (this.feed.isEmpty) { 101 111 arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) 102 112 } 113 + } else if (this.selectedView === Sections.Lists) { 114 + if (this.lists.hasContent) { 115 + arr = this.lists.lists 116 + } else if (this.lists.isEmpty) { 117 + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) 118 + } 103 119 } else { 104 120 arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) 105 121 } ··· 113 129 this.selectedView === Sections.PostsWithReplies 114 130 ) { 115 131 return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading 132 + } else if (this.selectedView === Sections.Lists) { 133 + return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading 116 134 } 117 135 return false 118 136 } ··· 133 151 .setup() 134 152 .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), 135 153 ]) 154 + // HACK: need to use the DID as a param, not the username -prf 155 + this.lists.source = this.profile.did 156 + this.lists 157 + .loadMore() 158 + .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) 136 159 } 137 160 138 161 async update() {
+17 -1
src/state/models/ui/shell.ts
··· 5 5 import {isObj, hasProp} from 'lib/type-guards' 6 6 import {Image as RNImage} from 'react-native-image-crop-picker' 7 7 import {ImageModel} from '../media/image' 8 + import {ListModel} from '../content/list' 8 9 import {GalleryModel} from '../media/gallery' 9 10 10 11 export interface ConfirmModal { ··· 36 37 export interface ReportAccountModal { 37 38 name: 'report-account' 38 39 did: string 40 + } 41 + 42 + export interface CreateOrEditMuteListModal { 43 + name: 'create-or-edit-mute-list' 44 + list?: ListModel 45 + onSave?: (uri: string) => void 46 + } 47 + 48 + export interface ListAddRemoveUserModal { 49 + name: 'list-add-remove-user' 50 + subject: string 51 + displayName: string 52 + onUpdate?: () => void 39 53 } 40 54 41 55 export interface EditImageModal { ··· 102 116 | ContentFilteringSettingsModal 103 117 | ContentLanguagesSettingsModal 104 118 105 - // Reporting 119 + // Moderation 106 120 | ReportAccountModal 107 121 | ReportPostModal 122 + | CreateMuteListModal 123 + | ListAddRemoveUserModal 108 124 109 125 // Posts 110 126 | AltTextImageModal
+155
src/view/com/lists/ListCard.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api' 4 + import {Link} from '../util/Link' 5 + import {Text} from '../util/text/Text' 6 + import {RichText as RichTextCom} from '../util/text/RichText' 7 + import {UserAvatar} from '../util/UserAvatar' 8 + import {s} from 'lib/styles' 9 + import {usePalette} from 'lib/hooks/usePalette' 10 + import {useStores} from 'state/index' 11 + import {sanitizeDisplayName} from 'lib/strings/display-names' 12 + 13 + export const ListCard = ({ 14 + testID, 15 + list, 16 + noBg, 17 + noBorder, 18 + renderButton, 19 + }: { 20 + testID?: string 21 + list: AppBskyGraphDefs.ListView 22 + noBg?: boolean 23 + noBorder?: boolean 24 + renderButton?: () => JSX.Element 25 + }) => { 26 + const pal = usePalette('default') 27 + const store = useStores() 28 + 29 + const rkey = React.useMemo(() => { 30 + try { 31 + const urip = new AtUri(list.uri) 32 + return urip.rkey 33 + } catch { 34 + return '' 35 + } 36 + }, [list]) 37 + 38 + const descriptionRichText = React.useMemo(() => { 39 + if (list.description) { 40 + return new RichText({ 41 + text: list.description, 42 + facets: list.descriptionFacets, 43 + }) 44 + } 45 + return undefined 46 + }, [list]) 47 + 48 + return ( 49 + <Link 50 + testID={testID} 51 + style={[ 52 + styles.outer, 53 + pal.border, 54 + noBorder && styles.outerNoBorder, 55 + !noBg && pal.view, 56 + ]} 57 + href={`/profile/${list.creator.did}/lists/${rkey}`} 58 + title={list.name} 59 + asAnchor 60 + anchorNoUnderline> 61 + <View style={styles.layout}> 62 + <View style={styles.layoutAvi}> 63 + <UserAvatar size={40} avatar={list.avatar} /> 64 + </View> 65 + <View style={styles.layoutContent}> 66 + <Text 67 + type="lg" 68 + style={[s.bold, pal.text]} 69 + numberOfLines={1} 70 + lineHeight={1.2}> 71 + {sanitizeDisplayName(list.name)} 72 + </Text> 73 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 74 + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} 75 + {list.creator.did === store.me.did 76 + ? 'you' 77 + : `@${list.creator.handle}`} 78 + </Text> 79 + {!!list.viewer?.muted && ( 80 + <View style={s.flexRow}> 81 + <View style={[s.mt5, pal.btn, styles.pill]}> 82 + <Text type="xs" style={pal.text}> 83 + Subscribed 84 + </Text> 85 + </View> 86 + </View> 87 + )} 88 + </View> 89 + {renderButton ? ( 90 + <View style={styles.layoutButton}>{renderButton()}</View> 91 + ) : undefined} 92 + </View> 93 + {descriptionRichText ? ( 94 + <View style={styles.details}> 95 + <RichTextCom 96 + style={pal.text} 97 + numberOfLines={20} 98 + richText={descriptionRichText} 99 + /> 100 + </View> 101 + ) : undefined} 102 + </Link> 103 + ) 104 + } 105 + 106 + const styles = StyleSheet.create({ 107 + outer: { 108 + borderTopWidth: 1, 109 + paddingHorizontal: 6, 110 + }, 111 + outerNoBorder: { 112 + borderTopWidth: 0, 113 + }, 114 + layout: { 115 + flexDirection: 'row', 116 + alignItems: 'center', 117 + }, 118 + layoutAvi: { 119 + width: 54, 120 + paddingLeft: 4, 121 + paddingTop: 8, 122 + paddingBottom: 10, 123 + }, 124 + avi: { 125 + width: 40, 126 + height: 40, 127 + borderRadius: 20, 128 + resizeMode: 'cover', 129 + }, 130 + layoutContent: { 131 + flex: 1, 132 + paddingRight: 10, 133 + paddingTop: 10, 134 + paddingBottom: 10, 135 + }, 136 + layoutButton: { 137 + paddingRight: 10, 138 + }, 139 + details: { 140 + paddingLeft: 54, 141 + paddingRight: 10, 142 + paddingBottom: 10, 143 + }, 144 + pill: { 145 + borderRadius: 4, 146 + paddingHorizontal: 6, 147 + paddingVertical: 2, 148 + }, 149 + btn: { 150 + paddingVertical: 7, 151 + borderRadius: 50, 152 + marginLeft: 6, 153 + paddingHorizontal: 14, 154 + }, 155 + })
+387
src/view/com/lists/ListItems.tsx
··· 1 + import React, {MutableRefObject} from 'react' 2 + import { 3 + ActivityIndicator, 4 + RefreshControl, 5 + StyleProp, 6 + StyleSheet, 7 + View, 8 + ViewStyle, 9 + } from 'react-native' 10 + import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' 11 + import {observer} from 'mobx-react-lite' 12 + import {FlatList} from '../util/Views' 13 + import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 14 + import {ErrorMessage} from '../util/error/ErrorMessage' 15 + import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 16 + import {ProfileCard} from '../profile/ProfileCard' 17 + import {Button} from '../util/forms/Button' 18 + import {Text} from '../util/text/Text' 19 + import {RichText as RichTextCom} from '../util/text/RichText' 20 + import {UserAvatar} from '../util/UserAvatar' 21 + import {TextLink} from '../util/Link' 22 + import {ListModel} from 'state/models/content/list' 23 + import {useAnalytics} from 'lib/analytics' 24 + import {usePalette} from 'lib/hooks/usePalette' 25 + import {useStores} from 'state/index' 26 + import {s} from 'lib/styles' 27 + import {isDesktopWeb} from 'platform/detection' 28 + 29 + const LOADING_ITEM = {_reactKey: '__loading__'} 30 + const HEADER_ITEM = {_reactKey: '__header__'} 31 + const EMPTY_ITEM = {_reactKey: '__empty__'} 32 + const ERROR_ITEM = {_reactKey: '__error__'} 33 + const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 34 + 35 + export const ListItems = observer( 36 + ({ 37 + list, 38 + style, 39 + scrollElRef, 40 + onPressTryAgain, 41 + onToggleSubscribed, 42 + onPressEditList, 43 + onPressDeleteList, 44 + renderEmptyState, 45 + testID, 46 + headerOffset = 0, 47 + }: { 48 + list: ListModel 49 + style?: StyleProp<ViewStyle> 50 + scrollElRef?: MutableRefObject<FlatList<any> | null> 51 + onPressTryAgain?: () => void 52 + onToggleSubscribed?: () => void 53 + onPressEditList?: () => void 54 + onPressDeleteList?: () => void 55 + renderEmptyState?: () => JSX.Element 56 + testID?: string 57 + headerOffset?: number 58 + }) => { 59 + const pal = usePalette('default') 60 + const store = useStores() 61 + const {track} = useAnalytics() 62 + const [isRefreshing, setIsRefreshing] = React.useState(false) 63 + 64 + const data = React.useMemo(() => { 65 + let items: any[] = [HEADER_ITEM] 66 + if (list.hasLoaded) { 67 + if (list.hasError) { 68 + items = items.concat([ERROR_ITEM]) 69 + } 70 + if (list.isEmpty) { 71 + items = items.concat([EMPTY_ITEM]) 72 + } else { 73 + items = items.concat(list.items) 74 + } 75 + if (list.loadMoreError) { 76 + items = items.concat([LOAD_MORE_ERROR_ITEM]) 77 + } 78 + } else if (list.isLoading) { 79 + items = items.concat([LOADING_ITEM]) 80 + } 81 + return items 82 + }, [ 83 + list.hasError, 84 + list.hasLoaded, 85 + list.isLoading, 86 + list.isEmpty, 87 + list.items, 88 + list.loadMoreError, 89 + ]) 90 + 91 + // events 92 + // = 93 + 94 + const onRefresh = React.useCallback(async () => { 95 + track('Lists:onRefresh') 96 + setIsRefreshing(true) 97 + try { 98 + await list.refresh() 99 + } catch (err) { 100 + list.rootStore.log.error('Failed to refresh lists', err) 101 + } 102 + setIsRefreshing(false) 103 + }, [list, track, setIsRefreshing]) 104 + 105 + const onEndReached = React.useCallback(async () => { 106 + track('Lists:onEndReached') 107 + try { 108 + await list.loadMore() 109 + } catch (err) { 110 + list.rootStore.log.error('Failed to load more lists', err) 111 + } 112 + }, [list, track]) 113 + 114 + const onPressRetryLoadMore = React.useCallback(() => { 115 + list.retryLoadMore() 116 + }, [list]) 117 + 118 + const onPressEditMembership = React.useCallback( 119 + (profile: AppBskyActorDefs.ProfileViewBasic) => { 120 + store.shell.openModal({ 121 + name: 'list-add-remove-user', 122 + subject: profile.did, 123 + displayName: profile.displayName || profile.handle, 124 + onUpdate() { 125 + list.refresh() 126 + }, 127 + }) 128 + }, 129 + [store, list], 130 + ) 131 + 132 + // rendering 133 + // = 134 + 135 + const renderMemberButton = React.useCallback( 136 + (profile: AppBskyActorDefs.ProfileViewBasic) => { 137 + if (!list.isOwner) { 138 + return null 139 + } 140 + return ( 141 + <Button 142 + type="default" 143 + label="Edit" 144 + onPress={() => onPressEditMembership(profile)} 145 + /> 146 + ) 147 + }, 148 + [list, onPressEditMembership], 149 + ) 150 + 151 + const renderItem = React.useCallback( 152 + ({item}: {item: any}) => { 153 + if (item === EMPTY_ITEM) { 154 + if (renderEmptyState) { 155 + return renderEmptyState() 156 + } 157 + return <View /> 158 + } else if (item === HEADER_ITEM) { 159 + return list.list ? ( 160 + <ListHeader 161 + list={list.list} 162 + isOwner={list.isOwner} 163 + onToggleSubscribed={onToggleSubscribed} 164 + onPressEditList={onPressEditList} 165 + onPressDeleteList={onPressDeleteList} 166 + /> 167 + ) : null 168 + } else if (item === ERROR_ITEM) { 169 + return ( 170 + <ErrorMessage 171 + message={list.error} 172 + onPressTryAgain={onPressTryAgain} 173 + /> 174 + ) 175 + } else if (item === LOAD_MORE_ERROR_ITEM) { 176 + return ( 177 + <LoadMoreRetryBtn 178 + label="There was an issue fetching the list. Tap here to try again." 179 + onPress={onPressRetryLoadMore} 180 + /> 181 + ) 182 + } else if (item === LOADING_ITEM) { 183 + return <ProfileCardFeedLoadingPlaceholder /> 184 + } 185 + return ( 186 + <ProfileCard 187 + testID={`user-${ 188 + (item as AppBskyGraphDefs.ListItemView).subject.handle 189 + }`} 190 + profile={(item as AppBskyGraphDefs.ListItemView).subject} 191 + renderButton={renderMemberButton} 192 + /> 193 + ) 194 + }, 195 + [ 196 + list, 197 + onPressTryAgain, 198 + onPressRetryLoadMore, 199 + renderMemberButton, 200 + onPressEditList, 201 + onPressDeleteList, 202 + onToggleSubscribed, 203 + renderEmptyState, 204 + ], 205 + ) 206 + 207 + const Footer = React.useCallback( 208 + () => 209 + list.isLoading ? ( 210 + <View style={styles.feedFooter}> 211 + <ActivityIndicator /> 212 + </View> 213 + ) : ( 214 + <View /> 215 + ), 216 + [list], 217 + ) 218 + 219 + return ( 220 + <View testID={testID} style={style}> 221 + {data.length > 0 && ( 222 + <FlatList 223 + testID={testID ? `${testID}-flatlist` : undefined} 224 + ref={scrollElRef} 225 + data={data} 226 + keyExtractor={item => item._reactKey} 227 + renderItem={renderItem} 228 + ListFooterComponent={Footer} 229 + refreshControl={ 230 + <RefreshControl 231 + refreshing={isRefreshing} 232 + onRefresh={onRefresh} 233 + tintColor={pal.colors.text} 234 + titleColor={pal.colors.text} 235 + progressViewOffset={headerOffset} 236 + /> 237 + } 238 + contentContainerStyle={s.contentContainer} 239 + style={{paddingTop: headerOffset}} 240 + onEndReached={onEndReached} 241 + onEndReachedThreshold={0.6} 242 + removeClippedSubviews={true} 243 + contentOffset={{x: 0, y: headerOffset * -1}} 244 + // @ts-ignore our .web version only -prf 245 + desktopFixedHeight 246 + /> 247 + )} 248 + </View> 249 + ) 250 + }, 251 + ) 252 + 253 + const ListHeader = observer( 254 + ({ 255 + list, 256 + isOwner, 257 + onToggleSubscribed, 258 + onPressEditList, 259 + onPressDeleteList, 260 + }: { 261 + list: AppBskyGraphDefs.ListView 262 + isOwner: boolean 263 + onToggleSubscribed?: () => void 264 + onPressEditList?: () => void 265 + onPressDeleteList?: () => void 266 + }) => { 267 + const pal = usePalette('default') 268 + const store = useStores() 269 + const descriptionRT = React.useMemo( 270 + () => 271 + list?.description && 272 + new RichText({text: list.description, facets: list.descriptionFacets}), 273 + [list], 274 + ) 275 + return ( 276 + <> 277 + <View style={[styles.header, pal.border]}> 278 + <View style={s.flex1}> 279 + <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> 280 + {list.name} 281 + </Text> 282 + {list && ( 283 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 284 + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} 285 + by{' '} 286 + {list.creator.did === store.me.did ? ( 287 + 'you' 288 + ) : ( 289 + <TextLink 290 + text={`@${list.creator.handle}`} 291 + href={`/profile/${list.creator.did}`} 292 + /> 293 + )} 294 + </Text> 295 + )} 296 + {descriptionRT && ( 297 + <RichTextCom 298 + testID="listDescription" 299 + style={[pal.text, styles.headerDescription]} 300 + richText={descriptionRT} 301 + /> 302 + )} 303 + {isDesktopWeb && ( 304 + <View style={styles.headerBtns}> 305 + {list.viewer?.muted ? ( 306 + <Button 307 + type="inverted" 308 + label="Unsubscribe" 309 + accessibilityLabel="Unsubscribe from this list" 310 + accessibilityHint="Stops muting the users included in this list" 311 + onPress={onToggleSubscribed} 312 + /> 313 + ) : ( 314 + <Button 315 + type="primary" 316 + label="Subscribe & Mute" 317 + accessibilityLabel="Subscribe to this list" 318 + accessibilityHint="Mutes the users included in this list" 319 + onPress={onToggleSubscribed} 320 + /> 321 + )} 322 + {isOwner && ( 323 + <Button 324 + type="default" 325 + label="Edit List" 326 + accessibilityLabel="Edit list" 327 + accessibilityHint="Opens a modal to edit the mutelist" 328 + onPress={onPressEditList} 329 + /> 330 + )} 331 + {isOwner && ( 332 + <Button 333 + type="default" 334 + label="Delete List" 335 + accessibilityLabel="Delete list" 336 + accessibilityHint="Deletes the mutelist" 337 + onPress={onPressDeleteList} 338 + /> 339 + )} 340 + </View> 341 + )} 342 + </View> 343 + <View> 344 + <UserAvatar avatar={list.avatar} size={64} /> 345 + </View> 346 + </View> 347 + <View style={[styles.fakeSelector, pal.border]}> 348 + <View 349 + style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> 350 + <Text type="md-medium" style={[pal.text]}> 351 + Muted users 352 + </Text> 353 + </View> 354 + </View> 355 + </> 356 + ) 357 + }, 358 + ) 359 + 360 + const styles = StyleSheet.create({ 361 + header: { 362 + flexDirection: 'row', 363 + gap: 12, 364 + paddingHorizontal: 16, 365 + paddingTop: 12, 366 + paddingBottom: 16, 367 + borderTopWidth: 1, 368 + }, 369 + headerDescription: { 370 + marginTop: 8, 371 + }, 372 + headerBtns: { 373 + flexDirection: 'row', 374 + gap: 8, 375 + marginTop: 12, 376 + }, 377 + fakeSelector: { 378 + flexDirection: 'row', 379 + paddingHorizontal: isDesktopWeb ? 16 : 6, 380 + }, 381 + fakeSelectorItem: { 382 + paddingHorizontal: 12, 383 + paddingBottom: 8, 384 + borderBottomWidth: 3, 385 + }, 386 + feedFooter: {paddingTop: 20}, 387 + })
+240
src/view/com/lists/ListsList.tsx
··· 1 + import React, {MutableRefObject} from 'react' 2 + import { 3 + ActivityIndicator, 4 + RefreshControl, 5 + StyleProp, 6 + StyleSheet, 7 + View, 8 + ViewStyle, 9 + } from 'react-native' 10 + import {observer} from 'mobx-react-lite' 11 + import { 12 + FontAwesomeIcon, 13 + FontAwesomeIconStyle, 14 + } from '@fortawesome/react-native-fontawesome' 15 + import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' 16 + import {FlatList} from '../util/Views' 17 + import {ListCard} from './ListCard' 18 + import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 19 + import {ErrorMessage} from '../util/error/ErrorMessage' 20 + import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 21 + import {Button} from '../util/forms/Button' 22 + import {Text} from '../util/text/Text' 23 + import {ListsListModel} from 'state/models/lists/lists-list' 24 + import {useAnalytics} from 'lib/analytics' 25 + import {usePalette} from 'lib/hooks/usePalette' 26 + import {s} from 'lib/styles' 27 + 28 + const LOADING_ITEM = {_reactKey: '__loading__'} 29 + const CREATENEW_ITEM = {_reactKey: '__loading__'} 30 + const EMPTY_ITEM = {_reactKey: '__empty__'} 31 + const ERROR_ITEM = {_reactKey: '__error__'} 32 + const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 33 + 34 + export const ListsList = observer( 35 + ({ 36 + listsList, 37 + showAddBtns, 38 + style, 39 + scrollElRef, 40 + onPressTryAgain, 41 + onPressCreateNew, 42 + renderItem, 43 + renderEmptyState, 44 + testID, 45 + headerOffset = 0, 46 + }: { 47 + listsList: ListsListModel 48 + showAddBtns?: boolean 49 + style?: StyleProp<ViewStyle> 50 + scrollElRef?: MutableRefObject<FlatList<any> | null> 51 + onPressCreateNew: () => void 52 + onPressTryAgain?: () => void 53 + renderItem?: (list: GraphDefs.ListView) => JSX.Element 54 + renderEmptyState?: () => JSX.Element 55 + testID?: string 56 + headerOffset?: number 57 + }) => { 58 + const pal = usePalette('default') 59 + const {track} = useAnalytics() 60 + const [isRefreshing, setIsRefreshing] = React.useState(false) 61 + 62 + const data = React.useMemo(() => { 63 + let items: any[] = [] 64 + if (listsList.hasLoaded) { 65 + if (listsList.hasError) { 66 + items = items.concat([ERROR_ITEM]) 67 + } 68 + if (listsList.isEmpty) { 69 + items = items.concat([EMPTY_ITEM]) 70 + } else { 71 + if (showAddBtns) { 72 + items = items.concat([CREATENEW_ITEM]) 73 + } 74 + items = items.concat(listsList.lists) 75 + } 76 + if (listsList.loadMoreError) { 77 + items = items.concat([LOAD_MORE_ERROR_ITEM]) 78 + } 79 + } else if (listsList.isLoading) { 80 + items = items.concat([LOADING_ITEM]) 81 + } 82 + return items 83 + }, [ 84 + listsList.hasError, 85 + listsList.hasLoaded, 86 + listsList.isLoading, 87 + listsList.isEmpty, 88 + listsList.lists, 89 + listsList.loadMoreError, 90 + showAddBtns, 91 + ]) 92 + 93 + // events 94 + // = 95 + 96 + const onRefresh = React.useCallback(async () => { 97 + track('Lists:onRefresh') 98 + setIsRefreshing(true) 99 + try { 100 + await listsList.refresh() 101 + } catch (err) { 102 + listsList.rootStore.log.error('Failed to refresh lists', err) 103 + } 104 + setIsRefreshing(false) 105 + }, [listsList, track, setIsRefreshing]) 106 + 107 + const onEndReached = React.useCallback(async () => { 108 + track('Lists:onEndReached') 109 + try { 110 + await listsList.loadMore() 111 + } catch (err) { 112 + listsList.rootStore.log.error('Failed to load more lists', err) 113 + } 114 + }, [listsList, track]) 115 + 116 + const onPressRetryLoadMore = React.useCallback(() => { 117 + listsList.retryLoadMore() 118 + }, [listsList]) 119 + 120 + // rendering 121 + // = 122 + 123 + const renderItemInner = React.useCallback( 124 + ({item}: {item: any}) => { 125 + if (item === EMPTY_ITEM) { 126 + if (renderEmptyState) { 127 + return renderEmptyState() 128 + } 129 + return <View /> 130 + } else if (item === CREATENEW_ITEM) { 131 + return <CreateNewItem onPress={onPressCreateNew} /> 132 + } else if (item === ERROR_ITEM) { 133 + return ( 134 + <ErrorMessage 135 + message={listsList.error} 136 + onPressTryAgain={onPressTryAgain} 137 + /> 138 + ) 139 + } else if (item === LOAD_MORE_ERROR_ITEM) { 140 + return ( 141 + <LoadMoreRetryBtn 142 + label="There was an issue fetching your lists. Tap here to try again." 143 + onPress={onPressRetryLoadMore} 144 + /> 145 + ) 146 + } else if (item === LOADING_ITEM) { 147 + return <ProfileCardFeedLoadingPlaceholder /> 148 + } 149 + return renderItem ? ( 150 + renderItem(item) 151 + ) : ( 152 + <ListCard list={item} testID={`list-${item.name}`} /> 153 + ) 154 + }, 155 + [ 156 + listsList, 157 + onPressTryAgain, 158 + onPressRetryLoadMore, 159 + onPressCreateNew, 160 + renderItem, 161 + renderEmptyState, 162 + ], 163 + ) 164 + 165 + const Footer = React.useCallback( 166 + () => 167 + listsList.isLoading ? ( 168 + <View style={styles.feedFooter}> 169 + <ActivityIndicator /> 170 + </View> 171 + ) : ( 172 + <View /> 173 + ), 174 + [listsList], 175 + ) 176 + 177 + return ( 178 + <View testID={testID} style={style}> 179 + {data.length > 0 && ( 180 + <FlatList 181 + testID={testID ? `${testID}-flatlist` : undefined} 182 + ref={scrollElRef} 183 + data={data} 184 + keyExtractor={item => item._reactKey} 185 + renderItem={renderItemInner} 186 + ListFooterComponent={Footer} 187 + refreshControl={ 188 + <RefreshControl 189 + refreshing={isRefreshing} 190 + onRefresh={onRefresh} 191 + tintColor={pal.colors.text} 192 + titleColor={pal.colors.text} 193 + progressViewOffset={headerOffset} 194 + /> 195 + } 196 + contentContainerStyle={s.contentContainer} 197 + style={{paddingTop: headerOffset}} 198 + onEndReached={onEndReached} 199 + onEndReachedThreshold={0.6} 200 + removeClippedSubviews={true} 201 + contentOffset={{x: 0, y: headerOffset * -1}} 202 + // @ts-ignore our .web version only -prf 203 + desktopFixedHeight 204 + /> 205 + )} 206 + </View> 207 + ) 208 + }, 209 + ) 210 + 211 + function CreateNewItem({onPress}: {onPress: () => void}) { 212 + const pal = usePalette('default') 213 + 214 + return ( 215 + <View style={[styles.createNewContainer]}> 216 + <Button type="default" onPress={onPress} style={styles.createNewButton}> 217 + <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} /> 218 + <Text type="button" style={pal.text}> 219 + New Mute List 220 + </Text> 221 + </Button> 222 + </View> 223 + ) 224 + } 225 + 226 + const styles = StyleSheet.create({ 227 + createNewContainer: { 228 + flexDirection: 'row', 229 + alignItems: 'center', 230 + paddingHorizontal: 18, 231 + paddingTop: 18, 232 + paddingBottom: 16, 233 + }, 234 + createNewButton: { 235 + flexDirection: 'row', 236 + alignItems: 'center', 237 + gap: 8, 238 + }, 239 + feedFooter: {paddingTop: 20}, 240 + })
+3 -3
src/view/com/modals/ContentFilteringSettings.tsx
··· 21 21 }, [store]) 22 22 23 23 return ( 24 - <View testID="contentModerationModal" style={[pal.view, styles.container]}> 25 - <Text style={[pal.text, styles.title]}>Content Moderation</Text> 24 + <View testID="contentFilteringModal" style={[pal.view, styles.container]}> 25 + <Text style={[pal.text, styles.title]}>Content Filtering</Text> 26 26 <ScrollView style={styles.scrollContainer}> 27 27 <ContentLabelPref 28 28 group="nsfw" ··· 50 50 testID="sendReportBtn" 51 51 onPress={onPressDone} 52 52 accessibilityRole="button" 53 - accessibilityLabel="Confirm content moderation settings" 53 + accessibilityLabel="Confirm content filtering settings" 54 54 accessibilityHint=""> 55 55 <LinearGradient 56 56 colors={[gradients.blueLight.start, gradients.blueLight.end]}
+273
src/view/com/modals/CreateOrEditMuteList.tsx
··· 1 + import React, {useState, useCallback} from 'react' 2 + import * as Toast from '../util/Toast' 3 + import { 4 + ActivityIndicator, 5 + KeyboardAvoidingView, 6 + ScrollView, 7 + StyleSheet, 8 + TextInput, 9 + TouchableOpacity, 10 + View, 11 + } from 'react-native' 12 + import LinearGradient from 'react-native-linear-gradient' 13 + import {Image as RNImage} from 'react-native-image-crop-picker' 14 + import {Text} from '../util/text/Text' 15 + import {ErrorMessage} from '../util/error/ErrorMessage' 16 + import {useStores} from 'state/index' 17 + import {ListModel} from 'state/models/content/list' 18 + import {s, colors, gradients} from 'lib/styles' 19 + import {enforceLen} from 'lib/strings/helpers' 20 + import {compressIfNeeded} from 'lib/media/manip' 21 + import {UserAvatar} from '../util/UserAvatar' 22 + import {usePalette} from 'lib/hooks/usePalette' 23 + import {useTheme} from 'lib/ThemeContext' 24 + import {useAnalytics} from 'lib/analytics' 25 + import {cleanError, isNetworkError} from 'lib/strings/errors' 26 + import {isDesktopWeb} from 'platform/detection' 27 + 28 + const MAX_NAME = 64 // todo 29 + const MAX_DESCRIPTION = 300 // todo 30 + 31 + export const snapPoints = ['fullscreen'] 32 + 33 + export function Component({ 34 + onSave, 35 + list, 36 + }: { 37 + onSave?: (uri: string) => void 38 + list?: ListModel 39 + }) { 40 + const store = useStores() 41 + const [error, setError] = useState<string>('') 42 + const pal = usePalette('default') 43 + const theme = useTheme() 44 + const {track} = useAnalytics() 45 + 46 + const [isProcessing, setProcessing] = useState<boolean>(false) 47 + const [name, setName] = useState<string>(list?.list.name || '') 48 + const [description, setDescription] = useState<string>( 49 + list?.list.description || '', 50 + ) 51 + const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar) 52 + const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() 53 + 54 + const onPressCancel = useCallback(() => { 55 + store.shell.closeModal() 56 + }, [store]) 57 + 58 + const onSelectNewAvatar = useCallback( 59 + async (img: RNImage | null) => { 60 + if (!img) { 61 + setNewAvatar(null) 62 + setAvatar(null) 63 + return 64 + } 65 + track('CreateMuteList:AvatarSelected') 66 + try { 67 + const finalImg = await compressIfNeeded(img, 1000000) 68 + setNewAvatar(finalImg) 69 + setAvatar(finalImg.path) 70 + } catch (e: any) { 71 + setError(cleanError(e)) 72 + } 73 + }, 74 + [track, setNewAvatar, setAvatar, setError], 75 + ) 76 + 77 + const onPressSave = useCallback(async () => { 78 + track('CreateMuteList:Save') 79 + const nameTrimmed = name.trim() 80 + if (!nameTrimmed) { 81 + setError('Name is required') 82 + return 83 + } 84 + setProcessing(true) 85 + if (error) { 86 + setError('') 87 + } 88 + try { 89 + if (list) { 90 + await list.updateMetadata({ 91 + name: nameTrimmed, 92 + description: description.trim(), 93 + avatar: newAvatar, 94 + }) 95 + Toast.show('Mute list updated') 96 + onSave?.(list.uri) 97 + } else { 98 + const res = await ListModel.createModList(store, { 99 + name, 100 + description, 101 + avatar: newAvatar, 102 + }) 103 + Toast.show('Mute list created') 104 + onSave?.(res.uri) 105 + } 106 + store.shell.closeModal() 107 + } catch (e: any) { 108 + if (isNetworkError(e)) { 109 + setError( 110 + 'Failed to create the mute list. Check your internet connection and try again.', 111 + ) 112 + } else { 113 + setError(cleanError(e)) 114 + } 115 + } 116 + setProcessing(false) 117 + }, [ 118 + track, 119 + setProcessing, 120 + setError, 121 + error, 122 + onSave, 123 + store, 124 + name, 125 + description, 126 + newAvatar, 127 + list, 128 + ]) 129 + 130 + return ( 131 + <KeyboardAvoidingView behavior="height"> 132 + <ScrollView 133 + style={[pal.view, styles.container]} 134 + testID="createOrEditMuteListModal"> 135 + <Text style={[styles.title, pal.text]}> 136 + {list ? 'Edit Mute List' : 'New Mute List'} 137 + </Text> 138 + {error !== '' && ( 139 + <View style={styles.errorContainer}> 140 + <ErrorMessage message={error} /> 141 + </View> 142 + )} 143 + <Text style={[styles.label, pal.text]}>List Avatar</Text> 144 + <View style={[styles.avi, {borderColor: pal.colors.background}]}> 145 + <UserAvatar 146 + size={80} 147 + avatar={avatar} 148 + onSelectNewAvatar={onSelectNewAvatar} 149 + /> 150 + </View> 151 + <View style={styles.form}> 152 + <View> 153 + <Text style={[styles.label, pal.text]}>List Name</Text> 154 + <TextInput 155 + testID="editNameInput" 156 + style={[styles.textInput, pal.border, pal.text]} 157 + placeholder="e.g. Spammers" 158 + placeholderTextColor={colors.gray4} 159 + value={name} 160 + onChangeText={v => setName(enforceLen(v, MAX_NAME))} 161 + accessible={true} 162 + accessibilityLabel="Name" 163 + accessibilityHint="Set the list's name" 164 + /> 165 + </View> 166 + <View style={s.pb10}> 167 + <Text style={[styles.label, pal.text]}>Description</Text> 168 + <TextInput 169 + testID="editDescriptionInput" 170 + style={[styles.textArea, pal.border, pal.text]} 171 + placeholder="e.g. Users that repeatedly reply with ads." 172 + placeholderTextColor={colors.gray4} 173 + keyboardAppearance={theme.colorScheme} 174 + multiline 175 + value={description} 176 + onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} 177 + accessible={true} 178 + accessibilityLabel="Description" 179 + accessibilityHint="Edit your list's description" 180 + /> 181 + </View> 182 + {isProcessing ? ( 183 + <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> 184 + <ActivityIndicator /> 185 + </View> 186 + ) : ( 187 + <TouchableOpacity 188 + testID="saveBtn" 189 + style={s.mt10} 190 + onPress={onPressSave} 191 + accessibilityRole="button" 192 + accessibilityLabel="Save" 193 + accessibilityHint="Creates the mute list"> 194 + <LinearGradient 195 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 196 + start={{x: 0, y: 0}} 197 + end={{x: 1, y: 1}} 198 + style={[styles.btn]}> 199 + <Text style={[s.white, s.bold]}>Save</Text> 200 + </LinearGradient> 201 + </TouchableOpacity> 202 + )} 203 + <TouchableOpacity 204 + testID="cancelBtn" 205 + style={s.mt5} 206 + onPress={onPressCancel} 207 + accessibilityRole="button" 208 + accessibilityLabel="Cancel creating the mute list" 209 + accessibilityHint="" 210 + onAccessibilityEscape={onPressCancel}> 211 + <View style={[styles.btn]}> 212 + <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> 213 + </View> 214 + </TouchableOpacity> 215 + </View> 216 + </ScrollView> 217 + </KeyboardAvoidingView> 218 + ) 219 + } 220 + 221 + const styles = StyleSheet.create({ 222 + container: { 223 + paddingHorizontal: isDesktopWeb ? 0 : 16, 224 + }, 225 + title: { 226 + textAlign: 'center', 227 + fontWeight: 'bold', 228 + fontSize: 24, 229 + marginBottom: 18, 230 + }, 231 + label: { 232 + fontWeight: 'bold', 233 + paddingHorizontal: 4, 234 + paddingBottom: 4, 235 + marginTop: 20, 236 + }, 237 + form: { 238 + paddingHorizontal: 6, 239 + }, 240 + textInput: { 241 + borderWidth: 1, 242 + borderRadius: 6, 243 + paddingHorizontal: 14, 244 + paddingVertical: 10, 245 + fontSize: 16, 246 + }, 247 + textArea: { 248 + borderWidth: 1, 249 + borderRadius: 6, 250 + paddingHorizontal: 12, 251 + paddingTop: 10, 252 + fontSize: 16, 253 + height: 100, 254 + textAlignVertical: 'top', 255 + }, 256 + btn: { 257 + flexDirection: 'row', 258 + alignItems: 'center', 259 + justifyContent: 'center', 260 + width: '100%', 261 + borderRadius: 32, 262 + padding: 10, 263 + marginBottom: 10, 264 + }, 265 + avi: { 266 + width: 84, 267 + height: 84, 268 + borderWidth: 2, 269 + borderRadius: 42, 270 + marginTop: 4, 271 + }, 272 + errorContainer: {marginTop: 20}, 273 + })
+255
src/view/com/modals/ListAddRemoveUser.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {observer} from 'mobx-react-lite' 3 + import {Pressable, StyleSheet, View} from 'react-native' 4 + import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' 5 + import { 6 + FontAwesomeIcon, 7 + FontAwesomeIconStyle, 8 + } from '@fortawesome/react-native-fontawesome' 9 + import {Text} from '../util/text/Text' 10 + import {UserAvatar} from '../util/UserAvatar' 11 + import {ListsList} from '../lists/ListsList' 12 + import {ListsListModel} from 'state/models/lists/lists-list' 13 + import {ListMembershipModel} from 'state/models/content/list-membership' 14 + import {EmptyStateWithButton} from '../util/EmptyStateWithButton' 15 + import {Button} from '../util/forms/Button' 16 + import * as Toast from '../util/Toast' 17 + import {useStores} from 'state/index' 18 + import {sanitizeDisplayName} from 'lib/strings/display-names' 19 + import {s} from 'lib/styles' 20 + import {usePalette} from 'lib/hooks/usePalette' 21 + import {isDesktopWeb, isAndroid} from 'platform/detection' 22 + 23 + export const snapPoints = ['fullscreen'] 24 + 25 + export const Component = observer( 26 + ({ 27 + subject, 28 + displayName, 29 + onUpdate, 30 + }: { 31 + subject: string 32 + displayName: string 33 + onUpdate?: () => void 34 + }) => { 35 + const store = useStores() 36 + const pal = usePalette('default') 37 + const palPrimary = usePalette('primary') 38 + const palInverted = usePalette('inverted') 39 + const [selected, setSelected] = React.useState([]) 40 + 41 + const listsList: ListsListModel = React.useMemo( 42 + () => new ListsListModel(store, store.me.did), 43 + [store], 44 + ) 45 + const memberships: ListMembershipModel = React.useMemo( 46 + () => new ListMembershipModel(store, subject), 47 + [store, subject], 48 + ) 49 + React.useEffect(() => { 50 + listsList.refresh() 51 + memberships.fetch().then( 52 + () => { 53 + setSelected(memberships.memberships.map(m => m.value.list)) 54 + }, 55 + err => { 56 + store.log.error('Failed to fetch memberships', {err}) 57 + }, 58 + ) 59 + }, [memberships, listsList, store, setSelected]) 60 + 61 + const onPressCancel = useCallback(() => { 62 + store.shell.closeModal() 63 + }, [store]) 64 + 65 + const onPressSave = useCallback(async () => { 66 + try { 67 + await memberships.updateTo(selected) 68 + } catch (err) { 69 + store.log.error('Failed to update memberships', {err}) 70 + return 71 + } 72 + Toast.show('Lists updated') 73 + onUpdate?.() 74 + store.shell.closeModal() 75 + }, [store, selected, memberships, onUpdate]) 76 + 77 + const onPressNewMuteList = useCallback(() => { 78 + store.shell.openModal({ 79 + name: 'create-or-edit-mute-list', 80 + onSave: (_uri: string) => { 81 + listsList.refresh() 82 + }, 83 + }) 84 + }, [store, listsList]) 85 + 86 + const onToggleSelected = useCallback( 87 + (uri: string) => { 88 + if (selected.includes(uri)) { 89 + setSelected(selected.filter(uri2 => uri2 !== uri)) 90 + } else { 91 + setSelected([...selected, uri]) 92 + } 93 + }, 94 + [selected, setSelected], 95 + ) 96 + 97 + const renderItem = useCallback( 98 + (list: GraphDefs.ListView) => { 99 + const isSelected = selected.includes(list.uri) 100 + return ( 101 + <Pressable 102 + testID={`toggleBtn-${list.name}`} 103 + style={[styles.listItem, pal.border]} 104 + accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ 105 + list.name 106 + }`} 107 + accessibilityHint="Toggle their inclusion in this list" 108 + onPress={() => onToggleSelected(list.uri)}> 109 + <View style={styles.listItemAvi}> 110 + <UserAvatar size={40} avatar={list.avatar} /> 111 + </View> 112 + <View style={styles.listItemContent}> 113 + <Text 114 + type="lg" 115 + style={[s.bold, pal.text]} 116 + numberOfLines={1} 117 + lineHeight={1.2}> 118 + {sanitizeDisplayName(list.name)} 119 + </Text> 120 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 121 + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '} 122 + by{' '} 123 + {list.creator.did === store.me.did 124 + ? 'you' 125 + : `@${list.creator.handle}`} 126 + </Text> 127 + </View> 128 + <View 129 + style={ 130 + isSelected 131 + ? [styles.checkbox, palPrimary.border, palPrimary.view] 132 + : [styles.checkbox, pal.borderDark] 133 + }> 134 + {isSelected && ( 135 + <FontAwesomeIcon 136 + icon="check" 137 + style={palInverted.text as FontAwesomeIconStyle} 138 + /> 139 + )} 140 + </View> 141 + </Pressable> 142 + ) 143 + }, 144 + [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], 145 + ) 146 + 147 + const renderEmptyState = React.useCallback(() => { 148 + return ( 149 + <EmptyStateWithButton 150 + icon="users-slash" 151 + message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." 152 + buttonLabel="New Mute List" 153 + onPress={onPressNewMuteList} 154 + /> 155 + ) 156 + }, [onPressNewMuteList]) 157 + 158 + return ( 159 + <View testID="listAddRemoveUserModal" style={s.hContentRegion}> 160 + <Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text> 161 + <ListsList 162 + listsList={listsList} 163 + showAddBtns 164 + onPressCreateNew={onPressNewMuteList} 165 + renderItem={renderItem} 166 + renderEmptyState={renderEmptyState} 167 + style={[styles.list, pal.border]} 168 + /> 169 + <View style={[styles.btns, pal.border]}> 170 + <Button 171 + testID="cancelBtn" 172 + type="default" 173 + onPress={onPressCancel} 174 + style={styles.footerBtn} 175 + accessibilityRole="button" 176 + accessibilityLabel="Cancel this modal" 177 + accessibilityHint="" 178 + onAccessibilityEscape={onPressCancel} 179 + label="Cancel" 180 + /> 181 + <Button 182 + testID="saveBtn" 183 + type="primary" 184 + onPress={onPressSave} 185 + style={styles.footerBtn} 186 + accessibilityRole="button" 187 + accessibilityLabel="Save these changes" 188 + accessibilityHint="" 189 + onAccessibilityEscape={onPressSave} 190 + label="Save Changes" 191 + /> 192 + </View> 193 + </View> 194 + ) 195 + }, 196 + ) 197 + 198 + const styles = StyleSheet.create({ 199 + container: { 200 + paddingHorizontal: isDesktopWeb ? 0 : 16, 201 + }, 202 + title: { 203 + textAlign: 'center', 204 + fontWeight: 'bold', 205 + fontSize: 24, 206 + marginBottom: 10, 207 + }, 208 + list: { 209 + flex: 1, 210 + borderTopWidth: 1, 211 + }, 212 + btns: { 213 + flexDirection: 'row', 214 + alignItems: 'center', 215 + justifyContent: 'center', 216 + gap: 10, 217 + paddingTop: 10, 218 + paddingBottom: isAndroid ? 10 : 0, 219 + borderTopWidth: 1, 220 + }, 221 + footerBtn: { 222 + paddingHorizontal: 24, 223 + paddingVertical: 12, 224 + }, 225 + 226 + listItem: { 227 + flexDirection: 'row', 228 + alignItems: 'center', 229 + borderTopWidth: 1, 230 + paddingHorizontal: 14, 231 + paddingVertical: 10, 232 + }, 233 + listItemAvi: { 234 + width: 54, 235 + paddingLeft: 4, 236 + paddingTop: 8, 237 + paddingBottom: 10, 238 + }, 239 + listItemContent: { 240 + flex: 1, 241 + paddingRight: 10, 242 + paddingTop: 10, 243 + paddingBottom: 10, 244 + }, 245 + checkbox: { 246 + flexDirection: 'row', 247 + alignItems: 'center', 248 + justifyContent: 'center', 249 + borderWidth: 1, 250 + width: 24, 251 + height: 24, 252 + borderRadius: 6, 253 + marginRight: 8, 254 + }, 255 + })
+8
src/view/com/modals/Modal.tsx
··· 12 12 import * as ServerInputModal from './ServerInput' 13 13 import * as ReportPostModal from './ReportPost' 14 14 import * as RepostModal from './Repost' 15 + import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' 16 + import * as ListAddRemoveUserModal from './ListAddRemoveUser' 15 17 import * as AltImageModal from './AltImage' 16 18 import * as ReportAccountModal from './ReportAccount' 17 19 import * as DeleteAccountModal from './DeleteAccount' ··· 66 68 } else if (activeModal?.name === 'report-account') { 67 69 snapPoints = ReportAccountModal.snapPoints 68 70 element = <ReportAccountModal.Component {...activeModal} /> 71 + } else if (activeModal?.name === 'create-or-edit-mute-list') { 72 + snapPoints = CreateOrEditMuteListModal.snapPoints 73 + element = <CreateOrEditMuteListModal.Component {...activeModal} /> 74 + } else if (activeModal?.name === 'list-add-remove-user') { 75 + snapPoints = ListAddRemoveUserModal.snapPoints 76 + element = <ListAddRemoveUserModal.Component {...activeModal} /> 69 77 } else if (activeModal?.name === 'delete-account') { 70 78 snapPoints = DeleteAccountModal.snapPoints 71 79 element = <DeleteAccountModal.Component />
+6
src/view/com/modals/Modal.web.tsx
··· 11 11 import * as ServerInputModal from './ServerInput' 12 12 import * as ReportPostModal from './ReportPost' 13 13 import * as ReportAccountModal from './ReportAccount' 14 + import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' 15 + import * as ListAddRemoveUserModal from './ListAddRemoveUser' 14 16 import * as DeleteAccountModal from './DeleteAccount' 15 17 import * as RepostModal from './Repost' 16 18 import * as CropImageModal from './crop-image/CropImage.web' ··· 69 71 element = <ReportPostModal.Component {...modal} /> 70 72 } else if (modal.name === 'report-account') { 71 73 element = <ReportAccountModal.Component {...modal} /> 74 + } else if (modal.name === 'create-or-edit-mute-list') { 75 + element = <CreateOrEditMuteListModal.Component {...modal} /> 76 + } else if (modal.name === 'list-add-remove-user') { 77 + element = <ListAddRemoveUserModal.Component {...modal} /> 72 78 } else if (modal.name === 'crop-image') { 73 79 element = <CropImageModal.Component {...modal} /> 74 80 } else if (modal.name === 'delete-account') {
+11 -8
src/view/com/pager/TabBar.tsx
··· 65 65 ], 66 66 } 67 67 68 - const onLayout = () => { 68 + const onLayout = React.useCallback(() => { 69 69 const promises = [] 70 70 for (let i = 0; i < items.length; i++) { 71 71 promises.push( ··· 86 86 Promise.all(promises).then((layouts: Layout[]) => { 87 87 setItemLayouts(layouts) 88 88 }) 89 - } 89 + }, [containerRef, itemRefs, setItemLayouts, items.length]) 90 90 91 - const onPressItem = (index: number) => { 92 - onSelect?.(index) 93 - if (index === selectedPage) { 94 - onPressSelected?.() 95 - } 96 - } 91 + const onPressItem = React.useCallback( 92 + (index: number) => { 93 + onSelect?.(index) 94 + if (index === selectedPage) { 95 + onPressSelected?.() 96 + } 97 + }, 98 + [onSelect, onPressSelected, selectedPage], 99 + ) 97 100 98 101 return ( 99 102 <View
+15 -3
src/view/com/posts/FeedItem.tsx
··· 8 8 FontAwesomeIconStyle, 9 9 } from '@fortawesome/react-native-fontawesome' 10 10 import {PostsFeedItemModel} from 'state/models/feeds/posts' 11 + import {ModerationBehaviorCode} from 'lib/labeling/types' 11 12 import {Link, DesktopWebTextLink} from '../util/Link' 12 13 import {Text} from '../util/text/Text' 13 14 import {UserInfoText} from '../util/UserInfoText' ··· 31 32 isThreadChild, 32 33 isThreadParent, 33 34 showFollowBtn, 35 + ignoreMuteFor, 34 36 }: { 35 37 item: PostsFeedItemModel 36 38 isThreadChild?: boolean 37 39 isThreadParent?: boolean 38 40 showReplyLine?: boolean 39 41 showFollowBtn?: boolean 40 - ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf 42 + ignoreMuteFor?: string 41 43 }) { 42 44 const store = useStores() 43 45 const pal = usePalette('default') ··· 142 144 isThreadParent ? styles.outerNoBottom : undefined, 143 145 ] 144 146 147 + // moderation override 148 + let moderation = item.moderation.list 149 + if ( 150 + ignoreMuteFor === item.post.author.did && 151 + moderation.isMute && 152 + !moderation.noOverride 153 + ) { 154 + moderation = {behavior: ModerationBehaviorCode.Show} 155 + } 156 + 145 157 return ( 146 158 <PostHider 147 159 testID={`feedItem-by-${item.post.author.handle}`} 148 160 style={outerStyles} 149 161 href={itemHref} 150 - moderation={item.moderation.list}> 162 + moderation={moderation}> 151 163 {isThreadChild && ( 152 164 <View 153 165 style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} ··· 237 249 </View> 238 250 )} 239 251 <ContentHider 240 - moderation={item.moderation.list} 252 + moderation={moderation} 241 253 containerStyle={styles.contentHider}> 242 254 {item.richText?.text ? ( 243 255 <View style={styles.postTextContainer}>
+3 -1
src/view/com/posts/FeedSlice.tsx
··· 19 19 ignoreMuteFor?: string 20 20 }) { 21 21 if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { 22 - return null 22 + if (!ignoreMuteFor && !slice.moderation.list.noOverride) { 23 + return null 24 + } 23 25 } 24 26 if (slice.isThread && slice.items.length > 3) { 25 27 const last = slice.items.length - 1
+2 -2
src/view/com/profile/ProfileCard.tsx
··· 32 32 noBorder?: boolean 33 33 followers?: AppBskyActorDefs.ProfileView[] | undefined 34 34 overrideModeration?: boolean 35 - renderButton?: () => JSX.Element 35 + renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element 36 36 }) => { 37 37 const store = useStores() 38 38 const pal = usePalette('default') ··· 92 92 )} 93 93 </View> 94 94 {renderButton ? ( 95 - <View style={styles.layoutButton}>{renderButton()}</View> 95 + <View style={styles.layoutButton}>{renderButton(profile)}</View> 96 96 ) : undefined} 97 97 </View> 98 98 {profile.description ? (
+42 -14
src/view/com/profile/ProfileHeader.tsx
··· 23 23 import * as Toast from '../util/Toast' 24 24 import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 25 25 import {Text} from '../util/text/Text' 26 + import {TextLink} from '../util/Link' 26 27 import {RichText} from '../util/text/RichText' 27 28 import {UserAvatar} from '../util/UserAvatar' 28 29 import {UserBanner} from '../util/UserBanner' ··· 30 31 import {usePalette} from 'lib/hooks/usePalette' 31 32 import {useAnalytics} from 'lib/analytics' 32 33 import {NavigationProp} from 'lib/routes/types' 34 + import {listUriToHref} from 'lib/strings/url-helpers' 33 35 import {isDesktopWeb, isNative} from 'platform/detection' 34 36 import {FollowState} from 'state/models/cache/my-follows' 35 37 import {shareUrl} from 'lib/sharing' ··· 146 148 navigation.push('ProfileFollows', {name: view.handle}) 147 149 }, [track, navigation, view]) 148 150 149 - const onPressShare = React.useCallback(async () => { 151 + const onPressShare = React.useCallback(() => { 150 152 track('ProfileHeader:ShareButtonClicked') 151 153 const url = toShareUrl(`/profile/${view.handle}`) 152 154 shareUrl(url) 153 155 }, [track, view]) 156 + 157 + const onPressAddRemoveLists = React.useCallback(() => { 158 + track('ProfileHeader:AddToListsButtonClicked') 159 + store.shell.openModal({ 160 + name: 'list-add-remove-user', 161 + subject: view.did, 162 + displayName: view.displayName || view.handle, 163 + }) 164 + }, [track, view, store]) 154 165 155 166 const onPressMuteAccount = React.useCallback(async () => { 156 167 track('ProfileHeader:MuteAccountButtonClicked') ··· 233 244 label: 'Share', 234 245 onPress: onPressShare, 235 246 }, 247 + { 248 + testID: 'profileHeaderDropdownListAddRemoveBtn', 249 + label: 'Add to Lists', 250 + onPress: onPressAddRemoveLists, 251 + }, 236 252 ] 237 253 if (!isMe) { 238 254 items.push({sep: true}) ··· 269 285 onPressUnblockAccount, 270 286 onPressBlockAccount, 271 287 onPressReportAccount, 288 + onPressAddRemoveLists, 272 289 ]) 273 290 274 291 const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) ··· 422 439 {view.viewer.blocking ? ( 423 440 <View 424 441 testID="profileHeaderBlockedNotice" 425 - style={[styles.moderationNotice, pal.view, pal.border]}> 426 - <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> 427 - <Text type="md" style={[s.mr2, pal.text]}> 442 + style={[styles.moderationNotice, pal.viewLight]}> 443 + <FontAwesomeIcon icon="ban" style={[pal.text]} /> 444 + <Text type="lg-medium" style={pal.text}> 428 445 Account blocked 429 446 </Text> 430 447 </View> 431 448 ) : view.viewer.muted ? ( 432 449 <View 433 450 testID="profileHeaderMutedNotice" 434 - style={[styles.moderationNotice, pal.view, pal.border]}> 451 + style={[styles.moderationNotice, pal.viewLight]}> 435 452 <FontAwesomeIcon 436 453 icon={['far', 'eye-slash']} 437 - style={[pal.text, s.mr5]} 454 + style={[pal.text]} 438 455 /> 439 - <Text type="md" style={[s.mr2, pal.text]}> 440 - Account muted 456 + <Text type="lg-medium" style={pal.text}> 457 + Account muted{' '} 458 + {view.viewer.mutedByList && ( 459 + <Text type="lg-medium" style={pal.text}> 460 + by{' '} 461 + <TextLink 462 + type="lg-medium" 463 + style={pal.link} 464 + href={listUriToHref(view.viewer.mutedByList.uri)} 465 + text={view.viewer.mutedByList.name} 466 + /> 467 + </Text> 468 + )} 441 469 </Text> 442 470 </View> 443 471 ) : undefined} 444 472 {view.viewer.blockedBy && ( 445 473 <View 446 474 testID="profileHeaderBlockedNotice" 447 - style={[styles.moderationNotice, pal.view, pal.border]}> 448 - <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> 449 - <Text type="md" style={[s.mr2, pal.text]}> 475 + style={[styles.moderationNotice, pal.viewLight]}> 476 + <FontAwesomeIcon icon="ban" style={[pal.text]} /> 477 + <Text type="lg-medium" style={pal.text}> 450 478 This account has blocked you 451 479 </Text> 452 480 </View> ··· 595 623 moderationNotice: { 596 624 flexDirection: 'row', 597 625 alignItems: 'center', 598 - borderWidth: 1, 599 626 borderRadius: 8, 600 - paddingHorizontal: 12, 601 - paddingVertical: 10, 627 + paddingHorizontal: 16, 628 + paddingVertical: 14, 629 + gap: 8, 602 630 }, 603 631 604 632 br40: {borderRadius: 40},
+3 -1
src/view/com/util/EmptyState.tsx
··· 10 10 import {usePalette} from 'lib/hooks/usePalette' 11 11 12 12 export function EmptyState({ 13 + testID, 13 14 icon, 14 15 message, 15 16 style, 16 17 }: { 18 + testID?: string 17 19 icon: IconProp | 'user-group' 18 20 message: string 19 21 style?: StyleProp<ViewStyle> 20 22 }) { 21 23 const pal = usePalette('default') 22 24 return ( 23 - <View style={[styles.container, style]}> 25 + <View testID={testID} style={[styles.container, style]}> 24 26 <View style={styles.iconContainer}> 25 27 {icon === 'user-group' ? ( 26 28 <UserGroupIcon size="64" style={styles.icon} />
+88
src/view/com/util/EmptyStateWithButton.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import {IconProp} from '@fortawesome/fontawesome-svg-core' 8 + import {Text} from './text/Text' 9 + import {Button} from './forms/Button' 10 + import {usePalette} from 'lib/hooks/usePalette' 11 + import {s} from 'lib/styles' 12 + 13 + interface Props { 14 + testID?: string 15 + icon: IconProp 16 + message: string 17 + buttonLabel: string 18 + onPress: () => void 19 + } 20 + 21 + export function EmptyStateWithButton(props: Props) { 22 + const pal = usePalette('default') 23 + const palInverted = usePalette('inverted') 24 + 25 + return ( 26 + <View testID={props.testID} style={styles.container}> 27 + <View style={styles.iconContainer}> 28 + <FontAwesomeIcon 29 + icon={props.icon} 30 + style={[styles.icon, pal.text]} 31 + size={62} 32 + /> 33 + </View> 34 + <Text type="xl-medium" style={[s.textCenter, pal.text]}> 35 + {props.message} 36 + </Text> 37 + <View style={styles.btns}> 38 + <Button 39 + testID={props.testID ? `${props.testID}-button` : undefined} 40 + type="inverted" 41 + style={styles.btn} 42 + onPress={props.onPress}> 43 + <FontAwesomeIcon 44 + icon="plus" 45 + style={palInverted.text as FontAwesomeIconStyle} 46 + size={14} 47 + /> 48 + <Text type="lg-medium" style={palInverted.text}> 49 + {props.buttonLabel} 50 + </Text> 51 + </Button> 52 + </View> 53 + </View> 54 + ) 55 + } 56 + const styles = StyleSheet.create({ 57 + container: { 58 + height: '100%', 59 + paddingVertical: 40, 60 + paddingHorizontal: 30, 61 + }, 62 + iconContainer: { 63 + marginBottom: 16, 64 + }, 65 + icon: { 66 + marginLeft: 'auto', 67 + marginRight: 'auto', 68 + }, 69 + btns: { 70 + flexDirection: 'row', 71 + justifyContent: 'center', 72 + }, 73 + btn: { 74 + gap: 10, 75 + marginVertical: 20, 76 + flexDirection: 'row', 77 + alignItems: 'center', 78 + paddingVertical: 14, 79 + paddingHorizontal: 24, 80 + borderRadius: 30, 81 + }, 82 + notice: { 83 + borderRadius: 12, 84 + paddingHorizontal: 12, 85 + paddingVertical: 10, 86 + marginHorizontal: 30, 87 + }, 88 + })
+16 -3
src/view/com/util/ViewHeader.tsx
··· 20 20 canGoBack, 21 21 hideOnScroll, 22 22 showOnDesktop, 23 + renderButton, 23 24 }: { 24 25 title: string 25 26 canGoBack?: boolean 26 27 hideOnScroll?: boolean 27 28 showOnDesktop?: boolean 29 + renderButton?: () => JSX.Element 28 30 }) { 29 31 const pal = usePalette('default') 30 32 const store = useStores() ··· 46 48 47 49 if (isDesktopWeb) { 48 50 if (showOnDesktop) { 49 - return <DesktopWebHeader title={title} /> 51 + return <DesktopWebHeader title={title} renderButton={renderButton} /> 50 52 } 51 53 return null 52 54 } else { ··· 79 81 {title} 80 82 </Text> 81 83 </View> 82 - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> 84 + {renderButton ? ( 85 + renderButton() 86 + ) : ( 87 + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> 88 + )} 83 89 </Container> 84 90 ) 85 91 } 86 92 }) 87 93 88 - function DesktopWebHeader({title}: {title: string}) { 94 + function DesktopWebHeader({ 95 + title, 96 + renderButton, 97 + }: { 98 + title: string 99 + renderButton?: () => JSX.Element 100 + }) { 89 101 const pal = usePalette('default') 90 102 return ( 91 103 <CenteredView style={[styles.header, styles.desktopHeader, pal.border]}> ··· 94 106 {title} 95 107 </Text> 96 108 </View> 109 + {renderButton?.()} 97 110 </CenteredView> 98 111 ) 99 112 }
+10
src/view/index.ts
··· 38 38 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' 39 39 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' 40 40 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' 41 + import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' 42 + import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' 41 43 import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' 42 44 import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' 43 45 import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' ··· 46 48 import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' 47 49 import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' 48 50 import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' 51 + import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' 49 52 import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' 50 53 import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' 51 54 import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' ··· 67 70 import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' 68 71 import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' 69 72 import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' 73 + import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash' 70 74 import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' 71 75 import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' 76 + import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' 72 77 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' 73 78 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' 74 79 import {faX} from '@fortawesome/free-solid-svg-icons/faX' ··· 116 121 farEyeSlash, 117 122 faGear, 118 123 faGlobe, 124 + faHand, 125 + farHand, 119 126 faHeart, 120 127 fasHeart, 121 128 faHouse, ··· 124 131 faInfo, 125 132 faLanguage, 126 133 faLink, 134 + faListUl, 127 135 faLock, 128 136 faMagnifyingGlass, 129 137 faMessage, ··· 145 153 faUser, 146 154 faUsers, 147 155 faUserCheck, 156 + faUserSlash, 148 157 faUserPlus, 149 158 faUserXmark, 159 + faUsersSlash, 150 160 faTicket, 151 161 faTrashCan, 152 162 faX,
+5 -2
src/view/screens/BlockedAccounts.tsx src/view/screens/ModerationBlockedAccounts.tsx
··· 22 22 import {CenteredView} from 'view/com/util/Views' 23 23 import {ProfileCard} from 'view/com/profile/ProfileCard' 24 24 25 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'> 26 - export const BlockedAccounts = withAuthRequired( 25 + type Props = NativeStackScreenProps< 26 + CommonNavigatorParams, 27 + 'ModerationBlockedAccounts' 28 + > 29 + export const ModerationBlockedAccounts = withAuthRequired( 27 30 observer(({}: Props) => { 28 31 const pal = usePalette('default') 29 32 const store = useStores()
+1 -1
src/view/screens/Home.tsx
··· 62 62 setSelectedPage(index) 63 63 store.shell.setIsDrawerSwipeDisabled(index > 0) 64 64 }, 65 - [store], 65 + [store, setSelectedPage], 66 66 ) 67 67 68 68 const onPressSelected = React.useCallback(() => {
+136
src/view/screens/Moderation.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import { 5 + FontAwesomeIcon, 6 + FontAwesomeIconStyle, 7 + } from '@fortawesome/react-native-fontawesome' 8 + import {observer} from 'mobx-react-lite' 9 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 10 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 11 + import {useStores} from 'state/index' 12 + import {s} from 'lib/styles' 13 + import {CenteredView} from '../com/util/Views' 14 + import {ViewHeader} from '../com/util/ViewHeader' 15 + import {Link} from '../com/util/Link' 16 + import {Text} from '../com/util/text/Text' 17 + import {usePalette} from 'lib/hooks/usePalette' 18 + import {useAnalytics} from 'lib/analytics' 19 + import {isDesktopWeb} from 'platform/detection' 20 + 21 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> 22 + export const ModerationScreen = withAuthRequired( 23 + observer(function Moderation({}: Props) { 24 + const pal = usePalette('default') 25 + const store = useStores() 26 + const {screen, track} = useAnalytics() 27 + 28 + useFocusEffect( 29 + React.useCallback(() => { 30 + screen('Moderation') 31 + store.shell.setMinimalShellMode(false) 32 + }, [screen, store]), 33 + ) 34 + 35 + const onPressContentFiltering = React.useCallback(() => { 36 + track('Moderation:ContentfilteringButtonClicked') 37 + store.shell.openModal({name: 'content-filtering-settings'}) 38 + }, [track, store]) 39 + 40 + return ( 41 + <CenteredView 42 + style={[ 43 + s.hContentRegion, 44 + pal.border, 45 + isDesktopWeb ? styles.desktopContainer : pal.viewLight, 46 + ]} 47 + testID="moderationScreen"> 48 + <ViewHeader title="Moderation" showOnDesktop /> 49 + <View style={styles.spacer} /> 50 + <TouchableOpacity 51 + testID="contentFilteringBtn" 52 + style={[styles.linkCard, pal.view]} 53 + onPress={onPressContentFiltering} 54 + accessibilityHint="Content filtering" 55 + accessibilityLabel="Opens configurable content filtering settings"> 56 + <View style={[styles.iconContainer, pal.btn]}> 57 + <FontAwesomeIcon 58 + icon="eye" 59 + style={pal.text as FontAwesomeIconStyle} 60 + /> 61 + </View> 62 + <Text type="lg" style={pal.text}> 63 + Content filtering 64 + </Text> 65 + </TouchableOpacity> 66 + <Link 67 + testID="mutelistsBtn" 68 + style={[styles.linkCard, pal.view]} 69 + href="/moderation/mute-lists"> 70 + <View style={[styles.iconContainer, pal.btn]}> 71 + <FontAwesomeIcon 72 + icon="users-slash" 73 + style={pal.text as FontAwesomeIconStyle} 74 + /> 75 + </View> 76 + <Text type="lg" style={pal.text}> 77 + Mute lists 78 + </Text> 79 + </Link> 80 + <Link 81 + testID="mutedAccountsBtn" 82 + style={[styles.linkCard, pal.view]} 83 + href="/moderation/muted-accounts"> 84 + <View style={[styles.iconContainer, pal.btn]}> 85 + <FontAwesomeIcon 86 + icon="user-slash" 87 + style={pal.text as FontAwesomeIconStyle} 88 + /> 89 + </View> 90 + <Text type="lg" style={pal.text}> 91 + Muted accounts 92 + </Text> 93 + </Link> 94 + <Link 95 + testID="blockedAccountsBtn" 96 + style={[styles.linkCard, pal.view]} 97 + href="/moderation/blocked-accounts"> 98 + <View style={[styles.iconContainer, pal.btn]}> 99 + <FontAwesomeIcon 100 + icon="ban" 101 + style={pal.text as FontAwesomeIconStyle} 102 + /> 103 + </View> 104 + <Text type="lg" style={pal.text}> 105 + Blocked accounts 106 + </Text> 107 + </Link> 108 + </CenteredView> 109 + ) 110 + }), 111 + ) 112 + 113 + const styles = StyleSheet.create({ 114 + desktopContainer: { 115 + borderLeftWidth: 1, 116 + borderRightWidth: 1, 117 + }, 118 + spacer: { 119 + height: 6, 120 + }, 121 + linkCard: { 122 + flexDirection: 'row', 123 + alignItems: 'center', 124 + paddingVertical: 12, 125 + paddingHorizontal: 18, 126 + marginBottom: 1, 127 + }, 128 + iconContainer: { 129 + alignItems: 'center', 130 + justifyContent: 'center', 131 + width: 40, 132 + height: 40, 133 + borderRadius: 30, 134 + marginRight: 12, 135 + }, 136 + })
+122
src/view/screens/ModerationMuteLists.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet} from 'react-native' 3 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 4 + import { 5 + FontAwesomeIcon, 6 + FontAwesomeIconStyle, 7 + } from '@fortawesome/react-native-fontawesome' 8 + import {AtUri} from '@atproto/api' 9 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 10 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 11 + import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' 12 + import {useStores} from 'state/index' 13 + import {ListsListModel} from 'state/models/lists/lists-list' 14 + import {ListsList} from 'view/com/lists/ListsList' 15 + import {Button} from 'view/com/util/forms/Button' 16 + import {NavigationProp} from 'lib/routes/types' 17 + import {usePalette} from 'lib/hooks/usePalette' 18 + import {CenteredView} from 'view/com/util/Views' 19 + import {ViewHeader} from 'view/com/util/ViewHeader' 20 + import {isDesktopWeb} from 'platform/detection' 21 + 22 + type Props = NativeStackScreenProps< 23 + CommonNavigatorParams, 24 + 'ModerationMuteLists' 25 + > 26 + export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { 27 + const pal = usePalette('default') 28 + const store = useStores() 29 + const navigation = useNavigation<NavigationProp>() 30 + 31 + const mutelists: ListsListModel = React.useMemo( 32 + () => new ListsListModel(store, 'my-modlists'), 33 + [store], 34 + ) 35 + 36 + useFocusEffect( 37 + React.useCallback(() => { 38 + store.shell.setMinimalShellMode(false) 39 + mutelists.refresh() 40 + }, [store, mutelists]), 41 + ) 42 + 43 + const onPressNewMuteList = React.useCallback(() => { 44 + store.shell.openModal({ 45 + name: 'create-or-edit-mute-list', 46 + onSave: (uri: string) => { 47 + try { 48 + const urip = new AtUri(uri) 49 + navigation.navigate('ProfileList', { 50 + name: urip.hostname, 51 + rkey: urip.rkey, 52 + }) 53 + } catch {} 54 + }, 55 + }) 56 + }, [store, navigation]) 57 + 58 + const renderEmptyState = React.useCallback(() => { 59 + return ( 60 + <EmptyStateWithButton 61 + testID="emptyMuteLists" 62 + icon="users-slash" 63 + message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." 64 + buttonLabel="New Mute List" 65 + onPress={onPressNewMuteList} 66 + /> 67 + ) 68 + }, [onPressNewMuteList]) 69 + 70 + const renderHeaderButton = React.useCallback( 71 + () => ( 72 + <Button 73 + type="primary-light" 74 + onPress={onPressNewMuteList} 75 + style={styles.createBtn}> 76 + <FontAwesomeIcon 77 + icon="plus" 78 + style={pal.link as FontAwesomeIconStyle} 79 + size={18} 80 + /> 81 + </Button> 82 + ), 83 + [onPressNewMuteList, pal], 84 + ) 85 + 86 + return ( 87 + <CenteredView 88 + style={[ 89 + styles.container, 90 + isDesktopWeb && styles.containerDesktop, 91 + pal.view, 92 + pal.border, 93 + ]} 94 + testID="moderationMutelistsScreen"> 95 + <ViewHeader 96 + title="Mute Lists" 97 + showOnDesktop 98 + renderButton={renderHeaderButton} 99 + /> 100 + <ListsList 101 + listsList={mutelists} 102 + showAddBtns={isDesktopWeb} 103 + renderEmptyState={renderEmptyState} 104 + onPressCreateNew={onPressNewMuteList} 105 + /> 106 + </CenteredView> 107 + ) 108 + }) 109 + 110 + const styles = StyleSheet.create({ 111 + container: { 112 + flex: 1, 113 + paddingBottom: isDesktopWeb ? 0 : 100, 114 + }, 115 + containerDesktop: { 116 + borderLeftWidth: 1, 117 + borderRightWidth: 1, 118 + }, 119 + createBtn: { 120 + width: 40, 121 + }, 122 + })
+5 -2
src/view/screens/MutedAccounts.tsx src/view/screens/ModerationMutedAccounts.tsx
··· 22 22 import {CenteredView} from 'view/com/util/Views' 23 23 import {ProfileCard} from 'view/com/profile/ProfileCard' 24 24 25 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'MutedAccounts'> 26 - export const MutedAccounts = withAuthRequired( 25 + type Props = NativeStackScreenProps< 26 + CommonNavigatorParams, 27 + 'ModerationMutedAccounts' 28 + > 29 + export const ModerationMutedAccounts = withAuthRequired( 27 30 observer(({}: Props) => { 28 31 const pal = usePalette('default') 29 32 const store = useStores()
+64 -31
src/view/screens/Profile.tsx
··· 7 7 import {ViewSelector} from '../com/util/ViewSelector' 8 8 import {CenteredView} from '../com/util/Views' 9 9 import {ScreenHider} from 'view/com/util/moderation/ScreenHider' 10 - import {ProfileUiModel} from 'state/models/ui/profile' 10 + import {ProfileUiModel, Sections} from 'state/models/ui/profile' 11 11 import {useStores} from 'state/index' 12 12 import {PostsFeedSliceModel} from 'state/models/feeds/posts' 13 13 import {ProfileHeader} from '../com/profile/ProfileHeader' 14 14 import {FeedSlice} from '../com/posts/FeedSlice' 15 - import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' 15 + import {ListCard} from 'view/com/lists/ListCard' 16 + import { 17 + PostFeedLoadingPlaceholder, 18 + ProfileCardFeedLoadingPlaceholder, 19 + } from '../com/util/LoadingPlaceholder' 16 20 import {ErrorScreen} from '../com/util/error/ErrorScreen' 17 21 import {ErrorMessage} from '../com/util/error/ErrorMessage' 18 22 import {EmptyState} from '../com/util/EmptyState' ··· 111 115 }, [uiState.showLoadingMoreFooter]) 112 116 const renderItem = React.useCallback( 113 117 (item: any) => { 114 - if (item === ProfileUiModel.END_ITEM) { 115 - return <Text style={styles.endItem}>- end of feed -</Text> 116 - } else if (item === ProfileUiModel.LOADING_ITEM) { 117 - return <PostFeedLoadingPlaceholder /> 118 - } else if (item._reactKey === '__error__') { 119 - if (uiState.feed.isBlocking) { 118 + if (uiState.selectedView === Sections.Lists) { 119 + if (item === ProfileUiModel.LOADING_ITEM) { 120 + return <ProfileCardFeedLoadingPlaceholder /> 121 + } else if (item._reactKey === '__error__') { 122 + return ( 123 + <View style={s.p5}> 124 + <ErrorMessage 125 + message={item.error} 126 + onPressTryAgain={onPressTryAgain} 127 + /> 128 + </View> 129 + ) 130 + } else if (item === ProfileUiModel.EMPTY_ITEM) { 120 131 return ( 121 132 <EmptyState 122 - icon="ban" 123 - message="Posts hidden" 133 + testID="listsEmpty" 134 + icon="list-ul" 135 + message="No lists yet!" 124 136 style={styles.emptyState} 125 137 /> 126 138 ) 139 + } else { 140 + return <ListCard testID={`list-${item.name}`} list={item} /> 127 141 } 128 - if (uiState.feed.isBlockedBy) { 142 + } else { 143 + if (item === ProfileUiModel.END_ITEM) { 144 + return <Text style={styles.endItem}>- end of feed -</Text> 145 + } else if (item === ProfileUiModel.LOADING_ITEM) { 146 + return <PostFeedLoadingPlaceholder /> 147 + } else if (item._reactKey === '__error__') { 148 + if (uiState.feed.isBlocking) { 149 + return ( 150 + <EmptyState 151 + icon="ban" 152 + message="Posts hidden" 153 + style={styles.emptyState} 154 + /> 155 + ) 156 + } 157 + if (uiState.feed.isBlockedBy) { 158 + return ( 159 + <EmptyState 160 + icon="ban" 161 + message="Posts hidden" 162 + style={styles.emptyState} 163 + /> 164 + ) 165 + } 166 + return ( 167 + <View style={s.p5}> 168 + <ErrorMessage 169 + message={item.error} 170 + onPressTryAgain={onPressTryAgain} 171 + /> 172 + </View> 173 + ) 174 + } else if (item === ProfileUiModel.EMPTY_ITEM) { 129 175 return ( 130 176 <EmptyState 131 - icon="ban" 132 - message="Posts hidden" 177 + icon={['far', 'message']} 178 + message="No posts yet!" 133 179 style={styles.emptyState} 134 180 /> 135 181 ) 182 + } else if (item instanceof PostsFeedSliceModel) { 183 + return ( 184 + <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> 185 + ) 136 186 } 137 - return ( 138 - <View style={s.p5}> 139 - <ErrorMessage 140 - message={item.error} 141 - onPressTryAgain={onPressTryAgain} 142 - /> 143 - </View> 144 - ) 145 - } else if (item === ProfileUiModel.EMPTY_ITEM) { 146 - return ( 147 - <EmptyState 148 - icon={['far', 'message']} 149 - message="No posts yet!" 150 - style={styles.emptyState} 151 - /> 152 - ) 153 - } else if (item instanceof PostsFeedSliceModel) { 154 - return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> 155 187 } 156 188 return <View /> 157 189 }, 158 190 [ 159 191 onPressTryAgain, 192 + uiState.selectedView, 160 193 uiState.profile.did, 161 194 uiState.feed.isBlocking, 162 195 uiState.feed.isBlockedBy,
+175
src/view/screens/ProfileList.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 5 + import {useNavigation} from '@react-navigation/native' 6 + import {observer} from 'mobx-react-lite' 7 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 8 + import {ViewHeader} from 'view/com/util/ViewHeader' 9 + import {CenteredView} from 'view/com/util/Views' 10 + import {ListItems} from 'view/com/lists/ListItems' 11 + import {EmptyState} from 'view/com/util/EmptyState' 12 + import {Button} from 'view/com/util/forms/Button' 13 + import * as Toast from 'view/com/util/Toast' 14 + import {ListModel} from 'state/models/content/list' 15 + import {useStores} from 'state/index' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + import {NavigationProp} from 'lib/routes/types' 18 + import {isDesktopWeb} from 'platform/detection' 19 + 20 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> 21 + export const ProfileListScreen = withAuthRequired( 22 + observer(({route}: Props) => { 23 + const store = useStores() 24 + const navigation = useNavigation<NavigationProp>() 25 + const pal = usePalette('default') 26 + const {name, rkey} = route.params 27 + 28 + const list: ListModel = React.useMemo(() => { 29 + const model = new ListModel( 30 + store, 31 + `at://${name}/app.bsky.graph.list/${rkey}`, 32 + ) 33 + return model 34 + }, [store, name, rkey]) 35 + 36 + useFocusEffect( 37 + React.useCallback(() => { 38 + store.shell.setMinimalShellMode(false) 39 + list.loadMore(true) 40 + }, [store, list]), 41 + ) 42 + 43 + const onToggleSubscribed = React.useCallback(async () => { 44 + try { 45 + if (list.list?.viewer?.muted) { 46 + await list.unsubscribe() 47 + } else { 48 + await list.subscribe() 49 + } 50 + } catch (err) { 51 + Toast.show( 52 + 'There was an an issue updating your subscription, please check your internet connection and try again.', 53 + ) 54 + store.log.error('Failed up update subscription', {err}) 55 + } 56 + }, [store, list]) 57 + 58 + const onPressEditList = React.useCallback(() => { 59 + store.shell.openModal({ 60 + name: 'create-or-edit-mute-list', 61 + list, 62 + onSave() { 63 + list.refresh() 64 + }, 65 + }) 66 + }, [store, list]) 67 + 68 + const onPressDeleteList = React.useCallback(() => { 69 + store.shell.openModal({ 70 + name: 'confirm', 71 + title: 'Delete List', 72 + message: 'Are you sure?', 73 + async onPressConfirm() { 74 + await list.delete() 75 + if (navigation.canGoBack()) { 76 + navigation.goBack() 77 + } else { 78 + navigation.navigate('Home') 79 + } 80 + }, 81 + }) 82 + }, [store, list, navigation]) 83 + 84 + const renderEmptyState = React.useCallback(() => { 85 + return <EmptyState icon="users-slash" message="This list is empty!" /> 86 + }, []) 87 + 88 + const renderHeaderBtn = React.useCallback(() => { 89 + return ( 90 + <View style={styles.headerBtns}> 91 + {list?.isOwner && ( 92 + <Button 93 + type="default" 94 + label="Delete List" 95 + testID="deleteListBtn" 96 + accessibilityLabel="Delete list" 97 + accessibilityHint="Deletes the mutelist" 98 + onPress={onPressDeleteList} 99 + /> 100 + )} 101 + {list?.isOwner && ( 102 + <Button 103 + type="default" 104 + label="Edit List" 105 + testID="editListBtn" 106 + accessibilityLabel="Edit list" 107 + accessibilityHint="Opens a modal to edit the mutelist" 108 + onPress={onPressEditList} 109 + /> 110 + )} 111 + {list.list?.viewer?.muted ? ( 112 + <Button 113 + type="inverted" 114 + label="Unsubscribe" 115 + testID="unsubscribeListBtn" 116 + accessibilityLabel="Unsubscribe from this list" 117 + accessibilityHint="Stops muting the users included in this list" 118 + onPress={onToggleSubscribed} 119 + /> 120 + ) : ( 121 + <Button 122 + type="primary" 123 + label="Subscribe & Mute" 124 + testID="subscribeListBtn" 125 + accessibilityLabel="Subscribe to this list" 126 + accessibilityHint="Mutes the users included in this list" 127 + onPress={onToggleSubscribed} 128 + /> 129 + )} 130 + </View> 131 + ) 132 + }, [ 133 + list?.isOwner, 134 + list.list?.viewer?.muted, 135 + onPressDeleteList, 136 + onPressEditList, 137 + onToggleSubscribed, 138 + ]) 139 + 140 + return ( 141 + <CenteredView 142 + style={[ 143 + styles.container, 144 + isDesktopWeb && styles.containerDesktop, 145 + pal.view, 146 + pal.border, 147 + ]} 148 + testID="moderationMutelistsScreen"> 149 + <ViewHeader title="" renderButton={renderHeaderBtn} /> 150 + <ListItems 151 + list={list} 152 + renderEmptyState={renderEmptyState} 153 + onToggleSubscribed={onToggleSubscribed} 154 + onPressEditList={onPressEditList} 155 + onPressDeleteList={onPressDeleteList} 156 + /> 157 + </CenteredView> 158 + ) 159 + }), 160 + ) 161 + 162 + const styles = StyleSheet.create({ 163 + headerBtns: { 164 + flexDirection: 'row', 165 + gap: 8, 166 + }, 167 + container: { 168 + flex: 1, 169 + paddingBottom: isDesktopWeb ? 0 : 100, 170 + }, 171 + containerDesktop: { 172 + borderLeftWidth: 1, 173 + borderRightWidth: 1, 174 + }, 175 + })
+2 -53
src/view/screens/Settings.tsx
··· 127 127 store.shell.openModal({name: 'invite-codes'}) 128 128 }, [track, store]) 129 129 130 - const onPressContentFiltering = React.useCallback(() => { 131 - track('Settings:ContentfilteringButtonClicked') 132 - store.shell.openModal({name: 'content-filtering-settings'}) 133 - }, [track, store]) 134 - 135 130 const onPressContentLanguages = React.useCallback(() => { 136 131 track('Settings:ContentlanguagesButtonClicked') 137 132 store.shell.openModal({name: 'content-languages-settings'}) ··· 252 247 Add account 253 248 </Text> 254 249 </TouchableOpacity> 250 + 255 251 <View style={styles.spacer20} /> 252 + 256 253 <Text type="xl-bold" style={[pal.text, styles.heading]}> 257 254 Invite a friend 258 255 </Text> ··· 287 284 288 285 <View style={styles.spacer20} /> 289 286 290 - <Text type="xl-bold" style={[pal.text, styles.heading]}> 291 - Moderation 292 - </Text> 293 - <TouchableOpacity 294 - testID="contentFilteringBtn" 295 - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 296 - onPress={isSwitching ? undefined : onPressContentFiltering} 297 - accessibilityHint="Content moderation" 298 - accessibilityLabel="Opens configurable content moderation settings"> 299 - <View style={[styles.iconContainer, pal.btn]}> 300 - <FontAwesomeIcon 301 - icon="eye" 302 - style={pal.text as FontAwesomeIconStyle} 303 - /> 304 - </View> 305 - <Text type="lg" style={pal.text}> 306 - Content moderation 307 - </Text> 308 - </TouchableOpacity> 309 - <Link 310 - testID="mutedAccountsBtn" 311 - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 312 - href="/settings/muted-accounts"> 313 - <View style={[styles.iconContainer, pal.btn]}> 314 - <FontAwesomeIcon 315 - icon={['far', 'eye-slash']} 316 - style={pal.text as FontAwesomeIconStyle} 317 - /> 318 - </View> 319 - <Text type="lg" style={pal.text}> 320 - Muted accounts 321 - </Text> 322 - </Link> 323 - <Link 324 - testID="blockedAccountsBtn" 325 - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 326 - href="/settings/blocked-accounts"> 327 - <View style={[styles.iconContainer, pal.btn]}> 328 - <FontAwesomeIcon 329 - icon="ban" 330 - style={pal.text as FontAwesomeIconStyle} 331 - /> 332 - </View> 333 - <Text type="lg" style={pal.text}> 334 - Blocked accounts 335 - </Text> 336 - </Link> 337 - <View style={styles.spacer20} /> 338 287 <Text type="xl-bold" style={[pal.text, styles.heading]}> 339 288 Advanced 340 289 </Text>
+19
src/view/shell/Drawer.tsx
··· 94 94 onPressTab('MyProfile') 95 95 }, [onPressTab]) 96 96 97 + const onPressModeration = React.useCallback(() => { 98 + track('Menu:ItemClicked', {url: 'Moderation'}) 99 + navigation.navigate('Moderation') 100 + store.shell.closeDrawer() 101 + }, [navigation, track, store.shell]) 102 + 97 103 const onPressSettings = React.useCallback(() => { 98 104 track('Menu:ItemClicked', {url: 'Settings'}) 99 105 navigation.navigate('Settings') ··· 219 225 count={notifications.unreadCountLabel} 220 226 bold={isAtNotifications} 221 227 onPress={onPressNotifications} 228 + /> 229 + <MenuItem 230 + icon={ 231 + <FontAwesomeIcon 232 + icon={['far', 'hand']} 233 + style={pal.text as FontAwesomeIconStyle} 234 + size={20} 235 + /> 236 + } 237 + label="Moderation" 238 + accessibilityLabel="Moderation" 239 + accessibilityHint="" 240 + onPress={onPressModeration} 222 241 /> 223 242 <MenuItem 224 243 icon={
+18
src/view/shell/desktop/LeftNav.tsx
··· 203 203 } 204 204 label="Notifications" 205 205 /> 206 + <NavItem 207 + href="/moderation" 208 + icon={ 209 + <FontAwesomeIcon 210 + icon={['far', 'hand']} 211 + style={pal.text as FontAwesomeIconStyle} 212 + size={20} 213 + /> 214 + } 215 + iconFilled={ 216 + <FontAwesomeIcon 217 + icon="hand" 218 + style={pal.text as FontAwesomeIconStyle} 219 + size={20} 220 + /> 221 + } 222 + label="Moderation" 223 + /> 206 224 {store.session.hasSession && ( 207 225 <NavItem 208 226 href={`/profile/${store.me.handle}`}
+16 -5
yarn.lock
··· 29 29 jsonpointer "^5.0.0" 30 30 leven "^3.1.0" 31 31 32 - "@atproto/api@*", "@atproto/api@0.2.11": 32 + "@atproto/api@*": 33 33 version "0.2.11" 34 34 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf" 35 35 integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ== 36 + dependencies: 37 + "@atproto/common-web" "*" 38 + "@atproto/uri" "*" 39 + "@atproto/xrpc" "*" 40 + tlds "^1.234.0" 41 + typed-emitter "^2.1.0" 42 + 43 + "@atproto/api@0.3.1": 44 + version "0.3.1" 45 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.1.tgz#98699479f8385c7494a853144657895be392c3f3" 46 + integrity sha512-/AAZntrLUPCxw7q8FMtDsSYOjsAs5aAmllmArXyye5ITvbSw4pzWfJcBiKnQdmXpdwSrVWVEX7uwIp+GYWopqg== 36 47 dependencies: 37 48 "@atproto/common-web" "*" 38 49 "@atproto/uri" "*" ··· 137 148 resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" 138 149 integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== 139 150 140 - "@atproto/pds@^0.1.5": 141 - version "0.1.5" 142 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.5.tgz#59411497f2d85b6706ab793e8f7f618bdb8c51a3" 143 - integrity sha512-QtTf2mbqO5MEsrXPTFU43dSb0WT3TzaLw5mL++9w18CZDMvdmv2uJXKeaSiU+u3WJEtRpRs5hoLSdfrJ2i3PuA== 151 + "@atproto/pds@^0.1.6": 152 + version "0.1.6" 153 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.6.tgz#2858355887eac06f5e2da8701231e5cb46004c18" 154 + integrity sha512-bddIWH+OrEIxJ5HYst1mBS+95bNWC08FLaa3DVtJRHRCdfYaGDndZUVpOLLgzBRklDLicJyvva2JYEgp2mdgLA== 144 155 dependencies: 145 156 "@atproto/api" "*" 146 157 "@atproto/common" "*"