diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "sentry-expo": "~7.0.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", + "use-deep-compare": "^1.1.0", "zeego": "^1.6.2", "zod": "^3.20.2" }, @@ -236,6 +237,9 @@ "json", "node" ], + "transform": { + "\\.[jt]sx?$": "babel-jest" + }, "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)" ], diff --git a/yarn.lock b/yarn.lock --- a/yarn.lock +++ b/yarn.lock @@ -8202,6 +8202,11 @@ invariant "*" prop-types "*" +dequal@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.0.tgz#41c6065e70de738541c82cdbedea5292277a017e" + integrity sha512-/Nd1EQbQbI9UbSHrMiKZjFLrXSnU328iQdZKPQf78XQI6C+gutkFUeoHpG5J08Ioa6HeRbRNFpSIclh1xyG0mw== + dequal@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.1.tgz#dbbf9795ec626e9da8bd68782f4add1d23700d8b" @@ -18339,6 +18344,13 @@ integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w== dependencies: tslib "^2.0.0" + +use-deep-compare@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/use-deep-compare/-/use-deep-compare-1.1.0.tgz#85580dde751f68400bf6ef7e043c7f986595cef8" + integrity sha512-6yY3zmKNCJ1jjIivfZMZMReZjr8e6iC6Uqtp701jvWJ6ejC/usXD+JjmslZDPJQgX8P4B1Oi5XSLHkOLeYSJsA== + dependencies: + dequal "1.0.0" use-latest-callback@^0.1.5: version "0.1.6" diff --git a/src/Navigation.tsx b/src/Navigation.tsx --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -43,16 +43,17 @@ import {SearchScreen} from './view/screens/Search' import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' +import {ListsScreen} from './view/screens/Lists' import {ModerationScreen} from './view/screens/Moderation' -import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' +import {ModerationModlistsScreen} from './view/screens/ModerationModlists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {LanguageSettingsScreen} from './view/screens/LanguageSettings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' -import {CustomFeedScreen} from './view/screens/CustomFeed' -import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy' +import {ProfileFeedScreen} from './view/screens/ProfileFeed' +import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' @@ -96,14 +97,19 @@ options={{title: title('Not Found')}} /> + ModerationScreen} options={{title: title('Moderation')}} /> ModerationMuteListsScreen} - options={{title: title('Mute Lists')}} + name="ModerationModlists" + getComponent={() => ModerationModlistsScreen} + options={{title: title('Moderation Lists')}} /> ProfileListScreen} - options={{title: title('Mute List')}} + options={{title: title('List')}} /> ({title: title(`Post by @${route.params.name}`)})} /> CustomFeedScreen} + name="ProfileFeed" + getComponent={() => ProfileFeedScreen} options={{title: title('Feed')}} /> CustomFeedLikedByScreen} + name="ProfileFeedLikedBy" + getComponent={() => ProfileFeedLikedByScreen} options={{title: title('Liked by')}} /> { + beforeAll(async () => { + await createServer('?users&follows&posts') + await openApp({ + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, + }) + }) + + it('Login and create a curatelists', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await loginAsAlice() + await element(by.id('e2eGotoLists')).tap() + await element(by.id('newUserListBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('editNameInput')).typeText('Good Ppl') + await element(by.id('editDescriptionInput')).typeText('They good') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await element(by.text('About')).tap() + await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They good') + }) + + it('Edit display name and description via the edit curatelist modal', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).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('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('headerTitle'))).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('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove description via the edit curatelist modal', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('listDescription'))).not.toBeVisible() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Set avi via the edit curatelist modal', async () => { + await expect(element(by.id('userAvatarFallback'))).toExist() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.text('Library')).tap() + await sleep(3e3) + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarImage'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove avi via the edit curatelist modal', async () => { + await expect(element(by.id('userAvatarImage'))).toExist() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.text('Remove')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarFallback'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Delete the curatelist', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Delete List')).tap() + await element(by.id('confirmBtn')).tap() + await expect(element(by.id('listsEmpty'))).toBeVisible() + }) + + it('Create a new curatelist', async () => { + await element(by.id('newUserListBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('editNameInput')).typeText('Good Ppl') + await element(by.id('editDescriptionInput')).typeText('They good') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await element(by.text('About')).tap() + await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They good') + }) + + it('Adds users on curatelists from the list', async () => { + await element(by.text('About')).tap() + await element(by.id('addUserBtn')).tap() + await expect(element(by.id('listAddUserModal'))).toBeVisible() + await waitFor(element(by.id('user-bob.test-addBtn'))) + .toBeVisible() + .withTimeout(5000) + await element(by.id('user-bob.test-addBtn')).tap() + await element(by.id('doneBtn')).tap() + await expect(element(by.id('listAddUserModal'))).not.toBeVisible() + await expect(element(by.id('user-bob.test'))).toBeVisible() + }) + + it('Shows posts by the users in the list', async () => { + await element(by.text('Posts')).tap() + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() + }) + + it('Pins the list', async () => { + await element(by.id('pinBtn')).tap() + await element(by.id('e2eGotoHome')).tap() + await element(by.id('homeScreenFeedTabs-Good Ppl')).tap() + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() + + await element(by.id('bottomBarFeedsBtn')).tap() + await element(by.id('saved-feed-Good Ppl')).tap() + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() + + await element(by.id('unpinBtn')).tap() + await element(by.id('bottomBarHomeBtn')).tap() + await expect( + element(by.id('homeScreenFeedTabs-Good Ppl')), + ).not.toBeVisible() + + await element(by.id('e2eGotoLists')).tap() + await element(by.id('list-Good Ppl')).tap() + }) + + it('Removes users on curatelists from the list', async () => { + await element(by.text('About')).tap() + await expect(element(by.id('user-bob.test'))).toBeVisible() + await element(by.id('user-bob.test-editBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Good Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + }) + + it('Shows the curatelist on my profile', async () => { + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('selector')).swipe('left') + await element(by.id('selector-4')).tap() + await element(by.id('list-Good Ppl')).tap() + }) + + it('Adds and removes users on curatelists from the profile', 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.text('Add to Lists')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Good Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.text('Add to Lists')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Good Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + }) + + it('Can report a user list', async () => { + await element(by.id('e2eGotoSettings')).tap() + await element(by.id('signOutBtn')).tap() + await loginAsBob() + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('alice') + await element(by.id('searchAutoCompleteResult-alice.test')).tap() + await element(by.id('selector')).swipe('left') + await element(by.id('selector-3')).tap() + await element(by.id('list-Good Ppl')).tap() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Report List')).tap() + await expect(element(by.id('reportModal'))).toBeVisible() + await expect(element(by.text('Report List'))).toBeVisible() + await element( + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), + ).tap() + await element(by.id('sendReportBtn')).tap() + await expect(element(by.id('reportModal'))).not.toBeVisible() + }) +}) diff --git a/__e2e__/tests/mod-lists.test.ts b/__e2e__/tests/mod-lists.test.ts new file mode 100644 --- /dev/null +++ b/__e2e__/tests/mod-lists.test.ts @@ -0,0 +1,187 @@ +/* eslint-env detox/detox */ + +import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' + +describe('Mod lists', () => { + beforeAll(async () => { + await createServer('?users&follows&labels') + await openApp({ + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, + }) + }) + + it('Login and view my modlists', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await loginAsAlice() + await element(by.id('e2eGotoModeration')).tap() + await element(by.id('moderationlistsBtn')).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 mute subscription', async () => { + await element(by.id('unmuteBtn')).tap() + await element(by.id('subscribeBtn')).tap() + await element(by.text('Mute accounts')).tap() + await element(by.id('confirmBtn')).tap() + }) + + it('Edit display name and description via the edit modlist modal', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).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('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('headerTitle'))).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('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove description via the edit modlist modal', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('listDescription'))).not.toBeVisible() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Set avi via the edit modlist modal', async () => { + await expect(element(by.id('userAvatarFallback'))).toExist() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.text('Library')).tap() + await sleep(3e3) + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarImage'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove avi via the edit modlist modal', async () => { + await expect(element(by.id('userAvatarImage'))).toExist() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Edit List Details')).tap() + await expect(element(by.id('createOrEditListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.text('Remove')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarFallback'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('headerDropdownBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Delete the modlist', async () => { + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Delete List')).tap() + await element(by.id('confirmBtn')).tap() + await expect(element(by.id('listsEmpty'))).toBeVisible() + }) + + it('Create a new modlist', async () => { + await element(by.id('newModListBtn')).tap() + await expect(element(by.id('createOrEditListModal'))).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('createOrEditListModal'))).not.toBeVisible() + await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They bad') + }) + + it('Adds and removes users on modlists from the list', async () => { + await element(by.id('addUserBtn')).tap() + await expect(element(by.id('listAddUserModal'))).toBeVisible() + await waitFor(element(by.id('user-warn-posts.test-addBtn'))) + .toBeVisible() + .withTimeout(5000) + await element(by.id('user-warn-posts.test-addBtn')).tap() + await element(by.id('doneBtn')).tap() + await expect(element(by.id('listAddUserModal'))).not.toBeVisible() + await element(by.id('listItems-flatlist')).swipe( + 'down', + 'slow', + 1, + 0.5, + 0.5, + ) + await expect(element(by.id('user-warn-posts.test'))).toBeVisible() + await element(by.id('user-warn-posts.test-editBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + }) + + it('Shows the modlist on my profile', async () => { + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('selector')).swipe('left') + await element(by.id('selector-4')).tap() + await element(by.id('list-Bad Ppl')).tap() + }) + + it('Adds and removes users on modlists from the profile', 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.text('Add to Lists')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.text('Add to Lists')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() + }) + + it('Can report a mute list', async () => { + await element(by.id('e2eGotoSettings')).tap() + await element(by.id('signOutBtn')).tap() + await loginAsBob() + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('alice') + await element(by.id('searchAutoCompleteResult-alice.test')).tap() + await element(by.id('selector')).swipe('left') + await element(by.id('selector-3')).tap() + await element(by.id('list-Bad Ppl')).tap() + await element(by.id('headerDropdownBtn')).tap() + await element(by.text('Report List')).tap() + await expect(element(by.id('reportModal'))).toBeVisible() + await expect(element(by.text('Report List'))).toBeVisible() + await element( + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), + ).tap() + await element(by.id('sendReportBtn')).tap() + await expect(element(by.id('reportModal'))).not.toBeVisible() + }) +}) diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts deleted file mode 100644 --- a/__e2e__/tests/mute-lists.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-env detox/detox */ - -import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' - -describe('Mute lists', () => { - beforeAll(async () => { - 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 loginAsAlice() - await element(by.id('e2eGotoModeration')).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.text('Library')).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.text('Remove')).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')).swipe('left') - await element(by.id('selector-4')).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.text('Add to Lists')).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.text('Add to Lists')).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() - }) - - it('Can report a mute list', async () => { - await element(by.id('e2eGotoSettings')).tap() - await element(by.id('signOutBtn')).tap() - await loginAsBob() - await element(by.id('bottomBarSearchBtn')).tap() - await element(by.id('searchTextInput')).typeText('alice') - await element(by.id('searchAutoCompleteResult-alice.test')).tap() - await element(by.id('selector')).swipe('left') - await element(by.id('selector-3')).tap() - await element(by.id('list-Bad Ppl')).tap() - await element(by.id('reportListBtn')).tap() - await expect(element(by.id('reportModal'))).toBeVisible() - await expect(element(by.text('Report List'))).toBeVisible() - await element( - by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), - ).tap() - await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportModal'))).not.toBeVisible() - }) -}) diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -947,3 +947,30 @@ ) } + +export function ListIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -17,9 +17,18 @@ } } if (cause.type === 'blocking') { - return { - name: 'User Blocked', - description: 'You have blocked this user. You cannot view their content.', + if (cause.source.type === 'list') { + return { + name: `User Blocked by "${cause.source.list.name}"`, + description: + 'You have blocked this user. You cannot view their content.', + } + } else { + return { + name: 'User Blocked', + description: + 'You have blocked this user. You cannot view their content.', + } } } if (cause.type === 'blocked-by') { diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -181,8 +181,9 @@ e.GET("/search", server.WebGeneric) e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) + e.GET("/lists", server.WebGeneric) e.GET("/moderation", server.WebGeneric) - e.GET("/moderation/mute-lists", server.WebGeneric) + e.GET("/moderation/modlists", server.WebGeneric) e.GET("/moderation/muted-accounts", server.WebGeneric) e.GET("/moderation/blocked-accounts", server.WebGeneric) e.GET("/settings", server.WebGeneric) diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -97,10 +97,13 @@ // LISTS events 'Lists:onRefresh': {} 'Lists:onEndReached': {} - 'CreateMuteList:AvatarSelected': {} - 'CreateMuteList:Save': {} // CAN BE SERVER - 'Lists:Subscribe': {} // CAN BE SERVER - 'Lists:Unsubscribe': {} // CAN BE SERVER + 'CreateList:AvatarSelected': {} + 'CreateList:SaveCurateList': {} // CAN BE SERVER + 'CreateList:SaveModList': {} // CAN BE SERVER + 'Lists:Mute': {} // CAN BE SERVER + 'Lists:Unmute': {} // CAN BE SERVER + 'Lists:Block': {} // CAN BE SERVER + 'Lists:Unblock': {} // CAN BE SERVER // CUSTOM FEED events 'CustomFeed:Save': {} 'CustomFeed:Unsave': {} diff --git a/src/lib/async/accumulate.ts b/src/lib/async/accumulate.ts new file mode 100644 --- /dev/null +++ b/src/lib/async/accumulate.ts @@ -0,0 +1,25 @@ +export interface AccumulateResponse { + cursor?: string + items: T[] +} + +export type AccumulateFetchFn = ( + cursor: string | undefined, +) => Promise> + +export async function accumulate( + fn: AccumulateFetchFn, + pageLimit = 100, +): Promise { + let cursor: string | undefined + let acc: T[] = [] + for (let i = 0; i < pageLimit; i++) { + const res = await fn(cursor) + cursor = res.cursor + acc = acc.concat(res.items) + if (!cursor) { + break + } + } + return acc +} diff --git a/src/lib/async/until.ts b/src/lib/async/until.ts new file mode 100644 --- /dev/null +++ b/src/lib/async/until.ts @@ -0,0 +1,24 @@ +import {timeout} from './timeout' + +export async function until( + retries: number, + delay: number, + cond: (v: any, err: any) => boolean, + fn: () => Promise, +): Promise { + while (retries > 0) { + try { + const v = await fn() + if (cond(v, undefined)) { + return true + } + } catch (e: any) { + if (cond(undefined, e)) { + return true + } + } + await timeout(delay) + retries-- + } + return false +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts --- a/src/lib/hooks/useCustomFeed.ts +++ b/src/lib/hooks/useCustomFeed.ts @@ -1,24 +1,15 @@ import {useEffect, useState} from 'react' import {useStores} from 'state/index' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' -export function useCustomFeed(uri: string): CustomFeedModel | undefined { +export function useCustomFeed(uri: string): FeedSourceModel | undefined { const store = useStores() - const [item, setItem] = useState() + const [item, setItem] = useState() useEffect(() => { - async function fetchView() { - const res = await store.agent.app.bsky.feed.getFeedGenerator({ - feed: uri, - }) - const view = res.data.view - return view - } async function buildFeedItem() { - const view = await fetchView() - if (view) { - const temp = new CustomFeedModel(store, view) - setItem(temp) - } + const model = new FeedSourceModel(store, uri) + await model.setup() + setItem(model) } buildFeedItem() }, [store, uri]) diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts new file mode 100644 --- /dev/null +++ b/src/lib/hooks/useDesktopRightNavItems.ts @@ -0,0 +1,51 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {AtUri} from '@atproto/api' +import {FeedSourceModel} from 'state/models/content/feed-source' + +interface RightNavItem { + uri: string + href: string + hostname: string + collection: string + rkey: string + displayName: string +} + +export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { + const store = useStores() + const [items, setItems] = useState([]) + const [lastUris, setLastUris] = useState([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setItems( + models.map(model => { + const {hostname, collection, rkey} = new AtUri(model.uri) + return { + uri: model.uri, + href: model.href, + hostname, + collection, + rkey, + displayName: model.displayName, + } + }), + ) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setItems]) + + return items +} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts new file mode 100644 --- /dev/null +++ b/src/lib/hooks/useHomeTabs.ts @@ -0,0 +1,29 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {FeedSourceModel} from 'state/models/content/feed-source' + +export function useHomeTabs(uris: string[]): string[] { + const store = useStores() + const [tabs, setTabs] = useState(['Following']) + const [lastUris, setLastUris] = useState([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setTabs(['Following'].concat(models.map(f => f.displayName))) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setTabs]) + + return tabs +} diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -13,3 +13,15 @@ ...segments, ].join('/') } + +export function makeCustomFeedLink( + did: string, + rkey: string, + ...segments: string[] +) { + return [`/profile`, did, 'feed', rkey, ...segments].join('/') +} + +export function makeListLink(did: string, rkey: string, ...segments: string[]) { + return [`/profile`, did, 'lists', rkey, ...segments].join('/') +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,8 +5,9 @@ export type CommonNavigatorParams = { NotFound: undefined + Lists: undefined Moderation: undefined - ModerationMuteLists: undefined + ModerationModlists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined Settings: undefined @@ -18,8 +19,8 @@ PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} - CustomFeed: {name: string; rkey: string} - CustomFeedLikedBy: {name: string; rkey: string} + ProfileFeed: {name: string; rkey: string} + ProfileFeedLikedBy: {name: string; rkey: string} Debug: undefined Log: undefined Support: undefined diff --git a/src/state/models/me.ts b/src/state/models/me.ts --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,7 +8,6 @@ import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' -import {SavedFeedsModel} from './ui/saved-feeds' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -22,7 +21,6 @@ followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel - savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -45,7 +43,6 @@ }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) - this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx deleted file mode 100644 --- a/src/view/screens/CustomFeed.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import React, {useMemo, useRef} from 'react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation, useIsFocused} from '@react-navigation/native' -import {usePalette} from 'lib/hooks/usePalette' -import {HeartIcon, HeartIconSolid} from 'lib/icons' -import {CommonNavigatorParams} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' -import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useCustomFeed} from 'lib/hooks/useCustomFeed' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Feed} from 'view/com/posts/Feed' -import {TextLink} from 'view/com/util/Link' -import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' -import {Button} from 'view/com/util/forms/Button' -import {Text} from 'view/com/util/text/Text' -import * as Toast from 'view/com/util/Toast' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useSetTitle} from 'lib/hooks/useSetTitle' -import {shareUrl} from 'lib/sharing' -import {toShareUrl} from 'lib/strings/url-helpers' -import {Haptics} from 'lib/haptics' -import {ComposeIcon2} from 'lib/icons' -import {FAB} from '../com/util/fab/FAB' -import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {EmptyState} from 'view/com/util/EmptyState' -import {useAnalytics} from 'lib/analytics/analytics' -import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {resolveName} from 'lib/api' -import {CenteredView} from 'view/com/util/Views' -import {NavigationProp} from 'lib/routes/types' - -type Props = NativeStackScreenProps - -export const CustomFeedScreen = withAuthRequired( - observer(function CustomFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation() - - const {name: handleOrDid} = props.route.params - - const [feedOwnerDid, setFeedOwnerDid] = React.useState() - const [error, setError] = React.useState() - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - - - - Could not load feed - - - {error} - - - - - - - - ) - } - - return feedOwnerDid ? ( - - ) : ( - - - - - - ) - }), -) - -export const CustomFeedScreenInner = observer( - function CustomFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const store = useStores() - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const navigation = useNavigation() - const isScreenFocused = useIsFocused() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {track} = useAnalytics() - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - const scrollElRef = useRef(null) - const currentFeed = useCustomFeed(uri) - const algoFeed: PostsFeedModel = useMemo(() => { - const feed = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - feed.setup() - return feed - }, [store, uri]) - const isPinned = store.me.savedFeeds.isPinned(uri) - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) - useSetTitle(currentFeed?.displayName) - - const onToggleSaved = React.useCallback(async () => { - try { - Haptics.default() - if (currentFeed?.isSaved) { - await currentFeed?.unsave() - } else { - await currentFeed?.save() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', - ) - store.log.error('Failed up update feeds', {err}) - } - }, [store, currentFeed]) - - const onToggleLiked = React.useCallback(async () => { - Haptics.default() - try { - if (currentFeed?.isLiked) { - await currentFeed?.unlike() - } else { - await currentFeed?.like() - } - } catch (err) { - Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', - ) - store.log.error('Failed up toggle like', {err}) - } - }, [store, currentFeed]) - - const onTogglePinned = React.useCallback(async () => { - Haptics.default() - store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { - Toast.show('There was an issue contacting the server') - store.log.error('Failed to toggle pinned feed', {e}) - }) - }, [store, currentFeed]) - - const onPressAbout = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: currentFeed?.displayName || '', - message: - currentFeed?.data.description || 'This feed has no description.', - confirmBtnText: 'Close', - onPressConfirm() {}, - }) - }, [store, currentFeed]) - - const onPressViewAuthor = React.useCallback(() => { - navigation.navigate('Profile', {name: handleOrDid}) - }, [handleOrDid, navigation]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!currentFeed) return - store.shell.openModal({ - name: 'report', - uri: currentFeed.uri, - cid: currentFeed.data.cid, - }) - }, [store, currentFeed]) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) - - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - - const onSoftReset = React.useCallback(() => { - if (isScreenFocused) { - onScrollToTop() - algoFeed.refresh() - } - }, [isScreenFocused, onScrollToTop, algoFeed]) - - // fires when page within screen is activated/deactivated - React.useEffect(() => { - if (!isScreenFocused) { - return - } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - return () => { - softResetSub.remove() - } - }, [store, onSoftReset, isScreenFocused]) - - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - currentFeed - ? { - testID: 'feedHeaderDropdownAboutBtn', - label: 'About this feed', - onPress: onPressAbout, - icon: { - ios: { - name: 'info.circle', - }, - android: '', - web: 'info', - }, - } - : undefined, - { - testID: 'feedHeaderDropdownViewAuthorBtn', - label: 'View author', - onPress: onPressViewAuthor, - icon: { - ios: { - name: 'person', - }, - android: '', - web: ['far', 'user'], - }, - }, - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: currentFeed?.isSaved - ? 'Remove from my feeds' - : 'Add to my feeds', - onPress: onToggleSaved, - icon: currentFeed?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: 'trash', - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - currentFeed, - onPressAbout, - onToggleSaved, - onPressReport, - onPressShare, - onPressViewAuthor, - ]) - - const renderEmptyState = React.useCallback(() => { - return ( - - - - ) - }, [pal.border]) - - return ( - - - - {currentFeed ? ( - store.emitScreenSoftReset()} - /> - ) : ( - 'Loading...' - )} - - {currentFeed ? ( - <> - - {currentFeed?.isSaved ? ( - - ) : ( - - )} - - ) : null} - - - - - - - - {isScrolledDown ? ( - - ) : null} - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - - ) - }, -) - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - gap: 12, - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 16, - borderTopWidth: 1, - }, - headerText: { - flex: 1, - fontWeight: 'bold', - }, - headerBtn: { - paddingVertical: 0, - }, - headerAddBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 4, - paddingLeft: 10, - }, - liked: { - color: colors.red3, - }, - top1: { - position: 'relative', - top: 1, - }, - top2: { - position: 'relative', - top: 2, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, -}) diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/CustomFeedLikedBy.tsx deleted file mode 100644 --- a/src/view/screens/CustomFeedLikedBy.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {useStores} from 'state/index' -import {makeRecordUri} from 'lib/strings/url-helpers' - -type Props = NativeStackScreenProps -export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { - const store = useStores() - const {name, rkey} = route.params - const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) - - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), - ) - - return ( - - - - - ) -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -2,7 +2,6 @@ import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {AtUri} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' @@ -24,9 +23,10 @@ import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' +import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' type Props = NativeStackScreenProps export const FeedsScreen = withAuthRequired( @@ -52,6 +52,10 @@ } }, [store, myFeeds]), ) + React.useEffect(() => { + // watch for changes to saved/pinned feeds + return myFeeds.registerListeners() + }, [myFeeds]) const onPressCompose = React.useCallback(() => { store.shell.openComposer({}) @@ -139,13 +143,7 @@ ) } else if (item.type === 'saved-feed') { - return ( - - ) + return } else if (item.type === 'discover-feeds-header') { return ( <> @@ -187,7 +185,7 @@ ) } else if (item.type === 'discover-feed') { return ( - - - - {displayName} - + {feed.error ? ( + + + + ) : ( + + )} + + + {feed.displayName} + + {feed.error && ( + + + Feed offline + + + )} + {isMobile && ( ([]) React.useEffect(() => { - const {pinned} = store.me.savedFeeds + const pinned = store.preferences.pinnedFeeds - if ( - isEqual( - pinned.map(p => p.uri), - requestedCustomFeeds, - ) - ) { + if (isEqual(pinned, requestedCustomFeeds)) { // no changes return } const feeds = [] - for (const feed of pinned) { - const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) - feeds.push(model) + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + const model = new PostsFeedModel(store, 'custom', {feed: uri}) + feeds.push(model) + } else if (uri.includes('app.bsky.graph.list')) { + const model = new PostsFeedModel(store, 'list', {list: uri}) + feeds.push(model) + } } pagerRef.current?.setPage(0) setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned.map(p => p.uri)) + setRequestedCustomFeeds(pinned) }, [ store, - store.me.savedFeeds.pinned, + store.preferences.pinnedFeeds, customFeeds, setCustomFeeds, pagerRef, @@ -124,7 +123,7 @@ {customFeeds.map((f, index) => { return ( +export const ListsScreen = withAuthRequired( + observer(function ListsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation() + + const listsLists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-curatelists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + listsLists.refresh() + }, [store, listsLists]), + ) + + const onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + + + + + User Lists + + + Public, shareable lists which can drive feeds. + + + + + + + + + ) + }), +) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -66,9 +66,9 @@ + href="/moderation/modlists"> - Mute lists + Moderation lists +export const ModerationModlistsScreen = withAuthRequired( + observer(function ModerationModlistsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + 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 onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + + + + + Moderation Lists + + + Public, shareable lists of users to mute or block in bulk. + + + + + + + + + ) + }), +) diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx deleted file mode 100644 --- a/src/view/screens/ModerationMuteLists.tsx +++ /dev/null @@ -1,124 +0,0 @@ -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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {CenteredView} from 'view/com/util/Views' -import {ViewHeader} from 'view/com/util/ViewHeader' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'ModerationMuteLists' -> -export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { - const pal = usePalette('default') - const store = useStores() - const {isTabletOrDesktop} = useWebMediaQueries() - 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: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, - }, - createBtn: { - width: 40, - }, -}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -25,8 +25,8 @@ import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {CustomFeed} from 'view/com/feeds/CustomFeed' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -189,9 +189,14 @@ style={styles.emptyState} /> ) - } else if (item instanceof CustomFeedModel) { + } else if (item instanceof FeedSourceModel) { return ( - + ) } // if section is posts or posts & replies diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx new file mode 100644 --- /dev/null +++ b/src/view/screens/ProfileFeed.tsx @@ -0,0 +1,535 @@ +import React, {useMemo, useCallback} from 'react' +import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useNavigation} from '@react-navigation/native' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIcon, HeartIconSolid} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CommonNavigatorParams} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {FeedSourceModel} from 'state/models/content/feed-source' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {TextLink} from 'view/com/util/Link' +import {Button} from 'view/com/util/forms/Button' +import {Text} from 'view/com/util/text/Text' +import {RichText} from 'view/com/util/text/RichText' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {EmptyState} from 'view/com/util/EmptyState' +import * as Toast from 'view/com/util/Toast' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {Haptics} from 'lib/haptics' +import {useAnalytics} from 'lib/analytics/analytics' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' +import {resolveName} from 'lib/api' +import {makeCustomFeedLink} from 'lib/routes/links' +import {pluralize} from 'lib/strings/helpers' +import {CenteredView, ScrollView} from 'view/com/util/Views' +import {NavigationProp} from 'lib/routes/types' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' + +const SECTION_TITLES = ['Posts', 'About'] + +interface SectionRef { + scrollToTop: () => void +} + +type Props = NativeStackScreenProps +export const ProfileFeedScreen = withAuthRequired( + observer(function ProfileFeedScreenImpl(props: Props) { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation() + + const {name: handleOrDid} = props.route.params + + const [feedOwnerDid, setFeedOwnerDid] = React.useState() + const [error, setError] = React.useState() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the feed owner before we can fetch the feed. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setFeedOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setFeedOwnerDid]) + + if (error) { + return ( + + + + Could not load feed + + + {error} + + + + + + + + ) + } + + return feedOwnerDid ? ( + + ) : ( + + + + + + ) + }), +) + +export const ProfileFeedScreenInner = observer( + function ProfileFeedScreenInnerImpl({ + route, + feedOwnerDid, + }: Props & {feedOwnerDid: string}) { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const feedSectionRef = React.useRef(null) + const {rkey, name: handleOrDid} = route.params + const uri = useMemo( + () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), + [rkey, feedOwnerDid], + ) + const feedInfo = useCustomFeed(uri) + const feed: PostsFeedModel = useMemo(() => { + const model = new PostsFeedModel(store, 'custom', { + feed: uri, + }) + model.setup() + return model + }, [store, uri]) + const isPinned = store.preferences.isPinnedFeed(uri) + useSetTitle(feedInfo?.displayName) + + // events + // = + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (feedInfo?.isSaved) { + await feedInfo?.unsave() + } else { + await feedInfo?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, feedInfo]) + + const onToggleLiked = React.useCallback(async () => { + Haptics.default() + try { + if (feedInfo?.isLiked) { + await feedInfo?.unlike() + } else { + await feedInfo?.like() + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + store.log.error('Failed up toggle like', {err}) + } + }, [store, feedInfo]) + + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + if (feedInfo) { + feedInfo.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + } + }, [store, feedInfo]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) + shareUrl(url) + track('CustomFeed:Share') + }, [handleOrDid, rkey, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + store.shell.openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [store, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', + onPress: onToggleSaved, + icon: feedInfo?.isSaved + ? { + ios: { + name: 'trash', + }, + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', + }, + }, + { + testID: 'feedHeaderDropdownReportBtn', + label: 'Report feed', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', + }, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: 'ic_menu_share', + web: 'share', + }, + }, + ] as DropdownItem[] + }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) + + const renderHeader = useCallback(() => { + return ( + + {feedInfo && ( + <> + + {typeof feedInfo.likeCount === 'number' && ( + + )} + + + Created by{' '} + {feedInfo.isOwner ? ( + 'you' + ) : ( + + )} + + + ) +}) + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + liked: { + color: colors.red3, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, +}) diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx new file mode 100644 --- /dev/null +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from '../com/util/ViewHeader' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' + +type Props = NativeStackScreenProps +export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { + const store = useStores() + const {name, rkey} = route.params + const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) + + return ( + + + + + ) +}) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -1,166 +1,802 @@ -import React from 'react' -import {StyleSheet} from 'react-native' +import React, {useCallback, useMemo} from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' +import {RichText as RichTextAPI} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {Text} from 'view/com/util/text/Text' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {CenteredView} from 'view/com/util/Views' -import {ListItems} from 'view/com/lists/ListItems' import {EmptyState} from 'view/com/util/EmptyState' +import {RichText} from 'view/com/util/text/RichText' +import {Button} from 'view/com/util/forms/Button' +import {TextLink} from 'view/com/util/Link' import * as Toast from 'view/com/util/Toast' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {Haptics} from 'lib/haptics' import {ListModel} from 'state/models/content/list' +import {PostsFeedModel} from 'state/models/feeds/posts' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {ListActions} from 'view/com/lists/ListActions' +import {resolveName} from 'lib/api' import {s} from 'lib/styles' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink, makeListLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' +import {ListItems} from 'view/com/lists/ListItems' + +const SECTION_TITLES_CURATE = ['Posts', 'About'] +const SECTION_TITLES_MOD = ['About'] + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl({route}: Props) { + observer(function ProfileListScreenImpl(props: Props) { + const pal = usePalette('default') const store = useStores() const navigation = useNavigation() - const {isTabletOrDesktop} = useWebMediaQueries() - 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]) - useSetTitle(list.list?.name) + const {name: handleOrDid} = props.route.params - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - list.loadMore(true) - }, [store, list]), - ) + const [listOwnerDid, setListOwnerDid] = React.useState() + const [error, setError] = React.useState() - 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}) + const onPressBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') } - }, [store, list]) + }, [navigation]) - const onPressEditList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - list, - onSave() { - list.refresh() - }, - }) - }, [store, list]) + React.useEffect(() => { + /* + * We must resolve the DID of the list owner before we can fetch the list. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setListOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, + ) + } + } - 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]) + fetchDid() + }, [store, handleOrDid, setListOwnerDid]) - const onPressReportList = React.useCallback(() => { - if (!list.list) return - store.shell.openModal({ - name: 'report', - uri: list.uri, - cid: list.list.cid, - }) - }, [store, list]) - - const onPressShareList = React.useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) - shareUrl(url) - }, [list.creatorDid, rkey]) - - const renderEmptyState = React.useCallback(() => { - return - }, []) - - const renderHeaderBtns = React.useCallback(() => { + if (error) { return ( - - ) - }, [ - list.isOwner, - list.list?.viewer?.muted, - onPressDeleteList, - onPressEditList, - onPressShareList, - onToggleSubscribed, - onPressReportList, - ]) + + + + Could not load list + + + {error} + - return ( - - - + + + + + + ) + } + + return listOwnerDid ? ( + + ) : ( + + + + ) }), ) -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, +export const ProfileListScreenInner = observer( + function ProfileListScreenInnerImpl({ + route, + listOwnerDid, + }: Props & {listOwnerDid: string}) { + const store = useStores() + const {rkey} = route.params + const feedSectionRef = React.useRef(null) + const aboutSectionRef = React.useRef(null) + + const list: ListModel = useMemo(() => { + const model = new ListModel( + store, + `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, + ) + return model + }, [store, listOwnerDid, rkey]) + const feed = useMemo( + () => new PostsFeedModel(store, 'list', {list: list.uri}), + [store, list], + ) + useSetTitle(list.data?.name) + + useFocusEffect( + useCallback(() => { + store.shell.setMinimalShellMode(false) + list.loadMore(true).then(() => { + if (list.isCuratelist) { + feed.setup() + } + }) + }, [store, list, feed]), + ) + + const onPressAddUser = useCallback(() => { + store.shell.openModal({ + name: 'list-add-user', + list, + onAdd() { + if (list.isCuratelist) { + feed.refresh() + } + }, + }) + }, [store, list, feed]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + const renderHeader = useCallback(() => { + return
+ }, [rkey, list]) + + if (list.isCuratelist) { + return ( + + + {({onScroll, headerHeight, isScrolledDown}) => ( + + )} + {({onScroll, headerHeight, isScrolledDown}) => ( + + )} + + store.shell.openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + + ) + } + if (list.isModlist) { + return ( + + + {({onScroll, headerHeight, isScrolledDown}) => ( + + )} + + store.shell.openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + + ) + } + return
}, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, +) + +const Header = observer(function HeaderImpl({ + rkey, + list, +}: { + rkey: string + list: ListModel +}) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation() + + const onTogglePinned = useCallback(async () => { + Haptics.default() + list.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned list', {e}) + }) + }, [store, list]) + + const onSubscribeMute = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Mute these accounts?', + message: + 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + confirmBtnText: 'Mute this List', + async onPressConfirm() { + try { + await list.mute() + Toast.show('List muted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeMute = useCallback(async () => { + try { + await list.unmute() + Toast.show('List unmuted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onSubscribeBlock = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Block these accounts?', + message: + 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + confirmBtnText: 'Block this List', + async onPressConfirm() { + try { + await list.block() + Toast.show('List blocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeBlock = useCallback(async () => { + try { + await list.unblock() + Toast.show('List unblocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onPressEdit = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDelete = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + Toast.show('List deleted') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const onPressReport = useCallback(() => { + if (!list.data) return + store.shell.openModal({ + name: 'report', + uri: list.uri, + cid: list.data.cid, + }) + }, [store, list]) + + const onPressShare = useCallback(() => { + const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + shareUrl(url) + }, [list.creatorDid, rkey]) + + const dropdownItems: DropdownItem[] = useMemo(() => { + if (!list.hasLoaded) { + return [] + } + let items: DropdownItem[] = [ + { + testID: 'listHeaderDropdownShareBtn', + label: 'Share', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: '', + web: 'share', + }, + }, + ] + if (list.isOwner) { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownEditBtn', + label: 'Edit List Details', + onPress: onPressEdit, + icon: { + ios: { + name: 'pencil', + }, + android: '', + web: 'pen', + }, + }) + items.push({ + testID: 'listHeaderDropdownDeleteBtn', + label: 'Delete List', + onPress: onPressDelete, + icon: { + ios: { + name: 'trash', + }, + android: '', + web: ['far', 'trash-can'], + }, + }) + } else { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownReportBtn', + label: 'Report List', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: '', + web: 'circle-exclamation', + }, + }) + } + return items + }, [ + list.hasLoaded, + list.isOwner, + onPressShare, + onPressEdit, + onPressDelete, + onPressReport, + ]) + + const subscribeDropdownItems: DropdownItem[] = useMemo(() => { + return [ + { + testID: 'subscribeDropdownMuteBtn', + label: 'Mute accounts', + onPress: onSubscribeMute, + icon: { + ios: { + name: 'speaker.slash', + }, + android: '', + web: 'user-slash', + }, + }, + { + testID: 'subscribeDropdownBlockBtn', + label: 'Block accounts', + onPress: onSubscribeBlock, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: '', + web: 'ban', + }, + }, + ] + }, [onSubscribeMute, onSubscribeBlock]) + + return ( + + {list.isCuratelist ? ( + - ), - , - !isOwner && ( - - ), - ] - - // If reversed is true, reverse the array to reverse the order of the buttons - if (reversed) { - buttons = buttons.filter(Boolean).reverse() // filterting out any falsey values and reversing the array - } else { - buttons = buttons.filter(Boolean) // filterting out any falsey values - } - - return {buttons} -} - -const styles = StyleSheet.create({ - headerBtns: { - flexDirection: 'row', - gap: 8, - marginTop: 12, - }, -}) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -76,7 +76,10 @@ {sanitizeDisplayName(list.name)} - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && + 'Moderation list '} + by{' '} {list.creator.did === store.me.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -3,34 +3,26 @@ ActivityIndicator, RefreshControl, StyleProp, - StyleSheet, View, ViewStyle, - FlatList, } from 'react-native' -import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' +import {FlatList} from '../util/Views' import {observer} from 'mobx-react-lite' 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/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useStores} from 'state/index' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' -import {ListActions} from './ListActions' -import {makeProfileLink} from 'lib/routes/links' -import {sanitizeHandle} from 'lib/strings/handles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 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__'} @@ -39,36 +31,35 @@ list, style, scrollElRef, + onScroll, onPressTryAgain, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, + renderHeader, renderEmptyState, testID, + scrollEventThrottle, headerOffset = 0, + desktopFixedHeightOffset, }: { list: ListModel style?: StyleProp scrollElRef?: MutableRefObject | null> + onScroll?: OnScrollCb onPressTryAgain?: () => void - onToggleSubscribed: () => void - onPressEditList: () => void - onPressDeleteList: () => void - onPressShareList: () => void - onPressReportList: () => void - renderEmptyState?: () => JSX.Element + renderHeader: () => JSX.Element + renderEmptyState: () => JSX.Element testID?: string + scrollEventThrottle?: number headerOffset?: number + desktopFixedHeightOffset?: number }) { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const {isMobile} = useWebMediaQueries() const data = React.useMemo(() => { - let items: any[] = [HEADER_ITEM] + let items: any[] = [] if (list.hasLoaded) { if (list.hasError) { items = items.concat([ERROR_ITEM]) @@ -124,11 +115,18 @@ const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { store.shell.openModal({ - name: 'list-add-remove-user', + name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onUpdate() { - list.refresh() + onAdd(listUri: string) { + if (listUri === list.uri) { + list.cacheAddMember(profile) + } + }, + onRemove(listUri: string) { + if (listUri === list.uri) { + list.cacheRemoveMember(profile) + } }, }) }, @@ -145,6 +143,7 @@ } 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}, item: { paddingHorizontal: 18, + paddingVertical: 4, }, }) diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx new file mode 100644 --- /dev/null +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -0,0 +1,311 @@ +import React, {useState, useCallback, useMemo} from 'react' +import * as Toast from '../util/Toast' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {Text} from '../util/text/Text' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {useStores} from 'state/index' +import {ListModel} from 'state/models/content/list' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {compressIfNeeded} from 'lib/media/manip' +import {EditableUserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useAnalytics} from 'lib/analytics/analytics' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError, isNetworkError} from 'lib/strings/errors' + +const MAX_NAME = 64 // todo +const MAX_DESCRIPTION = 300 // todo + +export const snapPoints = ['fullscreen'] + +export function Component({ + purpose, + onSave, + list, +}: { + purpose?: string + onSave?: (uri: string) => void + list?: ListModel +}) { + const store = useStores() + const {isMobile} = useWebMediaQueries() + const [error, setError] = useState('') + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + + const activePurpose = useMemo(() => { + if (list?.data?.purpose) { + return list.data.purpose + } + if (purpose) { + return purpose + } + return 'app.bsky.graph.defs#curatelist' + }, [list, purpose]) + const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' + const purposeLabel = isCurateList ? 'User' : 'Moderation' + + const [isProcessing, setProcessing] = useState(false) + const [name, setName] = useState(list?.data?.name || '') + const [description, setDescription] = useState( + list?.data?.description || '', + ) + const [avatar, setAvatar] = useState(list?.data?.avatar) + const [newAvatar, setNewAvatar] = useState() + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onSelectNewAvatar = useCallback( + async (img: RNImage | null) => { + if (!img) { + setNewAvatar(null) + setAvatar(undefined) + return + } + track('CreateList: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 () => { + if (isCurateList) { + track('CreateList:SaveCurateList') + } else { + track('CreateList:SaveModList') + } + 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(`${purposeLabel} list updated`) + onSave?.(list.uri) + } else { + const res = await ListModel.createList(store, { + purpose: activePurpose, + name, + description, + avatar: newAvatar, + }) + Toast.show(`${purposeLabel} list created`) + onSave?.(res.uri) + } + store.shell.closeModal() + } catch (e: any) { + if (isNetworkError(e)) { + setError( + 'Failed to create the list. Check your internet connection and try again.', + ) + } else { + setError(cleanError(e)) + } + } + setProcessing(false) + }, [ + track, + setProcessing, + setError, + error, + onSave, + store, + activePurpose, + isCurateList, + purposeLabel, + name, + description, + newAvatar, + list, + ]) + + return ( + + + + {list ? 'Edit' : 'New'} {purposeLabel} List + + {error !== '' && ( + + + + )} + List Avatar + + + + + + + List Name + + setName(enforceLen(v, MAX_NAME))} + accessible={true} + accessibilityLabel="Name" + accessibilityHint="" + accessibilityLabelledBy="list-name" + /> + + + + Description + + setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="" + accessibilityLabelledBy="list-description" + /> + + {isProcessing ? ( + + + + ) : ( + + + Save + + + )} + + + Cancel + + + + + + ) +} + +const styles = StyleSheet.create({ + 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/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx deleted file mode 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React, {useState, useCallback} from 'react' -import * as Toast from '../util/Toast' -import { - ActivityIndicator, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {Text} from '../util/text/Text' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' -import {s, colors, gradients} from 'lib/styles' -import {enforceLen} from 'lib/strings/helpers' -import {compressIfNeeded} from 'lib/media/manip' -import {EditableUserAvatar} from '../util/UserAvatar' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {cleanError, isNetworkError} from 'lib/strings/errors' - -const MAX_NAME = 64 // todo -const MAX_DESCRIPTION = 300 // todo - -export const snapPoints = ['fullscreen'] - -export function Component({ - onSave, - list, -}: { - onSave?: (uri: string) => void - list?: ListModel -}) { - const store = useStores() - const {isMobile} = useWebMediaQueries() - 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(undefined) - 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="" - accessibilityLabelledBy="list-name" - /> - - - - Description - - setDescription(enforceLen(v, MAX_DESCRIPTION))} - accessible={true} - accessibilityLabel="Description" - accessibilityHint="" - accessibilityLabelledBy="list-description" - /> - - {isProcessing ? ( - - - - ) : ( - - - Save - - - )} - - - Cancel - - - - - - ) -} - -const styles = StyleSheet.create({ - 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 deleted file mode 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import React, {useCallback} from 'react' -import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View, ActivityIndicator} 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 {sanitizeHandle} from 'lib/strings/handles' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid} from 'platform/detection' -import isEqual from 'lodash.isequal' - -export const snapPoints = ['fullscreen'] - -export const Component = observer(function ListAddRemoveUserImpl({ - 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 [originalSelections, setOriginalSelections] = React.useState( - [], - ) - const [selected, setSelected] = React.useState([]) - const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) - - 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( - () => { - const ids = memberships.memberships.map(m => m.value.list) - setOriginalSelections(ids) - setSelected(ids) - setMembershipsLoaded(true) - }, - err => { - store.log.error('Failed to fetch memberships', {err}) - }, - ) - }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) - - 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' - : sanitizeHandle(list.creator.handle, '@')} - - - {membershipsLoaded && ( - - {isSelected && ( - - )} - - )} - - ) - }, - [ - pal, - palPrimary, - palInverted, - onToggleSelected, - selected, - store.me.did, - membershipsLoaded, - ], - ) - - const renderEmptyState = React.useCallback(() => { - return ( - - ) - }, [onPressNewMuteList]) - - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection - const canSaveChanges = - !listsList.isEmpty && !isEqual(selected, originalSelections) - - return ( - - Add {displayName} to Lists - - -