diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 6744f697..6ddfe3ca 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -91,6 +91,7 @@ async function main() { 'always-warn-profile', 'always-warn-posts', 'muted-account', + 'muted-by-list-account', ]) { await server.mocker.createUser(user) await server.mocker.follow('alice', user) @@ -258,11 +259,32 @@ async function main() { await server.mocker.createPost('muted-account', 'muted post') await server.mocker.createQuotePost( 'muted-account', - 'account quote post', + 'muted quote post', anchorPost, ) await server.mocker.createReply( 'muted-account', + 'muted reply', + anchorPost, + ) + + const list = await server.mocker.createMuteList( + 'alice', + 'Muted Users', + ) + await server.mocker.addToMuteList( + 'alice', + list, + server.mocker.users['muted-by-list-account'].did, + ) + await server.mocker.createPost('muted-by-list-account', 'muted post') + await server.mocker.createQuotePost( + 'muted-by-list-account', + 'account quote post', + anchorPost, + ) + await server.mocker.createReply( + 'muted-by-list-account', 'account reply', anchorPost, ) diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts new file mode 100644 index 00000000..e9316251 --- /dev/null +++ b/__e2e__/tests/mute-lists.test.ts @@ -0,0 +1,141 @@ +/* eslint-env detox/detox */ + +import {openApp, login, createServer, sleep} from '../util' + +describe('Profile screen', () => { + let service: string + beforeAll(async () => { + service = await createServer('?users&follows&labels') + await openApp({ + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, + }) + }) + + it('Login and view my mutelists', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await login(service, 'alice', 'hunter2') + await element(by.id('viewHeaderDrawerBtn')).tap() + await expect(element(by.id('drawer'))).toBeVisible() + await element(by.id('menuItemButton-Moderation')).tap() + await element(by.id('mutelistsBtn')).tap() + await expect(element(by.id('list-Muted Users'))).toBeVisible() + await element(by.id('list-Muted Users')).tap() + await expect( + element(by.id('user-muted-by-list-account.test')), + ).toBeVisible() + }) + + it('Toggle subscription', async () => { + await element(by.id('unsubscribeListBtn')).tap() + await element(by.id('subscribeListBtn')).tap() + }) + + it('Edit display name and description via the edit mutelist modal', async () => { + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editNameInput')).clearText() + await element(by.id('editNameInput')).typeText('Bad Ppl') + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('editDescriptionInput')).typeText('They bad') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They bad') + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove description via the edit mutelist modal', async () => { + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listDescription'))).not.toBeVisible() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Set avi via the edit mutelist modal', async () => { + await expect(element(by.id('userAvatarFallback'))).toExist() + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.id('changeAvatarLibraryBtn')).tap() + await sleep(3e3) + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarImage'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove avi via the edit mutelist modal', async () => { + await expect(element(by.id('userAvatarImage'))).toExist() + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.id('changeAvatarRemoveBtn')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarFallback'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Delete the mutelist', async () => { + await element(by.id('deleteListBtn')).tap() + await element(by.id('confirmBtn')).tap() + await expect(element(by.id('emptyMuteLists'))).toBeVisible() + }) + + it('Create a new mutelist', async () => { + await element(by.id('emptyMuteLists-button')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editNameInput')).typeText('Bad Ppl') + await element(by.id('editDescriptionInput')).typeText('They bad') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They bad') + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Shows the mutelist on my profile', async () => { + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('selector-2')).tap() + await element(by.id('list-Bad Ppl')).tap() + }) + + it('Adds and removes users on mutelists', async () => { + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('bob') + await element(by.id('searchAutoCompleteResult-bob.test')).tap() + await expect(element(by.id('profileView'))).toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() + }) +}) diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 902d1ffc..7c230041 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -106,10 +106,12 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/search", server.WebGeneric) e.GET("/notifications", server.WebGeneric) + e.GET("/moderation", server.WebGeneric) + e.GET("/moderation/mute-lists", server.WebGeneric) + e.GET("/moderation/muted-accounts", server.WebGeneric) + e.GET("/moderation/blocked-accounts", server.WebGeneric) e.GET("/settings", server.WebGeneric) e.GET("/settings/app-passwords", server.WebGeneric) - e.GET("/settings/muted-accounts", server.WebGeneric) - e.GET("/settings/blocked-accounts", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) e.GET("/support", server.WebGeneric) @@ -122,6 +124,7 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handle", server.WebProfile) e.GET("/profile/:handle/follows", server.WebGeneric) e.GET("/profile/:handle/followers", server.WebGeneric) + e.GET("/profile/:handle/lists/:rkey", server.WebGeneric) // post endpoints; only first populates info e.GET("/profile/:handle/post/:rkey", server.WebPost) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 7f8d2023..a75a0034 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -337,6 +337,32 @@ class Mocker { ]) .execute() } + + async createMuteList(user: string, name: string): Promise { + const res = await this.users[user]?.agent.app.bsky.graph.list.create( + {repo: this.users[user]?.did}, + { + purpose: 'app.bsky.graph.defs#modlist', + name, + createdAt: new Date().toISOString(), + }, + ) + await this.users[user]?.agent.app.bsky.graph.muteActorList({ + list: res.uri, + }) + return res.uri + } + + async addToMuteList(owner: string, list: string, subject: string) { + await this.users[owner]?.agent.app.bsky.graph.listitem.create( + {repo: this.users[owner]?.did}, + { + list, + subject, + createdAt: new Date().toISOString(), + }, + ) + } } const checkAvailablePort = (port: number) => diff --git a/package.json b/package.json index 29d0449d..77eae011 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.2.11", + "@atproto/api": "0.3.1", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/webpack-config": "^18.0.1", @@ -140,7 +140,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.1.5", + "@atproto/pds": "^0.1.6", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index afc7b39b..4e0403be 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -33,11 +33,14 @@ import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' import {NotificationsScreen} from './view/screens/Notifications' +import {ModerationScreen} from './view/screens/Moderation' +import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' @@ -49,8 +52,8 @@ import {TermsOfServiceScreen} from './view/screens/TermsOfService' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' import {AppPasswords} from 'view/screens/AppPasswords' -import {MutedAccounts} from 'view/screens/MutedAccounts' -import {BlockedAccounts} from 'view/screens/BlockedAccounts' +import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' +import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {getRoutingInstrumentation} from 'lib/sentry' const navigationRef = createNavigationContainerRef() @@ -70,6 +73,19 @@ function commonScreens(Stack: typeof HomeTab) { return ( <> + + + + + @@ -91,8 +108,6 @@ function commonScreens(Stack: typeof HomeTab) { /> - - ) } diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index baac0ed5..447b0a99 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -1,5 +1,6 @@ import { AppBskyActorDefs, + AppBskyGraphDefs, AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, AppBskyEmbedImages, @@ -16,6 +17,7 @@ import { Label, LabelValGroup, ModerationBehaviorCode, + ModerationBehavior, PostModeration, ProfileModeration, PostLabelInfo, @@ -127,11 +129,15 @@ export function getPostModeration( // muting if (postInfo.isMuted) { + let msg = 'Post from an account you muted.' + if (postInfo.mutedByList) { + msg = `Muted by ${postInfo.mutedByList.name}` + } return { avatar, - list: hide('Post from an account you muted.'), - thread: warn('Post from an account you muted.'), - view: warn('Post from an account you muted.'), + list: isMute(hide(msg)), + thread: isMute(warn(msg)), + view: isMute(warn(msg)), } } @@ -273,6 +279,7 @@ export function getProfileViewBasicLabelInfo( profileLabels: filterProfileLabels(profile.labels), isMuted: profile.viewer?.muted || false, isBlocking: !!profile.viewer?.blocking || false, + isBlockedBy: !!profile.viewer?.blockedBy || false, } } @@ -302,6 +309,21 @@ export function getEmbedMuted(embed?: Embed): boolean { return false } +export function getEmbedMutedByList( + embed?: Embed, +): AppBskyGraphDefs.ListViewBasic | undefined { + if (!embed) { + return undefined + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return embed.record.author.viewer?.mutedByList + } + return undefined +} + export function getEmbedBlocking(embed?: Embed): boolean { if (!embed) { return false @@ -401,6 +423,11 @@ function warnContent(reason: string) { } } +function isMute(behavior: ModerationBehavior): ModerationBehavior { + behavior.isMute = true + return behavior +} + function warnImages(reason: string) { return { behavior: ModerationBehaviorCode.WarnImages, diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts index 07804307..1ee05802 100644 --- a/src/lib/labeling/types.ts +++ b/src/lib/labeling/types.ts @@ -1,4 +1,4 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' +import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api' import {LabelPreferencesModel} from 'state/models/ui/preferences' export type Label = ComAtprotoLabelDefs.Label @@ -22,6 +22,7 @@ export interface PostLabelInfo { accountLabels: Label[] profileLabels: Label[] isMuted: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic isBlocking: boolean isBlockedBy: boolean } @@ -44,6 +45,7 @@ export enum ModerationBehaviorCode { export interface ModerationBehavior { behavior: ModerationBehaviorCode + isMute?: boolean noOverride?: boolean reason?: string } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 34e6e6a4..56775dee 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,10 +5,15 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { NotFound: undefined + Moderation: undefined + ModerationMuteLists: undefined + ModerationMutedAccounts: undefined + ModerationBlockedAccounts: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} ProfileFollows: {name: string} + ProfileList: {name: string; rkey: string} PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} @@ -20,8 +25,6 @@ export type CommonNavigatorParams = { CommunityGuidelines: undefined CopyrightPolicy: undefined AppPasswords: undefined - MutedAccounts: undefined - BlockedAccounts: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 549587f7..a5412920 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -94,6 +94,15 @@ export function convertBskyAppUrlIfNeeded(url: string): string { return url } +export function listUriToHref(url: string): string { + try { + const {hostname, rkey} = new AtUri(url) + return `/profile/${hostname}/lists/${rkey}` + } catch { + return '' + } +} + export function getYoutubeVideoId(link: string): string | undefined { let url try { diff --git a/src/routes.ts b/src/routes.ts index 43d31ee0..571aca7f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,17 +5,20 @@ export const router = new Router({ Search: '/search', Notifications: '/notifications', Settings: '/settings', + Moderation: '/moderation', + ModerationMuteLists: '/moderation/mute-lists', + ModerationMutedAccounts: '/moderation/muted-accounts', + ModerationBlockedAccounts: '/moderation/blocked-accounts', Profile: '/profile/:name', ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', + ProfileList: '/profile/:name/lists/:rkey', PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', - MutedAccounts: '/settings/muted-accounts', - BlockedAccounts: '/settings/blocked-accounts', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts new file mode 100644 index 00000000..b4af4472 --- /dev/null +++ b/src/state/models/content/list-membership.ts @@ -0,0 +1,112 @@ +import {makeAutoObservable} from 'mobx' +import {AtUri, AppBskyGraphListitem} from '@atproto/api' +import {runInAction} from 'mobx' +import {RootStoreModel} from '../root-store' + +const PAGE_SIZE = 100 +interface Membership { + uri: string + value: AppBskyGraphListitem.Record +} + +export class ListMembershipModel { + // data + memberships: Membership[] = [] + + constructor(public rootStore: RootStoreModel, public subject: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async fetch() { + // NOTE + // this approach to determining list membership is too inefficient to work at any scale + // it needs to be replaced with server side list membership queries + // -prf + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.subject === this.subject), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + runInAction(() => { + this.memberships = records + }) + } + + getMembership(listUri: string) { + return this.memberships.find(m => m.value.list === listUri) + } + + isMember(listUri: string) { + return !!this.getMembership(listUri) + } + + async add(listUri: string) { + if (this.isMember(listUri)) { + return + } + const res = await this.rootStore.agent.app.bsky.graph.listitem.create( + { + repo: this.rootStore.me.did, + }, + { + subject: this.subject, + list: listUri, + createdAt: new Date().toISOString(), + }, + ) + const {rkey} = new AtUri(res.uri) + const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.concat([record]) + }) + } + + async remove(listUri: string) { + const membership = this.getMembership(listUri) + if (!membership) { + return + } + const {rkey} = new AtUri(membership.uri) + await this.rootStore.agent.app.bsky.graph.listitem.delete({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.filter(m => m.value.list !== listUri) + }) + } + + async updateTo(uris: string) { + for (const uri of uris) { + await this.add(uri) + } + for (const membership of this.memberships) { + if (!uris.includes(membership.value.list)) { + await this.remove(membership.value.list) + } + } + } +} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts new file mode 100644 index 00000000..673ee943 --- /dev/null +++ b/src/state/models/content/list.ts @@ -0,0 +1,257 @@ +import {makeAutoObservable} from 'mobx' +import { + AtUri, + AppBskyGraphGetList as GetList, + AppBskyGraphDefs as GraphDefs, + AppBskyGraphList, +} from '@atproto/api' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {RootStoreModel} from '../root-store' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class ListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + list: GraphDefs.ListView | null = null + items: GraphDefs.ListItemView[] = [] + + static async createModList( + rootStore: RootStoreModel, + { + name, + description, + avatar, + }: {name: string; description: string; avatar: RNImage | undefined}, + ) { + const record: AppBskyGraphList.Record = { + purpose: 'app.bsky.graph.defs#modlist', + name, + description, + avatar: undefined, + createdAt: new Date().toISOString(), + } + if (avatar) { + const blobRes = await apilib.uploadBlob( + rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } + const res = await rootStore.agent.app.bsky.graph.list.create( + { + repo: rootStore.me.did, + }, + record, + ) + await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) + return res + } + + constructor(public rootStore: RootStoreModel, public uri: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.items.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get isOwner() { + return this.list?.creator.did === this.rootStore.me.did + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.graph.getList({ + list: this.uri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + async updateMetadata({ + name, + description, + avatar, + }: { + name: string + description: string + avatar: RNImage | null | undefined + }) { + if (!this.isOwner) { + throw new Error('Cannot edit this list') + } + + // get the current record + const {rkey} = new AtUri(this.uri) + const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ + repo: this.rootStore.me.did, + rkey, + }) + + // update the fields + record.name = name + record.description = description + if (avatar) { + const blobRes = await apilib.uploadBlob( + this.rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } else if (avatar === null) { + record.avatar = undefined + } + return await this.rootStore.agent.com.atproto.repo.putRecord({ + repo: this.rootStore.me.did, + collection: 'app.bsky.graph.list', + rkey, + record, + }) + } + + async delete() { + // fetch all the listitem records that belong to this list + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.list === this.uri), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + + // batch delete the list and listitem records + const createDel = (uri: string) => { + const urip = new AtUri(uri) + return { + $type: 'com.atproto.repo.applyWrites#delete', + collection: urip.collection, + rkey: urip.rkey, + } + } + await this.rootStore.agent.com.atproto.repo.applyWrites({ + repo: this.rootStore.me.did, + writes: [createDel(this.uri)].concat( + records.map(record => createDel(record.uri)), + ), + }) + } + + async subscribe() { + await this.rootStore.agent.app.bsky.graph.muteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + async unsubscribe() { + await this.rootStore.agent.app.bsky.graph.unmuteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user items', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user items', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetList.Response) { + this.items = [] + this._appendAll(res) + } + + _appendAll(res: GetList.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.list = res.data.list + this.items = this.items.concat( + res.data.items.map(item => ({...item, _reactKey: item.subject})), + ) + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index a0f75493..74a75d80 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, filterAccountLabels, @@ -70,6 +71,9 @@ export class PostThreadItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index dddf488a..9d8378f7 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AtUri, ComAtprotoLabelDefs, + AppBskyGraphDefs, AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, @@ -18,10 +19,9 @@ import { filterProfileLabels, } from 'lib/labeling/helpers' -export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' - export class ProfileViewerModel { muted?: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic following?: string followedBy?: string blockedBy?: boolean diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 3ffd10b9..73424f03 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -111,6 +111,7 @@ export class NotificationsFeedItemModel { addedInfo?.profileLabels || [], ), isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, + mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, isBlocking: !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, isBlockedBy: diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 44cec3af..b2dffdc6 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -24,6 +24,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, getPostModeration, @@ -105,6 +106,9 @@ export class PostsFeedItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts new file mode 100644 index 00000000..309ab0e0 --- /dev/null +++ b/src/state/models/lists/lists-list.ts @@ -0,0 +1,214 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetLists as GetLists, + AppBskyGraphGetListMutes as GetListMutes, + AppBskyGraphDefs as GraphDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class ListsListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + lists: GraphDefs.ListView[] = [] + + constructor( + public rootStore: RootStoreModel, + public source: 'my-modlists' | string, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.lists.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + let res + if (this.source === 'my-modlists') { + res = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + const [res1, res2] = await Promise.all([ + fetchAllUserLists(this.rootStore, this.rootStore.me.did), + fetchAllMyMuteLists(this.rootStore), + ]) + for (let list of res1.data.lists) { + if (list.purpose === 'app.bsky.graph.defs#modlist') { + res.data.lists.push(list) + } + } + for (let list of res2.data.lists) { + if ( + list.purpose === 'app.bsky.graph.defs#modlist' && + !res.data.lists.find(l => l.uri === list.uri) + ) { + res.data.lists.push(list) + } + } + } else { + res = await this.rootStore.agent.app.bsky.graph.getLists({ + actor: this.source, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + } + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user lists', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user lists', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetLists.Response | GetListMutes.Response) { + this.lists = [] + this._appendAll(res) + } + + _appendAll(res: GetLists.Response | GetListMutes.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.lists = this.lists.concat( + res.data.lists.map(list => ({...list, _reactKey: list.uri})), + ) + } +} + +async function fetchAllUserLists( + store: RootStoreModel, + did: string, +): Promise { + let acc: GetLists.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getLists({ + actor: did, + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} + +async function fetchAllMyMuteLists( + store: RootStoreModel, +): Promise { + let acc: GetListMutes.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getListMutes({ + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index d06a196f..861b3df0 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,13 +2,19 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' +import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', + Lists = 'Lists', } -const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies] +const USER_SELECTOR_ITEMS = [ + Sections.Posts, + Sections.PostsWithReplies, + Sections.Lists, +] export interface ProfileUiParams { user: string @@ -22,6 +28,7 @@ export class ProfileUiModel { // data profile: ProfileModel feed: PostsFeedModel + lists: ListsListModel // ui state selectedViewIndex = 0 @@ -43,14 +50,17 @@ export class ProfileUiModel { actor: params.user, limit: 10, }) + this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel { + get currentView(): PostsFeedModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies ) { return this.feed + } else if (this.selectedView === Sections.Lists) { + return this.lists } throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) } @@ -100,6 +110,12 @@ export class ProfileUiModel { } else if (this.feed.isEmpty) { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } + } else if (this.selectedView === Sections.Lists) { + if (this.lists.hasContent) { + arr = this.lists.lists + } else if (this.lists.isEmpty) { + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) + } } else { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } @@ -113,6 +129,8 @@ export class ProfileUiModel { this.selectedView === Sections.PostsWithReplies ) { return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading + } else if (this.selectedView === Sections.Lists) { + return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading } return false } @@ -133,6 +151,11 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + // HACK: need to use the DID as a param, not the username -prf + this.lists.source = this.profile.did + this.lists + .loadMore() + .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) } async update() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 67f8e16d..9b9a176b 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' +import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' export interface ConfirmModal { @@ -38,6 +39,19 @@ export interface ReportAccountModal { did: string } +export interface CreateOrEditMuteListModal { + name: 'create-or-edit-mute-list' + list?: ListModel + onSave?: (uri: string) => void +} + +export interface ListAddRemoveUserModal { + name: 'list-add-remove-user' + subject: string + displayName: string + onUpdate?: () => void +} + export interface EditImageModal { name: 'edit-image' image: ImageModel @@ -102,9 +116,11 @@ export type Modal = | ContentFilteringSettingsModal | ContentLanguagesSettingsModal - // Reporting + // Moderation | ReportAccountModal | ReportPostModal + | CreateMuteListModal + | ListAddRemoveUserModal // Posts | AltTextImageModal diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx new file mode 100644 index 00000000..7cbdaaf6 --- /dev/null +++ b/src/view/com/lists/ListCard.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' + +export const ListCard = ({ + testID, + list, + noBg, + noBorder, + renderButton, +}: { + testID?: string + list: AppBskyGraphDefs.ListView + noBg?: boolean + noBorder?: boolean + renderButton?: () => JSX.Element +}) => { + const pal = usePalette('default') + const store = useStores() + + const rkey = React.useMemo(() => { + try { + const urip = new AtUri(list.uri) + return urip.rkey + } catch { + return '' + } + }, [list]) + + const descriptionRichText = React.useMemo(() => { + if (list.description) { + return new RichText({ + text: list.description, + facets: list.descriptionFacets, + }) + } + return undefined + }, [list]) + + return ( + + + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + + {!!list.viewer?.muted && ( + + + + Subscribed + + + + )} + + {renderButton ? ( + {renderButton()} + ) : undefined} + + {descriptionRichText ? ( + + + + ) : undefined} + + ) +} + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + paddingHorizontal: 6, + }, + outerNoBorder: { + borderTopWidth: 0, + }, + layout: { + flexDirection: 'row', + alignItems: 'center', + }, + layoutAvi: { + width: 54, + paddingLeft: 4, + paddingTop: 8, + paddingBottom: 10, + }, + avi: { + width: 40, + height: 40, + borderRadius: 20, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, + layoutButton: { + paddingRight: 10, + }, + details: { + paddingLeft: 54, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + btn: { + paddingVertical: 7, + borderRadius: 50, + marginLeft: 6, + paddingHorizontal: 14, + }, +}) diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx new file mode 100644 index 00000000..52b728cb --- /dev/null +++ b/src/view/com/lists/ListItems.tsx @@ -0,0 +1,387 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {FlatList} from '../util/Views' +import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {ProfileCard} from '../profile/ProfileCard' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {TextLink} from '../util/Link' +import {ListModel} from 'state/models/content/list' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' + +const LOADING_ITEM = {_reactKey: '__loading__'} +const HEADER_ITEM = {_reactKey: '__header__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export const ListItems = observer( + ({ + list, + style, + scrollElRef, + onPressTryAgain, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + renderEmptyState, + testID, + headerOffset = 0, + }: { + list: ListModel + style?: StyleProp + scrollElRef?: MutableRefObject | null> + onPressTryAgain?: () => void + onToggleSubscribed?: () => void + onPressEditList?: () => void + onPressDeleteList?: () => void + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + }) => { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let items: any[] = [HEADER_ITEM] + if (list.hasLoaded) { + if (list.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (list.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + items = items.concat(list.items) + } + if (list.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + } else if (list.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await list.refresh() + } catch (err) { + list.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [list, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await list.loadMore() + } catch (err) { + list.rootStore.log.error('Failed to load more lists', err) + } + }, [list, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) + + const onPressEditMembership = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + store.shell.openModal({ + name: 'list-add-remove-user', + subject: profile.did, + displayName: profile.displayName || profile.handle, + onUpdate() { + list.refresh() + }, + }) + }, + [store, list], + ) + + // rendering + // = + + const renderMemberButton = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + if (!list.isOwner) { + return null + } + return ( + + + ) +} + +const styles = StyleSheet.create({ + createNewContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + paddingTop: 18, + paddingBottom: 16, + }, + createNewButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 30b46556..5db0ef5a 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -21,8 +21,8 @@ export function Component({}: {}) { }, [store]) return ( - - Content Moderation + + Content Filtering void + list?: ListModel +}) { + const store = useStores() + const [error, setError] = useState('') + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + + const [isProcessing, setProcessing] = useState(false) + const [name, setName] = useState(list?.list.name || '') + const [description, setDescription] = useState( + list?.list.description || '', + ) + const [avatar, setAvatar] = useState(list?.list.avatar) + const [newAvatar, setNewAvatar] = useState() + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onSelectNewAvatar = useCallback( + async (img: RNImage | null) => { + if (!img) { + setNewAvatar(null) + setAvatar(null) + return + } + track('CreateMuteList:AvatarSelected') + try { + const finalImg = await compressIfNeeded(img, 1000000) + setNewAvatar(finalImg) + setAvatar(finalImg.path) + } catch (e: any) { + setError(cleanError(e)) + } + }, + [track, setNewAvatar, setAvatar, setError], + ) + + const onPressSave = useCallback(async () => { + track('CreateMuteList:Save') + const nameTrimmed = name.trim() + if (!nameTrimmed) { + setError('Name is required') + return + } + setProcessing(true) + if (error) { + setError('') + } + try { + if (list) { + await list.updateMetadata({ + name: nameTrimmed, + description: description.trim(), + avatar: newAvatar, + }) + Toast.show('Mute list updated') + onSave?.(list.uri) + } else { + const res = await ListModel.createModList(store, { + name, + description, + avatar: newAvatar, + }) + Toast.show('Mute list created') + onSave?.(res.uri) + } + store.shell.closeModal() + } catch (e: any) { + if (isNetworkError(e)) { + setError( + 'Failed to create the mute list. Check your internet connection and try again.', + ) + } else { + setError(cleanError(e)) + } + } + setProcessing(false) + }, [ + track, + setProcessing, + setError, + error, + onSave, + store, + name, + description, + newAvatar, + list, + ]) + + return ( + + + + {list ? 'Edit Mute List' : 'New Mute List'} + + {error !== '' && ( + + + + )} + List Avatar + + + + + + List Name + setName(enforceLen(v, MAX_NAME))} + accessible={true} + accessibilityLabel="Name" + accessibilityHint="Set the list's name" + /> + + + Description + setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="Edit your list's description" + /> + + {isProcessing ? ( + + + + ) : ( + + + Save + + + )} + + + Cancel + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: isDesktopWeb ? 0 : 16, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 18, + }, + label: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 4, + marginTop: 20, + }, + form: { + paddingHorizontal: 6, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 12, + paddingTop: 10, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 10, + marginBottom: 10, + }, + avi: { + width: 84, + height: 84, + borderWidth: 2, + borderRadius: 42, + marginTop: 4, + }, + errorContainer: {marginTop: 20}, +}) diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx new file mode 100644 index 00000000..a2775df9 --- /dev/null +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -0,0 +1,255 @@ +import React, {useCallback} from 'react' +import {observer} from 'mobx-react-lite' +import {Pressable, StyleSheet, View} from 'react-native' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {UserAvatar} from '../util/UserAvatar' +import {ListsList} from '../lists/ListsList' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListMembershipModel} from 'state/models/content/list-membership' +import {EmptyStateWithButton} from '../util/EmptyStateWithButton' +import {Button} from '../util/forms/Button' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb, isAndroid} from 'platform/detection' + +export const snapPoints = ['fullscreen'] + +export const Component = observer( + ({ + subject, + displayName, + onUpdate, + }: { + subject: string + displayName: string + onUpdate?: () => void + }) => { + const store = useStores() + const pal = usePalette('default') + const palPrimary = usePalette('primary') + const palInverted = usePalette('inverted') + const [selected, setSelected] = React.useState([]) + + const listsList: ListsListModel = React.useMemo( + () => new ListsListModel(store, store.me.did), + [store], + ) + const memberships: ListMembershipModel = React.useMemo( + () => new ListMembershipModel(store, subject), + [store, subject], + ) + React.useEffect(() => { + listsList.refresh() + memberships.fetch().then( + () => { + setSelected(memberships.memberships.map(m => m.value.list)) + }, + err => { + store.log.error('Failed to fetch memberships', {err}) + }, + ) + }, [memberships, listsList, store, setSelected]) + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onPressSave = useCallback(async () => { + try { + await memberships.updateTo(selected) + } catch (err) { + store.log.error('Failed to update memberships', {err}) + return + } + Toast.show('Lists updated') + onUpdate?.() + store.shell.closeModal() + }, [store, selected, memberships, onUpdate]) + + const onPressNewMuteList = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (_uri: string) => { + listsList.refresh() + }, + }) + }, [store, listsList]) + + const onToggleSelected = useCallback( + (uri: string) => { + if (selected.includes(uri)) { + setSelected(selected.filter(uri2 => uri2 !== uri)) + } else { + setSelected([...selected, uri]) + } + }, + [selected, setSelected], + ) + + const renderItem = useCallback( + (list: GraphDefs.ListView) => { + const isSelected = selected.includes(list.uri) + return ( + onToggleSelected(list.uri)}> + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '} + by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + + + + {isSelected && ( + + )} + + + ) + }, + [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], + ) + + const renderEmptyState = React.useCallback(() => { + return ( + + ) + }, [onPressNewMuteList]) + + return ( + + Add {displayName} to lists + + + + + + ) +} +const styles = StyleSheet.create({ + container: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + iconContainer: { + marginBottom: 16, + }, + icon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + btns: { + flexDirection: 'row', + justifyContent: 'center', + }, + btn: { + gap: 10, + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 30, + }, + notice: { + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + marginHorizontal: 30, + }, +}) diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 7f5b5b7c..97802394 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({ canGoBack, hideOnScroll, showOnDesktop, + renderButton, }: { title: string canGoBack?: boolean hideOnScroll?: boolean showOnDesktop?: boolean + renderButton?: () => JSX.Element }) { const pal = usePalette('default') const store = useStores() @@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({ if (isDesktopWeb) { if (showOnDesktop) { - return + return } return null } else { @@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({ {title} - + {renderButton ? ( + renderButton() + ) : ( + + )} ) } }) -function DesktopWebHeader({title}: {title: string}) { +function DesktopWebHeader({ + title, + renderButton, +}: { + title: string + renderButton?: () => JSX.Element +}) { const pal = usePalette('default') return ( @@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) { {title} + {renderButton?.()} ) } diff --git a/src/view/index.ts b/src/view/index.ts index dd8a585d..b8a13f7f 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -38,6 +38,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' +import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' +import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' @@ -46,6 +48,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage' import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' +import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' @@ -67,8 +70,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' +import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' +import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' @@ -116,6 +121,8 @@ export function setup() { farEyeSlash, faGear, faGlobe, + faHand, + farHand, faHeart, fasHeart, faHouse, @@ -124,6 +131,7 @@ export function setup() { faInfo, faLanguage, faLink, + faListUl, faLock, faMagnifyingGlass, faMessage, @@ -145,8 +153,10 @@ export function setup() { faUser, faUsers, faUserCheck, + faUserSlash, faUserPlus, faUserXmark, + faUsersSlash, faTicket, faTrashCan, faX, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 18e4f250..0ead6b65 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired( setSelectedPage(index) store.shell.setIsDrawerSwipeDisabled(index > 0) }, - [store], + [store, setSelectedPage], ) const onPressSelected = React.useCallback(() => { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx new file mode 100644 index 00000000..29ef8b4b --- /dev/null +++ b/src/view/screens/Moderation.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {CenteredView} from '../com/util/Views' +import {ViewHeader} from '../com/util/ViewHeader' +import {Link} from '../com/util/Link' +import {Text} from '../com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps +export const ModerationScreen = withAuthRequired( + observer(function Moderation({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {screen, track} = useAnalytics() + + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + store.shell.setMinimalShellMode(false) + }, [screen, store]), + ) + + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + store.shell.openModal({name: 'content-filtering-settings'}) + }, [track, store]) + + return ( + + + + + + + + + Content filtering + + + + + + + + Mute lists + + + + + + + + Muted accounts + + + + + + + + Blocked accounts + + + + ) + }), +) + +const styles = StyleSheet.create({ + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + spacer: { + height: 6, + }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 1, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + borderRadius: 30, + marginRight: 12, + }, +}) diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx similarity index 96% rename from src/view/screens/BlockedAccounts.tsx rename to src/view/screens/ModerationBlockedAccounts.tsx index 19506851..cd506d63 100644 --- a/src/view/screens/BlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' -type Props = NativeStackScreenProps -export const BlockedAccounts = withAuthRequired( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationBlockedAccounts' +> +export const ModerationBlockedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx new file mode 100644 index 00000000..0b81f432 --- /dev/null +++ b/src/view/screens/ModerationMuteLists.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {CenteredView} from 'view/com/util/Views' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMuteLists' +> +export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation() + + const mutelists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-modlists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + mutelists.refresh() + }, [store, mutelists]), + ) + + const onPressNewMuteList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + const renderEmptyState = React.useCallback(() => { + return ( + + ) + }, [onPressNewMuteList]) + + const renderHeaderButton = React.useCallback( + () => ( + + ), + [onPressNewMuteList, pal], + ) + + return ( + + + + + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + createBtn: { + width: 40, + }, +}) diff --git a/src/view/screens/MutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx similarity index 96% rename from src/view/screens/MutedAccounts.tsx rename to src/view/screens/ModerationMutedAccounts.tsx index f7120051..ec732f68 100644 --- a/src/view/screens/MutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' -type Props = NativeStackScreenProps -export const MutedAccounts = withAuthRequired( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMutedAccounts' +> +export const ModerationMutedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5fb21255..d2397485 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel} from 'state/models/ui/profile' +import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {useStores} from 'state/index' import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' -import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' +import {ListCard} from 'view/com/lists/ListCard' +import { + PostFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from '../com/util/LoadingPlaceholder' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' @@ -111,52 +115,81 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { - if (item === ProfileUiModel.END_ITEM) { - return - end of feed - - } else if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { + if (uiState.selectedView === Sections.Lists) { + if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else { + return } - if (uiState.feed.isBlockedBy) { + } else { + if (item === ProfileUiModel.END_ITEM) { + return - end of feed - + } else if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + + ) + } + if (uiState.feed.isBlockedBy) { + return ( + + ) + } + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else if (item instanceof PostsFeedSliceModel) { + return ( + + ) } - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else if (item instanceof PostsFeedSliceModel) { - return } return }, [ onPressTryAgain, + uiState.selectedView, uiState.profile.did, uiState.feed.isBlocking, uiState.feed.isBlockedBy, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx new file mode 100644 index 00000000..a78faaf6 --- /dev/null +++ b/src/view/screens/ProfileList.tsx @@ -0,0 +1,175 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {useNavigation} from '@react-navigation/native' +import {observer} from 'mobx-react-lite' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {ListItems} from 'view/com/lists/ListItems' +import {EmptyState} from 'view/com/util/EmptyState' +import {Button} from 'view/com/util/forms/Button' +import * as Toast from 'view/com/util/Toast' +import {ListModel} from 'state/models/content/list' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps +export const ProfileListScreen = withAuthRequired( + observer(({route}: Props) => { + const store = useStores() + const navigation = useNavigation() + const pal = usePalette('default') + const {name, rkey} = route.params + + const list: ListModel = React.useMemo(() => { + const model = new ListModel( + store, + `at://${name}/app.bsky.graph.list/${rkey}`, + ) + return model + }, [store, name, rkey]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + list.loadMore(true) + }, [store, list]), + ) + + const onToggleSubscribed = React.useCallback(async () => { + try { + if (list.list?.viewer?.muted) { + await list.unsubscribe() + } else { + await list.subscribe() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your subscription, please check your internet connection and try again.', + ) + store.log.error('Failed up update subscription', {err}) + } + }, [store, list]) + + const onPressEditList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDeleteList = React.useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const renderEmptyState = React.useCallback(() => { + return + }, []) + + const renderHeaderBtn = React.useCallback(() => { + return ( + + {list?.isOwner && ( +