diff --git a/__e2e__/tests/curate-lists.test.ts b/__e2e__/tests/curate-lists.test.ts
new file mode 100644
index 00000000..b7c6c25c
--- /dev/null
+++ b/__e2e__/tests/curate-lists.test.ts
@@ -0,0 +1,208 @@
+/* eslint-env detox/detox */
+
+import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
+
+describe('Curate lists', () => {
+ 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/mute-lists.test.ts b/__e2e__/tests/mod-lists.test.ts
similarity index 52%
rename from __e2e__/tests/mute-lists.test.ts
rename to __e2e__/tests/mod-lists.test.ts
index 6c46de0e..2b33aaed 100644
--- a/__e2e__/tests/mute-lists.test.ts
+++ b/__e2e__/tests/mod-lists.test.ts
@@ -2,7 +2,7 @@
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
-describe('Mute lists', () => {
+describe('Mod lists', () => {
beforeAll(async () => {
await createServer('?users&follows&labels')
await openApp({
@@ -10,11 +10,11 @@ describe('Mute lists', () => {
})
})
- it('Login and view my mutelists', async () => {
+ 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('mutelistsBtn')).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(
@@ -22,101 +22,128 @@ describe('Mute lists', () => {
).toBeVisible()
})
- it('Toggle subscription', async () => {
- await element(by.id('unsubscribeListBtn')).tap()
- await element(by.id('subscribeListBtn')).tap()
+ 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 mutelist modal', async () => {
- await element(by.id('editListBtn')).tap()
- await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+ 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('createOrEditMuteListModal'))).not.toBeVisible()
- await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
+ 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('editListBtn')))
+ await waitFor(element(by.id('headerDropdownBtn')))
.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()
+ 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('createOrEditMuteListModal'))).not.toBeVisible()
+ 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('editListBtn')))
+ await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
- it('Set avi via the edit mutelist modal', async () => {
+ it('Set avi via the edit modlist 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('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('createOrEditMuteListModal'))).not.toBeVisible()
+ 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('editListBtn')))
+ await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
- it('Remove avi via the edit mutelist modal', async () => {
+ it('Remove avi via the edit modlist 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('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('createOrEditMuteListModal'))).not.toBeVisible()
+ 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('editListBtn')))
+ await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
- it('Delete the mutelist', async () => {
- await element(by.id('deleteListBtn')).tap()
+ 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('emptyMuteLists'))).toBeVisible()
+ await expect(element(by.id('listsEmpty'))).toBeVisible()
})
- it('Create a new mutelist', async () => {
- await element(by.id('emptyMuteLists-button')).tap()
- await expect(element(by.id('createOrEditMuteListModal'))).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('createOrEditMuteListModal'))).not.toBeVisible()
- await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
+ 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('editListBtn')))
- .toBeVisible()
- .withTimeout(5000)
})
- it('Shows the mutelist on my profile', async () => {
+ 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 mutelists', async () => {
+ 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()
@@ -124,17 +151,17 @@ describe('Mute lists', () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
- await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
+ 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('listAddRemoveUserModal'))).not.toBeVisible()
+ 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('listAddRemoveUserModal'))).toBeVisible()
+ 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('listAddRemoveUserModal'))).not.toBeVisible()
+ await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Can report a mute list', async () => {
@@ -147,7 +174,8 @@ describe('Mute lists', () => {
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 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(
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 5be96ce0..46848a82 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -181,8 +181,9 @@ func serve(cctx *cli.Context) error {
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/package.json b/package.json
index 66f7cfbb..a5194a52 100644
--- 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/src/Navigation.tsx b/src/Navigation.tsx
index e481d966..49a2c429 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -43,16 +43,17 @@ import {HomeScreen} from './view/screens/Home'
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'
@@ -95,15 +96,20 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => NotFoundScreen}
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')}}
/>
{
+ const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+ ...this.params,
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+ ...this.params,
+ cursor: this.cursor,
+ limit,
+ })
+ if (res.success) {
+ this.cursor = res.data.cursor
+ return {
+ cursor: res.data.cursor,
+ feed: res.data.feed,
+ }
+ }
+ return {
+ feed: [],
+ }
+ }
+}
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index 31e27fec..e0fbcecd 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -114,13 +114,8 @@ export class MergeFeedAPI implements FeedAPI {
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
- this.rootStore.me.savedFeeds.all.map(
- feed =>
- new MergeFeedSource_Custom(
- this.rootStore,
- feed.uri,
- feed.displayName,
- ),
+ this.rootStore.preferences.savedFeeds.map(
+ feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
),
)
}
@@ -213,43 +208,56 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
- constructor(
- public rootStore: RootStoreModel,
- public feedUri: string,
- public feedDisplayName: string,
- ) {
+ constructor(public rootStore: RootStoreModel, public feedUri: string) {
super(rootStore)
this.sourceInfo = {
- displayName: feedDisplayName,
+ displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
+ this.rootStore.agent.app.bsky.feed
+ .getFeedGenerator({
+ feed: feedUri,
+ })
+ .then(
+ res => {
+ if (this.sourceInfo) {
+ this.sourceInfo.displayName = res.data.view.displayName
+ }
+ },
+ _err => {},
+ )
}
protected async _getFeed(
cursor: string | undefined,
limit: number,
): Promise {
- const res = await this.rootStore.agent.app.bsky.feed.getFeed({
- cursor,
- limit,
- feed: this.feedUri,
- })
- // NOTE
- // some custom feeds fail to enforce the pagination limit
- // so we manually truncate here
- // -prf
- if (limit && res.data.feed.length > limit) {
- res.data.feed = res.data.feed.slice(0, limit)
+ try {
+ const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+ cursor,
+ limit,
+ feed: this.feedUri,
+ })
+ // NOTE
+ // some custom feeds fail to enforce the pagination limit
+ // so we manually truncate here
+ // -prf
+ if (limit && res.data.feed.length > limit) {
+ res.data.feed = res.data.feed.slice(0, limit)
+ }
+ // filter out older posts
+ res.data.feed = res.data.feed.filter(
+ post => new Date(post.post.indexedAt) > this.minDate,
+ )
+ // attach source info
+ for (const post of res.data.feed) {
+ post.__source = this.sourceInfo
+ }
+ return res
+ } catch {
+ // dont bubble custom-feed errors
+ return {success: false, headers: {}, data: {feed: []}}
}
- // filter out older posts
- res.data.feed = res.data.feed.filter(
- post => new Date(post.post.indexedAt) > this.minDate,
- )
- // attach source info
- for (const post of res.data.feed) {
- post.__source = this.sourceInfo
- }
- return res
}
}
diff --git a/src/lib/async/accumulate.ts b/src/lib/async/accumulate.ts
new file mode 100644
index 00000000..99226418
--- /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
index 00000000..db53c921
--- /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
index d7a27050..04201b9a 100644
--- 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
index 00000000..f27efd28
--- /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
index 00000000..69183e62
--- /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/icons.tsx b/src/lib/icons.tsx
index fef7be2f..7ae88806 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -947,3 +947,30 @@ export function ShieldExclamation({
)
}
+
+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
index aadee0e7..6c08606e 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -17,9 +17,18 @@ export function describeModerationCause(
}
}
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/src/lib/routes/links.ts b/src/lib/routes/links.ts
index cc543b6b..397a5916 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -13,3 +13,15 @@ export function makeProfileLink(
...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
index 35a379d4..c157c0ab 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
export type CommonNavigatorParams = {
NotFound: undefined
+ Lists: undefined
Moderation: undefined
- ModerationMuteLists: undefined
+ ModerationModlists: undefined
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
Settings: undefined
@@ -18,8 +19,8 @@ export type CommonNavigatorParams = {
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/routes.ts b/src/routes.ts
index 7049d60f..bb242198 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -7,8 +7,9 @@ export const router = new Router({
Notifications: '/notifications',
Settings: '/settings',
LanguageSettings: '/settings/language',
+ Lists: '/lists',
Moderation: '/moderation',
- ModerationMuteLists: '/moderation/mute-lists',
+ ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts',
Profile: '/profile/:name',
@@ -18,8 +19,8 @@ export const router = new Router({
PostThread: '/profile/:name/post/:rkey',
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
- CustomFeed: '/profile/:name/feed/:rkey',
- CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
+ ProfileFeed: '/profile/:name/feed/:rkey',
+ ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
Debug: '/sys/debug',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
new file mode 100644
index 00000000..8dac9b56
--- /dev/null
+++ b/src/state/models/content/feed-source.ts
@@ -0,0 +1,223 @@
+import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {RootStoreModel} from 'state/models/root-store'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {track} from 'lib/analytics/analytics'
+
+export class FeedSourceModel {
+ // state
+ _reactKey: string
+ hasLoaded = false
+ error: string | undefined
+
+ // data
+ uri: string
+ cid: string = ''
+ type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
+ avatar: string | undefined = ''
+ displayName: string = ''
+ descriptionRT: RichText | null = null
+ creatorDid: string = ''
+ creatorHandle: string = ''
+ likeCount: number | undefined = 0
+ likeUri: string | undefined = ''
+
+ constructor(public rootStore: RootStoreModel, uri: string) {
+ this._reactKey = uri
+ this.uri = uri
+
+ try {
+ const urip = new AtUri(uri)
+ if (urip.collection === 'app.bsky.feed.generator') {
+ this.type = 'feed-generator'
+ } else if (urip.collection === 'app.bsky.graph.list') {
+ this.type = 'list'
+ }
+ } catch {}
+ this.displayName = uri.split('/').pop() || ''
+
+ makeAutoObservable(
+ this,
+ {
+ rootStore: false,
+ },
+ {autoBind: true},
+ )
+ }
+
+ get href() {
+ const urip = new AtUri(this.uri)
+ const collection =
+ urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+ return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+ }
+
+ get isSaved() {
+ return this.rootStore.preferences.savedFeeds.includes(this.uri)
+ }
+
+ get isPinned() {
+ return this.rootStore.preferences.isPinnedFeed(this.uri)
+ }
+
+ get isLiked() {
+ return !!this.likeUri
+ }
+
+ get isOwner() {
+ return this.creatorDid === this.rootStore.me.did
+ }
+
+ setup = bundleAsync(async () => {
+ try {
+ if (this.type === 'feed-generator') {
+ const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+ feed: this.uri,
+ })
+ this.hydrateFeedGenerator(res.data.view)
+ } else if (this.type === 'list') {
+ const res = await this.rootStore.agent.app.bsky.graph.getList({
+ list: this.uri,
+ limit: 1,
+ })
+ this.hydrateList(res.data.list)
+ }
+ } catch (e) {
+ runInAction(() => {
+ this.error = cleanError(e)
+ })
+ }
+ })
+
+ hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
+ this.uri = view.uri
+ this.cid = view.cid
+ this.avatar = view.avatar
+ this.displayName = view.displayName
+ ? sanitizeDisplayName(view.displayName)
+ : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
+ this.descriptionRT = new RichText({
+ text: view.description || '',
+ facets: (view.descriptionFacets || [])?.slice(),
+ })
+ this.creatorDid = view.creator.did
+ this.creatorHandle = view.creator.handle
+ this.likeCount = view.likeCount
+ this.likeUri = view.viewer?.like
+ this.hasLoaded = true
+ }
+
+ hydrateList(view: AppBskyGraphDefs.ListView) {
+ this.uri = view.uri
+ this.cid = view.cid
+ this.avatar = view.avatar
+ this.displayName = view.name
+ ? sanitizeDisplayName(view.name)
+ : `User List by ${sanitizeHandle(view.creator.handle, '@')}`
+ this.descriptionRT = new RichText({
+ text: view.description || '',
+ facets: (view.descriptionFacets || [])?.slice(),
+ })
+ this.creatorDid = view.creator.did
+ this.creatorHandle = view.creator.handle
+ this.likeCount = undefined
+ this.hasLoaded = true
+ }
+
+ async save() {
+ if (this.type !== 'feed-generator') {
+ return
+ }
+ try {
+ await this.rootStore.preferences.addSavedFeed(this.uri)
+ } catch (error) {
+ this.rootStore.log.error('Failed to save feed', error)
+ } finally {
+ track('CustomFeed:Save')
+ }
+ }
+
+ async unsave() {
+ if (this.type !== 'feed-generator') {
+ return
+ }
+ try {
+ await this.rootStore.preferences.removeSavedFeed(this.uri)
+ } catch (error) {
+ this.rootStore.log.error('Failed to unsave feed', error)
+ } finally {
+ track('CustomFeed:Unsave')
+ }
+ }
+
+ async pin() {
+ try {
+ await this.rootStore.preferences.addPinnedFeed(this.uri)
+ } catch (error) {
+ this.rootStore.log.error('Failed to pin feed', error)
+ } finally {
+ track('CustomFeed:Pin', {
+ name: this.displayName,
+ uri: this.uri,
+ })
+ }
+ }
+
+ async togglePin() {
+ if (!this.isPinned) {
+ track('CustomFeed:Pin', {
+ name: this.displayName,
+ uri: this.uri,
+ })
+ return this.rootStore.preferences.addPinnedFeed(this.uri)
+ } else {
+ track('CustomFeed:Unpin', {
+ name: this.displayName,
+ uri: this.uri,
+ })
+ return this.rootStore.preferences.removePinnedFeed(this.uri)
+ }
+ }
+
+ async like() {
+ if (this.type !== 'feed-generator') {
+ return
+ }
+ try {
+ this.likeUri = 'pending'
+ this.likeCount = (this.likeCount || 0) + 1
+ const res = await this.rootStore.agent.like(this.uri, this.cid)
+ this.likeUri = res.uri
+ } catch (e: any) {
+ this.likeUri = undefined
+ this.likeCount = (this.likeCount || 1) - 1
+ this.rootStore.log.error('Failed to like feed', e)
+ } finally {
+ track('CustomFeed:Like')
+ }
+ }
+
+ async unlike() {
+ if (this.type !== 'feed-generator') {
+ return
+ }
+ if (!this.likeUri) {
+ return
+ }
+ const uri = this.likeUri
+ try {
+ this.likeUri = undefined
+ this.likeCount = (this.likeCount || 1) - 1
+ await this.rootStore.agent.deleteLike(uri!)
+ } catch (e: any) {
+ this.likeUri = uri
+ this.likeCount = (this.likeCount || 0) + 1
+ this.rootStore.log.error('Failed to unlike feed', e)
+ } finally {
+ track('CustomFeed:Unlike')
+ }
+ }
+}
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
index 20d9b60a..135d34dd 100644
--- a/src/state/models/content/list-membership.ts
+++ b/src/state/models/content/list-membership.ts
@@ -110,14 +110,21 @@ export class ListMembershipModel {
})
}
- async updateTo(uris: string[]) {
+ async updateTo(
+ uris: string[],
+ ): Promise<{added: string[]; removed: string[]}> {
+ const added = []
+ const removed = []
for (const uri of uris) {
await this.add(uri)
+ added.push(uri)
}
for (const membership of this.memberships) {
if (!uris.includes(membership.value.list)) {
await this.remove(membership.value.list)
+ removed.push(membership.value.list)
}
}
+ return {added, removed}
}
}
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index fd5074d8..0331f58b 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -1,10 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AtUri,
+ AppBskyActorDefs,
AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs,
AppBskyGraphList,
AppBskyGraphListitem,
+ RichText,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import chunk from 'lodash.chunk'
@@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {track} from 'lib/analytics/analytics'
+import {until} from 'lib/async/until'
const PAGE_SIZE = 30
@@ -37,19 +40,32 @@ export class ListModel {
loadMoreCursor?: string
// data
- list: GraphDefs.ListView | null = null
+ data: GraphDefs.ListView | null = null
items: GraphDefs.ListItemView[] = []
+ descriptionRT: RichText | null = null
- static async createModList(
+ static async createList(
rootStore: RootStoreModel,
{
+ purpose,
name,
description,
avatar,
- }: {name: string; description: string; avatar: RNImage | null | undefined},
+ }: {
+ purpose: string
+ name: string
+ description: string
+ avatar: RNImage | null | undefined
+ },
) {
+ if (
+ purpose !== 'app.bsky.graph.defs#curatelist' &&
+ purpose !== 'app.bsky.graph.defs#modlist'
+ ) {
+ throw new Error('Invalid list purpose: must be curatelist or modlist')
+ }
const record: AppBskyGraphList.Record = {
- purpose: 'app.bsky.graph.defs#modlist',
+ purpose,
name,
description,
avatar: undefined,
@@ -69,7 +85,20 @@ export class ListModel {
},
record,
)
- await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
+
+ // wait for the appview to update
+ await until(
+ 5, // 5 tries
+ 1e3, // 1s delay between tries
+ (v: GetList.Response, _e: any) => {
+ return typeof v?.data?.list.uri === 'string'
+ },
+ () =>
+ rootStore.agent.app.bsky.graph.getList({
+ list: res.uri,
+ limit: 1,
+ }),
+ )
return res
}
@@ -95,16 +124,40 @@ export class ListModel {
return this.hasLoaded && !this.hasContent
}
- get isOwner() {
- return this.list?.creator.did === this.rootStore.me.did
+ get isCuratelist() {
+ return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
}
- get isSubscribed() {
- return this.list?.viewer?.muted
+ get isModlist() {
+ return this.data?.purpose === 'app.bsky.graph.defs#modlist'
+ }
+
+ get isOwner() {
+ return this.data?.creator.did === this.rootStore.me.did
+ }
+
+ get isBlocking() {
+ return !!this.data?.viewer?.blocked
+ }
+
+ get isMuting() {
+ return !!this.data?.viewer?.muted
+ }
+
+ get isPinned() {
+ return this.rootStore.preferences.isPinnedFeed(this.uri)
}
get creatorDid() {
- return this.list?.creator.did
+ return this.data?.creator.did
+ }
+
+ getMembership(did: string) {
+ return this.items.find(item => item.subject.did === did)
+ }
+
+ isMember(did: string) {
+ return !!this.getMembership(did)
}
// public api
@@ -137,6 +190,15 @@ export class ListModel {
}
})
+ async loadAll() {
+ for (let i = 0; i < 1000; i++) {
+ if (!this.hasMore) {
+ break
+ }
+ await this.loadMore()
+ }
+ }
+
async updateMetadata({
name,
description,
@@ -146,7 +208,7 @@ export class ListModel {
description: string
avatar: RNImage | null | undefined
}) {
- if (!this.list) {
+ if (!this.data) {
return
}
if (!this.isOwner) {
@@ -183,7 +245,7 @@ export class ListModel {
}
async delete() {
- if (!this.list) {
+ if (!this.data) {
return
}
await this._resolveUri()
@@ -231,28 +293,140 @@ export class ListModel {
this.rootStore.emitListDeleted(this.uri)
}
- async subscribe() {
- if (!this.list) {
+ async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+ if (this.isMember(profile.did)) {
return
}
- await this._resolveUri()
- await this.rootStore.agent.app.bsky.graph.muteActorList({
- list: this.list.uri,
+ await this.rootStore.agent.app.bsky.graph.listitem.create(
+ {
+ repo: this.rootStore.me.did,
+ },
+ {
+ subject: profile.did,
+ list: this.uri,
+ createdAt: new Date().toISOString(),
+ },
+ )
+ runInAction(() => {
+ this.items = this.items.concat([
+ {_reactKey: profile.did, subject: profile},
+ ])
})
- track('Lists:Subscribe')
- await this.refresh()
}
- async unsubscribe() {
- if (!this.list) {
+ /**
+ * Just adds to local cache; used to reflect changes affected elsewhere
+ */
+ cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+ if (!this.isMember(profile.did)) {
+ this.items = this.items.concat([
+ {_reactKey: profile.did, subject: profile},
+ ])
+ }
+ }
+
+ /**
+ * Just removes from local cache; used to reflect changes affected elsewhere
+ */
+ cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+ if (this.isMember(profile.did)) {
+ this.items = this.items.filter(item => item.subject.did !== profile.did)
+ }
+ }
+
+ async pin() {
+ try {
+ await this.rootStore.preferences.addPinnedFeed(this.uri)
+ } catch (error) {
+ this.rootStore.log.error('Failed to pin feed', error)
+ } finally {
+ track('CustomFeed:Pin', {
+ name: this.data?.name || '',
+ uri: this.uri,
+ })
+ }
+ }
+
+ async togglePin() {
+ if (!this.isPinned) {
+ track('CustomFeed:Pin', {
+ name: this.data?.name || '',
+ uri: this.uri,
+ })
+ return this.rootStore.preferences.addPinnedFeed(this.uri)
+ } else {
+ track('CustomFeed:Unpin', {
+ name: this.data?.name || '',
+ uri: this.uri,
+ })
+ // TEMPORARY
+ // lists are temporarily piggybacking on the saved/pinned feeds preferences
+ // we'll eventually replace saved feeds with the bookmarks API
+ // until then, we need to unsave lists instead of just unpin them
+ // -prf
+ // return this.rootStore.preferences.removePinnedFeed(this.uri)
+ return this.rootStore.preferences.removeSavedFeed(this.uri)
+ }
+ }
+
+ async mute() {
+ if (!this.data) {
return
}
await this._resolveUri()
- await this.rootStore.agent.app.bsky.graph.unmuteActorList({
- list: this.list.uri,
+ await this.rootStore.agent.muteModList(this.data.uri)
+ track('Lists:Mute')
+ runInAction(() => {
+ if (this.data) {
+ const d = this.data
+ this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
+ }
+ })
+ }
+
+ async unmute() {
+ if (!this.data) {
+ return
+ }
+ await this._resolveUri()
+ await this.rootStore.agent.unmuteModList(this.data.uri)
+ track('Lists:Unmute')
+ runInAction(() => {
+ if (this.data) {
+ const d = this.data
+ this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
+ }
+ })
+ }
+
+ async block() {
+ if (!this.data) {
+ return
+ }
+ await this._resolveUri()
+ const res = await this.rootStore.agent.blockModList(this.data.uri)
+ track('Lists:Block')
+ runInAction(() => {
+ if (this.data) {
+ const d = this.data
+ this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
+ }
+ })
+ }
+
+ async unblock() {
+ if (!this.data || !this.data.viewer?.blocked) {
+ return
+ }
+ await this._resolveUri()
+ await this.rootStore.agent.unblockModList(this.data.uri)
+ track('Lists:Unblock')
+ runInAction(() => {
+ if (this.data) {
+ const d = this.data
+ this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
+ }
})
- track('Lists:Unsubscribe')
- await this.refresh()
}
/**
@@ -314,9 +488,17 @@ export class ListModel {
_appendAll(res: GetList.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
- this.list = res.data.list
+ this.data = res.data.list
this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
)
+ if (this.data.description) {
+ this.descriptionRT = new RichText({
+ text: this.data.description,
+ facets: (this.data.descriptionFacets || [])?.slice(),
+ })
+ } else {
+ this.descriptionRT = null
+ }
}
}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 906f84c2..5333e711 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -22,7 +22,8 @@ export class ProfileViewerModel {
following?: string
followedBy?: string
blockedBy?: boolean
- blocking?: string;
+ blocking?: string
+ blockingByList?: AppBskyGraphDefs.ListViewBasic;
[key: string]: unknown
constructor() {
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
index fa4054ff..1a00f802 100644
--- a/src/state/models/discovery/feeds.ts
+++ b/src/state/models/discovery/feeds.ts
@@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
const DEFAULT_LIMIT = 50
@@ -16,7 +16,7 @@ export class FeedsDiscoveryModel {
loadMoreCursor: string | undefined = undefined
// data
- feeds: CustomFeedModel[] = []
+ feeds: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@@ -137,7 +137,9 @@ export class FeedsDiscoveryModel {
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. push data into feeds array
for (const f of res.data.feeds) {
- this.feeds.push(new CustomFeedModel(this.rootStore, f))
+ const model = new FeedSourceModel(this.rootStore, f.uri)
+ model.hydrateFeedGenerator(f)
+ this.feeds.push(model)
}
// 2. set loadMoreCursor
this.loadMoreCursor = res.data.cursor
diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts
deleted file mode 100644
index 2de4534e..00000000
--- a/src/state/models/feeds/custom-feed.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import {AppBskyFeedDefs} from '@atproto/api'
-import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from 'state/models/root-store'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {updateDataOptimistically} from 'lib/async/revertible'
-import {track} from 'lib/analytics/analytics'
-
-export class CustomFeedModel {
- // data
- _reactKey: string
- data: AppBskyFeedDefs.GeneratorView
- isOnline: boolean
- isValid: boolean
-
- constructor(
- public rootStore: RootStoreModel,
- view: AppBskyFeedDefs.GeneratorView,
- isOnline?: boolean,
- isValid?: boolean,
- ) {
- this._reactKey = view.uri
- this.data = view
- this.isOnline = isOnline ?? true
- this.isValid = isValid ?? true
- makeAutoObservable(
- this,
- {
- rootStore: false,
- },
- {autoBind: true},
- )
- }
-
- // local actions
- // =
-
- get uri() {
- return this.data.uri
- }
-
- get displayName() {
- if (this.data.displayName) {
- return sanitizeDisplayName(this.data.displayName)
- }
- return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}`
- }
-
- get isSaved() {
- return this.rootStore.preferences.savedFeeds.includes(this.uri)
- }
-
- get isLiked() {
- return this.data.viewer?.like
- }
-
- // public apis
- // =
-
- async save() {
- try {
- await this.rootStore.preferences.addSavedFeed(this.uri)
- } catch (error) {
- this.rootStore.log.error('Failed to save feed', error)
- } finally {
- track('CustomFeed:Save')
- }
- }
-
- async pin() {
- try {
- await this.rootStore.preferences.addPinnedFeed(this.uri)
- } catch (error) {
- this.rootStore.log.error('Failed to pin feed', error)
- } finally {
- track('CustomFeed:Pin', {
- name: this.data.displayName,
- uri: this.uri,
- })
- }
- }
-
- async unsave() {
- try {
- await this.rootStore.preferences.removeSavedFeed(this.uri)
- } catch (error) {
- this.rootStore.log.error('Failed to unsave feed', error)
- } finally {
- track('CustomFeed:Unsave')
- }
- }
-
- async like() {
- try {
- await updateDataOptimistically(
- this.data,
- () => {
- this.data.viewer = this.data.viewer || {}
- this.data.viewer.like = 'pending'
- this.data.likeCount = (this.data.likeCount || 0) + 1
- },
- () => this.rootStore.agent.like(this.data.uri, this.data.cid),
- res => {
- this.data.viewer = this.data.viewer || {}
- this.data.viewer.like = res.uri
- },
- )
- } catch (e: any) {
- this.rootStore.log.error('Failed to like feed', e)
- } finally {
- track('CustomFeed:Like')
- }
- }
-
- async unlike() {
- if (!this.data.viewer?.like) {
- return
- }
- try {
- const likeUri = this.data.viewer.like
- await updateDataOptimistically(
- this.data,
- () => {
- this.data.viewer = this.data.viewer || {}
- this.data.viewer.like = undefined
- this.data.likeCount = (this.data.likeCount || 1) - 1
- },
- () => this.rootStore.agent.deleteLike(likeUri),
- )
- } catch (e: any) {
- this.rootStore.log.error('Failed to unlike feed', e)
- } finally {
- track('CustomFeed:Unlike')
- }
- }
-
- async reload() {
- const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
- feed: this.data.uri,
- })
- runInAction(() => {
- this.data = res.data.view
- this.isOnline = res.data.isOnline
- this.isValid = res.data.isValid
- })
- }
-
- serialize() {
- return JSON.stringify(this.data)
- }
-}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 2462689b..169eedac 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -4,6 +4,7 @@ import {
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
AppBskyFeedGetFeed as GetCustomFeed,
AppBskyFeedGetActorLikes as GetActorLikes,
+ AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
@@ -19,6 +20,7 @@ import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
const PAGE_SIZE = 30
@@ -36,6 +38,7 @@ type QueryParams =
| GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
+ | GetListFeed.QueryParams
export class PostsFeedModel {
// state
@@ -66,7 +69,13 @@ export class PostsFeedModel {
constructor(
public rootStore: RootStoreModel,
- public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
+ public feedType:
+ | 'home'
+ | 'following'
+ | 'author'
+ | 'custom'
+ | 'likes'
+ | 'list',
params: QueryParams,
options?: Options,
) {
@@ -99,11 +108,26 @@ export class PostsFeedModel {
rootStore,
params as GetCustomFeed.QueryParams,
)
+ } else if (feedType === 'list') {
+ this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
} else {
this.api = new FollowingFeedAPI(rootStore)
}
}
+ get reactKey() {
+ if (this.feedType === 'author') {
+ return (this.params as GetAuthorFeed.QueryParams).actor
+ }
+ if (this.feedType === 'custom') {
+ return (this.params as GetCustomFeed.QueryParams).feed
+ }
+ if (this.feedType === 'list') {
+ return (this.params as GetListFeed.QueryParams).list
+ }
+ return this.feedType
+ }
+
get hasContent() {
return this.slices.length !== 0
}
@@ -117,7 +141,7 @@ export class PostsFeedModel {
}
get isLoadingMore() {
- return this.isLoading && !this.isRefreshing
+ return this.isLoading && !this.isRefreshing && this.hasContent
}
setHasNewLatest(v: boolean) {
diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts
index 0f206058..d2bd7680 100644
--- a/src/state/models/lists/actor-feeds.ts
+++ b/src/state/models/lists/actor-feeds.ts
@@ -3,7 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
const PAGE_SIZE = 30
@@ -17,7 +17,7 @@ export class ActorFeedsModel {
loadMoreCursor?: string
// data
- feeds: CustomFeedModel[] = []
+ feeds: FeedSourceModel[] = []
constructor(
public rootStore: RootStoreModel,
@@ -114,7 +114,9 @@ export class ActorFeedsModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) {
- this.feeds.push(new CustomFeedModel(this.rootStore, f))
+ const model = new FeedSourceModel(this.rootStore, f.uri)
+ model.hydrateFeedGenerator(f)
+ this.feeds.push(model)
}
}
}
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
index 54e2f5fd..42638757 100644
--- a/src/state/models/lists/lists-list.ts
+++ b/src/state/models/lists/lists-list.ts
@@ -1,12 +1,9 @@
import {makeAutoObservable} from 'mobx'
-import {
- AppBskyGraphGetLists as GetLists,
- AppBskyGraphGetListMutes as GetListMutes,
- AppBskyGraphDefs as GraphDefs,
-} from '@atproto/api'
+import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
+import {accumulate} from 'lib/async/accumulate'
const PAGE_SIZE = 30
@@ -25,7 +22,7 @@ export class ListsListModel {
constructor(
public rootStore: RootStoreModel,
- public source: 'my-modlists' | string,
+ public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
) {
makeAutoObservable(
this,
@@ -48,6 +45,26 @@ export class ListsListModel {
return this.hasLoaded && !this.hasContent
}
+ get curatelists() {
+ return this.lists.filter(
+ list => list.purpose === 'app.bsky.graph.defs#curatelist',
+ )
+ }
+
+ get isCuratelistsEmpty() {
+ return this.hasLoaded && this.curatelists.length === 0
+ }
+
+ get modlists() {
+ return this.lists.filter(
+ list => list.purpose === 'app.bsky.graph.defs#modlist',
+ )
+ }
+
+ get isModlistsEmpty() {
+ return this.hasLoaded && this.modlists.length === 0
+ }
+
/**
* Removes posts from the feed upon deletion.
*/
@@ -76,44 +93,85 @@ export class ListsListModel {
}
this._xLoading(replace)
try {
- let res: GetLists.Response
- if (this.source === 'my-modlists') {
- res = {
- success: true,
- headers: {},
- data: {
- subject: undefined,
- lists: [],
- },
+ let cursor: string | undefined
+ let lists: GraphDefs.ListView[] = []
+ if (
+ this.source === 'mine' ||
+ this.source === 'my-curatelists' ||
+ this.source === 'my-modlists'
+ ) {
+ const promises = [
+ accumulate(cursor =>
+ this.rootStore.agent.app.bsky.graph
+ .getLists({
+ actor: this.rootStore.me.did,
+ cursor,
+ limit: 50,
+ })
+ .then(res => ({cursor: res.data.cursor, items: res.data.lists})),
+ ),
+ ]
+ if (this.source === 'my-modlists') {
+ promises.push(
+ accumulate(cursor =>
+ this.rootStore.agent.app.bsky.graph
+ .getListMutes({
+ cursor,
+ limit: 50,
+ })
+ .then(res => ({
+ cursor: res.data.cursor,
+ items: res.data.lists,
+ })),
+ ),
+ )
+ promises.push(
+ accumulate(cursor =>
+ this.rootStore.agent.app.bsky.graph
+ .getListBlocks({
+ cursor,
+ limit: 50,
+ })
+ .then(res => ({
+ cursor: res.data.cursor,
+ items: res.data.lists,
+ })),
+ ),
+ )
}
- const [res1, res2] = await Promise.all([
- fetchAllUserLists(this.rootStore, this.rootStore.me.did),
- fetchAllMyMuteLists(this.rootStore),
- ])
- for (let list of res1.data.lists) {
- if (list.purpose === 'app.bsky.graph.defs#modlist') {
- res.data.lists.push(list)
- }
- }
- for (let list of res2.data.lists) {
- if (
- list.purpose === 'app.bsky.graph.defs#modlist' &&
- !res.data.lists.find(l => l.uri === list.uri)
- ) {
- res.data.lists.push(list)
+ const resultset = await Promise.all(promises)
+ for (const res of resultset) {
+ for (let list of res) {
+ if (
+ this.source === 'my-curatelists' &&
+ list.purpose !== 'app.bsky.graph.defs#curatelist'
+ ) {
+ continue
+ }
+ if (
+ this.source === 'my-modlists' &&
+ list.purpose !== 'app.bsky.graph.defs#modlist'
+ ) {
+ continue
+ }
+ if (!lists.find(l => l.uri === list.uri)) {
+ lists.push(list)
+ }
}
}
} else {
- res = await this.rootStore.agent.app.bsky.graph.getLists({
+ const res = await this.rootStore.agent.app.bsky.graph.getLists({
actor: this.source,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
+ lists = res.data.lists
+ cursor = res.data.cursor
}
if (replace) {
- this._replaceAll(res)
+ this._replaceAll({lists, cursor})
} else {
- this._appendAll(res)
+ this._appendAll({lists, cursor})
}
this._xIdle()
} catch (e: any) {
@@ -156,75 +214,28 @@ export class ListsListModel {
// helper functions
// =
- _replaceAll(res: GetLists.Response | GetListMutes.Response) {
+ _replaceAll({
+ lists,
+ cursor,
+ }: {
+ lists: GraphDefs.ListView[]
+ cursor: string | undefined
+ }) {
this.lists = []
- this._appendAll(res)
+ this._appendAll({lists, cursor})
}
- _appendAll(res: GetLists.Response | GetListMutes.Response) {
- this.loadMoreCursor = res.data.cursor
+ _appendAll({
+ lists,
+ cursor,
+ }: {
+ lists: GraphDefs.ListView[]
+ cursor: string | undefined
+ }) {
+ this.loadMoreCursor = cursor
this.hasMore = !!this.loadMoreCursor
this.lists = this.lists.concat(
- res.data.lists.map(list => ({...list, _reactKey: list.uri})),
+ lists.map(list => ({...list, _reactKey: list.uri})),
)
}
}
-
-async function fetchAllUserLists(
- store: RootStoreModel,
- did: string,
-): Promise {
- let acc: GetLists.Response = {
- success: true,
- headers: {},
- data: {
- subject: undefined,
- lists: [],
- },
- }
-
- let cursor
- for (let i = 0; i < 100; i++) {
- const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({
- actor: did,
- cursor,
- limit: 50,
- })
- cursor = res.data.cursor
- acc.data.lists = acc.data.lists.concat(res.data.lists)
- if (!cursor) {
- break
- }
- }
-
- return acc
-}
-
-async function fetchAllMyMuteLists(
- store: RootStoreModel,
-): Promise {
- let acc: GetListMutes.Response = {
- success: true,
- headers: {},
- data: {
- subject: undefined,
- lists: [],
- },
- }
-
- let cursor
- for (let i = 0; i < 100; i++) {
- const res: GetListMutes.Response =
- await store.agent.app.bsky.graph.getListMutes({
- cursor,
- limit: 50,
- })
- cursor = res.data.cursor
- acc.data.lists = acc.data.lists.concat(res.data.lists)
- if (!cursor) {
- break
- }
- }
-
- return acc
-}
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 186e61cf..75c87d76 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts'
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 @@ export class MeModel {
followsCount: number | undefined
followersCount: number | undefined
mainFeed: PostsFeedModel
- savedFeeds: SavedFeedsModel
notifications: NotificationsFeedModel
follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
@@ -45,7 +43,6 @@ export class MeModel {
})
this.notifications = new NotificationsFeedModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore)
- this.savedFeeds = new SavedFeedsModel(this.rootStore)
}
clear() {
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
index 6b017709..58f2e7f6 100644
--- a/src/state/models/ui/my-feeds.ts
+++ b/src/state/models/ui/my-feeds.ts
@@ -1,6 +1,7 @@
-import {makeAutoObservable} from 'mobx'
+import {makeAutoObservable, reaction} from 'mobx'
+import {SavedFeedsModel} from './saved-feeds'
import {FeedsDiscoveryModel} from '../discovery/feeds'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
import {RootStoreModel} from '../root-store'
export type MyFeedsItem =
@@ -29,7 +30,7 @@ export type MyFeedsItem =
| {
_reactKey: string
type: 'saved-feed'
- feed: CustomFeedModel
+ feed: FeedSourceModel
}
| {
_reactKey: string
@@ -46,21 +47,19 @@ export type MyFeedsItem =
| {
_reactKey: string
type: 'discover-feed'
- feed: CustomFeedModel
+ feed: FeedSourceModel
}
export class MyFeedsUIModel {
+ saved: SavedFeedsModel
discovery: FeedsDiscoveryModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
+ this.saved = new SavedFeedsModel(this.rootStore)
this.discovery = new FeedsDiscoveryModel(this.rootStore)
}
- get saved() {
- return this.rootStore.me.savedFeeds
- }
-
get isRefreshing() {
return !this.saved.isLoading && this.saved.isRefreshing
}
@@ -78,6 +77,21 @@ export class MyFeedsUIModel {
}
}
+ registerListeners() {
+ const dispose1 = reaction(
+ () => this.rootStore.preferences.savedFeeds,
+ () => this.saved.refresh(),
+ )
+ const dispose2 = reaction(
+ () => this.rootStore.preferences.pinnedFeeds,
+ () => this.saved.refresh(),
+ )
+ return () => {
+ dispose1()
+ dispose2()
+ }
+ }
+
async refresh() {
return Promise.all([this.saved.refresh(), this.discovery.refresh()])
}
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 6ca19b4b..7714d65d 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -194,7 +194,7 @@ export class PreferencesModel {
/**
* This function fetches preferences and sets defaults for missing items.
*/
- async sync({clearCache}: {clearCache?: boolean} = {}) {
+ async sync() {
await this.lock.acquireAsync()
try {
// fetch preferences
@@ -252,8 +252,6 @@ export class PreferencesModel {
} finally {
this.lock.release()
}
-
- await this.rootStore.me.savedFeeds.updateCache(clearCache)
}
async syncLegacyPreferences() {
@@ -286,6 +284,9 @@ export class PreferencesModel {
}
}
+ // languages
+ // =
+
hasContentLanguage(code2: string) {
return this.contentLanguages.includes(code2)
}
@@ -358,6 +359,9 @@ export class PreferencesModel {
return all.join(', ')
}
+ // moderation
+ // =
+
async setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,
@@ -409,6 +413,13 @@ export class PreferencesModel {
}
}
+ // feeds
+ // =
+
+ isPinnedFeed(uri: string) {
+ return this.pinnedFeeds.includes(uri)
+ }
+
async _optimisticUpdateSavedFeeds(
saved: string[],
pinned: string[],
@@ -474,6 +485,9 @@ export class PreferencesModel {
)
}
+ // other
+ // =
+
async setBirthDate(birthDate: Date) {
this.birthDate = birthDate
await this.lock.acquireAsync()
@@ -602,7 +616,7 @@ export class PreferencesModel {
}
getFeedTuners(
- feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
+ feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
) {
if (feedType === 'custom') {
return [
@@ -610,6 +624,9 @@ export class PreferencesModel {
FeedTuner.preferredLangOnly(this.contentLanguages),
]
}
+ if (feedType === 'list') {
+ return [FeedTuner.dedupReposts]
+ }
if (feedType === 'home' || feedType === 'following') {
const feedTuners = []
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 2dd72980..4156f792 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
import {track} from 'lib/analytics/analytics'
export class SavedFeedsModel {
@@ -13,7 +13,7 @@ export class SavedFeedsModel {
error = ''
// data
- _feedModelCache: Record = {}
+ all: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@@ -38,20 +38,11 @@ export class SavedFeedsModel {
}
get pinned() {
- return this.rootStore.preferences.pinnedFeeds
- .map(uri => this._feedModelCache[uri] as CustomFeedModel)
- .filter(Boolean)
+ return this.all.filter(feed => feed.isPinned)
}
get unpinned() {
- return this.rootStore.preferences.savedFeeds
- .filter(uri => !this.isPinned(uri))
- .map(uri => this._feedModelCache[uri] as CustomFeedModel)
- .filter(Boolean)
- }
-
- get all() {
- return [...this.pinned, ...this.unpinned]
+ return this.all.filter(feed => !feed.isPinned)
}
get pinnedFeedNames() {
@@ -61,121 +52,39 @@ export class SavedFeedsModel {
// public api
// =
- /**
- * Syncs the cached models against the current state
- * - Should only be called by the preferences model after syncing state
- */
- updateCache = bundleAsync(async (clearCache?: boolean) => {
- let newFeedModels: Record = {}
- if (!clearCache) {
- newFeedModels = {...this._feedModelCache}
- }
-
- // collect the feed URIs that havent been synced yet
- const neededFeedUris = []
- for (const feedUri of this.rootStore.preferences.savedFeeds) {
- if (!(feedUri in newFeedModels)) {
- neededFeedUris.push(feedUri)
- }
- }
-
- // early exit if no feeds need to be fetched
- if (!neededFeedUris.length || neededFeedUris.length === 0) {
- return
- }
-
- // fetch the missing models
- try {
- for (let i = 0; i < neededFeedUris.length; i += 25) {
- const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
- feeds: neededFeedUris.slice(i, 25),
- })
- for (const feedInfo of res.data.feeds) {
- newFeedModels[feedInfo.uri] = new CustomFeedModel(
- this.rootStore,
- feedInfo,
- )
- }
- }
- } catch (error) {
- console.error('Failed to fetch feed models', error)
- this.rootStore.log.error('Failed to fetch feed models', error)
- }
-
- // merge into the cache
- runInAction(() => {
- this._feedModelCache = newFeedModels
- })
- })
-
/**
* Refresh the preferences then reload all feed infos
*/
refresh = bundleAsync(async () => {
this._xLoading(true)
try {
- await this.rootStore.preferences.sync({clearCache: true})
+ await this.rootStore.preferences.sync()
+ const uris = dedup(
+ this.rootStore.preferences.pinnedFeeds.concat(
+ this.rootStore.preferences.savedFeeds,
+ ),
+ )
+ const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri))
+ await Promise.all(feeds.map(f => f.setup()))
+ runInAction(() => {
+ this.all = feeds
+ this._updatePinSortOrder()
+ })
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
- async save(feed: CustomFeedModel) {
- try {
- await feed.save()
- await this.updateCache()
- } catch (e: any) {
- this.rootStore.log.error('Failed to save feed', e)
- }
- }
-
- async unsave(feed: CustomFeedModel) {
- const uri = feed.uri
- try {
- if (this.isPinned(feed)) {
- await this.rootStore.preferences.removePinnedFeed(uri)
- }
- await feed.unsave()
- } catch (e: any) {
- this.rootStore.log.error('Failed to unsave feed', e)
- }
- }
-
- async togglePinnedFeed(feed: CustomFeedModel) {
- if (!this.isPinned(feed)) {
- track('CustomFeed:Pin', {
- name: feed.data.displayName,
- uri: feed.uri,
- })
- return this.rootStore.preferences.addPinnedFeed(feed.uri)
- } else {
- track('CustomFeed:Unpin', {
- name: feed.data.displayName,
- uri: feed.uri,
- })
- return this.rootStore.preferences.removePinnedFeed(feed.uri)
- }
- }
-
- async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
- return this.rootStore.preferences.setSavedFeeds(
+ async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
+ this._updatePinSortOrder(feeds.map(f => f.uri))
+ await this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
- feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
+ feeds.filter(feed => feed.isPinned).map(feed => feed.uri),
)
}
- isPinned(feedOrUri: CustomFeedModel | string) {
- let uri: string
- if (typeof feedOrUri === 'string') {
- uri = feedOrUri
- } else {
- uri = feedOrUri.uri
- }
- return this.rootStore.preferences.pinnedFeeds.includes(uri)
- }
-
- async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
+ async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri)
if (index === -1) {
@@ -194,8 +103,9 @@ export class SavedFeedsModel {
this.rootStore.preferences.savedFeeds,
pinned,
)
+ this._updatePinSortOrder()
track('CustomFeed:Reorder', {
- name: item.data.displayName,
+ name: item.displayName,
uri: item.uri,
index: pinned.indexOf(item.uri),
})
@@ -219,4 +129,20 @@ export class SavedFeedsModel {
this.rootStore.log.error('Failed to fetch user feeds', err)
}
}
+
+ // helpers
+ // =
+
+ _updatePinSortOrder(order?: string[]) {
+ order ??= this.rootStore.preferences.pinnedFeeds.concat(
+ this.rootStore.preferences.savedFeeds,
+ )
+ this.all.sort((a, b) => {
+ return order!.indexOf(a.uri) - order!.indexOf(b.uri)
+ })
+ }
+}
+
+function dedup(strings: string[]): string[] {
+ return Array.from(new Set(strings))
}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index a8937b84..9c0cc6e3 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,4 +1,4 @@
-import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
+import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile'
@@ -60,17 +60,25 @@ export type ReportModal = {
| {did: string}
)
-export interface CreateOrEditMuteListModal {
- name: 'create-or-edit-mute-list'
+export interface CreateOrEditListModal {
+ name: 'create-or-edit-list'
+ purpose?: string
list?: ListModel
onSave?: (uri: string) => void
}
-export interface ListAddRemoveUserModal {
- name: 'list-add-remove-user'
+export interface UserAddRemoveListsModal {
+ name: 'user-add-remove-lists'
subject: string
displayName: string
- onUpdate?: () => void
+ onAdd?: (listUri: string) => void
+ onRemove?: (listUri: string) => void
+}
+
+export interface ListAddUserModal {
+ name: 'list-add-user'
+ list: ListModel
+ onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}
export interface EditImageModal {
@@ -180,8 +188,11 @@ export type Modal =
// Moderation
| ModerationDetailsModal
| ReportModal
- | CreateOrEditMuteListModal
- | ListAddRemoveUserModal
+
+ // Lists
+ | CreateOrEditListModal
+ | UserAddRemoveListsModal
+ | ListAddUserModal
// Posts
| AltTextImageModal
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index aaba19c8..400b836d 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -12,7 +12,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette'
import {useQuery} from '@tanstack/react-query'
import {useStores} from 'state/index'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
+import {FeedSourceModel} from 'state/models/content/feed-source'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
type Props = {
@@ -39,7 +39,9 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
}
return (feeds.length ? feeds : []).map(feed => {
- return new CustomFeedModel(store, feed)
+ const model = new FeedSourceModel(store, feed.uri)
+ model.hydrateFeedGenerator(feed)
+ return model
})
} catch (e) {
return []
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index 6796c64d..bee23c95 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
+import {RichText} from 'view/com/util/text/RichText'
import {Button} from 'view/com/util/forms/Button'
import {UserAvatar} from 'view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
@@ -10,12 +11,12 @@ import {HeartIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeHandle} from 'lib/strings/handles'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
+import {FeedSourceModel} from 'state/models/content/feed-source'
export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
item,
}: {
- item: CustomFeedModel
+ item: FeedSourceModel
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
@@ -54,7 +55,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
},
]}>
-
+
- by {sanitizeHandle(item.data.creator.handle, '@')}
+ by {sanitizeHandle(item.creatorHandle, '@')}
- {item.data.description ? (
-
- {item.data.description}
-
+ richText={item.descriptionRT}
+ numberOfLines={6}
+ />
) : null}
@@ -129,7 +130,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
style={[pal.textLight, {position: 'relative', top: 2}]}
/>
- {item.data.likeCount || 0}
+ {item.likeCount || 0}
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 725106d5..72b83b80 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -111,10 +111,6 @@ export const FeedPage = observer(function FeedPageImpl({
store.shell.openComposer({})
}, [store, track])
- const onPressTryAgain = React.useCallback(() => {
- feed.refresh()
- }, [feed])
-
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
feed.refresh()
@@ -179,10 +175,8 @@ export const FeedPage = observer(function FeedPageImpl({
showSaveBtn?: boolean
showDescription?: boolean
@@ -40,7 +41,7 @@ export const CustomFeed = observer(function CustomFeedImpl({
message: `Remove ${item.displayName} from my feeds?`,
onPressConfirm: async () => {
try {
- await store.me.savedFeeds.unsave(item)
+ await item.unsave()
Toast.show('Removed from my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
@@ -50,7 +51,7 @@ export const CustomFeed = observer(function CustomFeedImpl({
})
} else {
try {
- await store.me.savedFeeds.save(item)
+ await item.save()
Toast.show('Added to my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
@@ -65,22 +66,29 @@ export const CustomFeed = observer(function CustomFeedImpl({
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
- navigation.push('CustomFeed', {
- name: item.data.creator.did,
- rkey: new AtUri(item.data.uri).rkey,
- })
+ if (item.type === 'feed-generator') {
+ navigation.push('ProfileFeed', {
+ name: item.creatorDid,
+ rkey: new AtUri(item.uri).rkey,
+ })
+ } else if (item.type === 'list') {
+ navigation.push('ProfileList', {
+ name: item.creatorDid,
+ rkey: new AtUri(item.uri).rkey,
+ })
+ }
}}
- key={item.data.uri}>
+ key={item.uri}>
-
+
{item.displayName}
- by {sanitizeHandle(item.data.creator.handle, '@')}
+ by {sanitizeHandle(item.creatorHandle, '@')}
{showSaveBtn && (
@@ -112,16 +120,18 @@ export const CustomFeed = observer(function CustomFeedImpl({
)}
- {showDescription && item.data.description ? (
-
- {item.data.description}
-
+ {showDescription && item.descriptionRT ? (
+
) : null}
{showLikes ? (
- Liked by {item.data.likeCount || 0}{' '}
- {pluralize(item.data.likeCount || 0, 'user')}
+ Liked by {item.likeCount || 0}{' '}
+ {pluralize(item.likeCount || 0, 'user')}
) : null}
diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx
deleted file mode 100644
index 35333819..00000000
--- a/src/view/com/lists/ListActions.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Button} from '../util/forms/Button'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-
-export const ListActions = ({
- muted,
- onToggleSubscribed,
- onPressEditList,
- isOwner,
- onPressDeleteList,
- onPressShareList,
- onPressReportList,
- reversed = false, // Default value of reversed is false
-}: {
- isOwner: boolean
- muted?: boolean
- onToggleSubscribed?: () => void
- onPressEditList?: () => void
- onPressDeleteList?: () => void
- onPressShareList?: () => void
- onPressReportList?: () => void
- reversed?: boolean // New optional prop
-}) => {
- const pal = usePalette('default')
-
- let buttons = [
- ,
- isOwner && (
-
- ),
- isOwner && (
-
- ),
- ,
- !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
index 159d966e..a481902d 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -76,7 +76,10 @@ export const ListCard = ({
{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
index c5ea1316..855c07d1 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListItems.tsx
@@ -3,34 +3,26 @@ import {
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 @@ export const ListItems = observer(function ListItemsImpl({
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 @@ export const ListItems = observer(function ListItemsImpl({
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 @@ export const ListItems = observer(function ListItemsImpl({
}
return (
) : view.viewer.blocking ? (
-
-
- Unblock
-
-
+ view.viewer.blockingByList ? null : (
+
+
+ Unblock
+
+
+ )
) : !view.viewer.blockedBy ? (
<>
{!isProfilePreview && (
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
new file mode 100644
index 00000000..8e957728
--- /dev/null
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -0,0 +1,194 @@
+import React from 'react'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
+import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {CenteredView} from '../util/Views'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {makeProfileLink} from 'lib/routes/links'
+import {useStores} from 'state/index'
+import {NavigationProp} from 'lib/routes/types'
+import {BACK_HITSLOP} from 'lib/constants'
+import {isNative} from 'platform/detection'
+import {ImagesLightbox} from 'state/models/ui/shell'
+
+export const ProfileSubpageHeader = observer(function HeaderImpl({
+ isLoading,
+ href,
+ title,
+ avatar,
+ isOwner,
+ creator,
+ avatarType,
+ children,
+}: React.PropsWithChildren<{
+ isLoading?: boolean
+ href: string
+ title: string | undefined
+ avatar: string | undefined
+ isOwner: boolean | undefined
+ creator:
+ | {
+ did: string
+ handle: string
+ }
+ | undefined
+ avatarType: UserAvatarType
+}>) {
+ const store = useStores()
+ const navigation = useNavigation()
+ const {isMobile} = useWebMediaQueries()
+ const pal = usePalette('default')
+ const canGoBack = navigation.canGoBack()
+
+ const onPressBack = React.useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('Home')
+ }
+ }, [navigation])
+
+ const onPressMenu = React.useCallback(() => {
+ store.shell.openDrawer()
+ }, [store])
+
+ const onPressAvi = React.useCallback(() => {
+ if (
+ avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+ ) {
+ store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0))
+ }
+ }, [store, avatar])
+
+ return (
+
+ {isMobile && (
+
+
+ {canGoBack ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+ )}
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ store.emitScreenSoftReset()}
+ numberOfLines={4}
+ />
+ )}
+
+ {isLoading ? (
+
+ ) : (
+
+ by{' '}
+ {!creator ? (
+ '—'
+ ) : isOwner ? (
+ 'you'
+ ) : (
+
+ )}
+
+ )}
+
+ {!isMobile && (
+
+ {children}
+
+ )}
+
+
+ )
+})
+
+const styles = StyleSheet.create({
+ backBtn: {
+ width: 20,
+ height: 30,
+ },
+ backBtnWide: {
+ width: 20,
+ height: 30,
+ paddingHorizontal: 6,
+ },
+ backIcon: {
+ marginTop: 6,
+ },
+})
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 3f2b2fd0..db9b6b4b 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -65,6 +65,12 @@ export function TestCtrls() {
accessibilityRole="button"
style={BTN}
/>
+ navigate('Lists')}
+ accessibilityRole="button"
+ style={BTN}
+ />
store.preferences.toggleHomeFeedMergeFeedEnabled()}
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 761fec21..29571696 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -25,7 +25,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) {
name: 'trash',
},
android: 'ic_delete',
- web: 'trash',
+ web: ['far', 'trash-can'],
},
},
]
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index d7ab1be5..461cbcbe 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -83,19 +83,14 @@ export function PostLoadingPlaceholder({
export function PostFeedLoadingPlaceholder() {
return (
- <>
+
-
-
-
-
-
- >
+
)
}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index fbc0b5e1..7b23547c 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -17,10 +17,10 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
-type Type = 'user' | 'algo' | 'list'
+export type UserAvatarType = 'user' | 'algo' | 'list'
interface BaseUserAvatarProps {
- type?: Type
+ type?: UserAvatarType
size: number
avatar?: string | null
}
@@ -41,7 +41,7 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
const BLUR_AMOUNT = isWeb ? 5 : 100
-function DefaultAvatar({type, size}: {type: Type; size: number}) {
+function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) {
if (type === 'algo') {
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return (
@@ -261,7 +261,7 @@ export function EditableUserAvatar({
name: 'trash',
},
android: 'ic_delete',
- web: 'trash',
+ web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null)
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 9a99dc5a..4bdfad06 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -91,7 +91,7 @@ export function UserBanner({
name: 'trash',
},
android: 'ic_delete',
- web: 'trash',
+ web: ['far', 'trash-can'],
},
onPress: () => {
onSelectNewBanner?.(null)
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index ec459b4e..4cc9efb7 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -124,7 +124,6 @@ function DesktopWebHeader({
+}
diff --git a/src/view/com/util/Views.tsx b/src/view/com/util/Views.tsx
deleted file mode 100644
index 07dcc4de..00000000
--- a/src/view/com/util/Views.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export {View as CenteredView, FlatList, ScrollView} from 'react-native'
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index fda0a9b8..1c2edc0c 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -14,9 +14,7 @@
import React from 'react'
import {
- FlatList as RNFlatList,
FlatListProps,
- ScrollView as RNScrollView,
ScrollViewProps,
StyleSheet,
View,
@@ -25,16 +23,29 @@ import {
import {addStyle} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import Animated from 'react-native-reanimated'
interface AddedProps {
- desktopFixedHeight?: boolean
+ desktopFixedHeight?: boolean | number
}
export function CenteredView({
style,
+ sideBorders,
...props
-}: React.PropsWithChildren) {
- style = addStyle(style, styles.container)
+}: React.PropsWithChildren) {
+ const pal = usePalette('default')
+ const {isMobile} = useWebMediaQueries()
+ if (!isMobile) {
+ style = addStyle(style, styles.container)
+ }
+ if (sideBorders) {
+ style = addStyle(style, {
+ borderLeftWidth: 1,
+ borderRightWidth: 1,
+ })
+ style = addStyle(style, pal.border)
+ }
return
}
@@ -46,14 +57,16 @@ export const FlatList = React.forwardRef(function FlatListImpl(
desktopFixedHeight,
...props
}: React.PropsWithChildren & AddedProps>,
- ref: React.Ref,
+ ref: React.Ref>,
) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
- contentContainerStyle = addStyle(
- contentContainerStyle,
- styles.containerScroll,
- )
+ if (!isMobile) {
+ contentContainerStyle = addStyle(
+ contentContainerStyle,
+ styles.containerScroll,
+ )
+ }
if (contentOffset && contentOffset?.y !== 0) {
// NOTE
// we use paddingTop & contentOffset to space around the floating header
@@ -68,7 +81,14 @@ export const FlatList = React.forwardRef(function FlatListImpl(
})
}
if (desktopFixedHeight) {
- style = addStyle(style, styles.fixedHeight)
+ if (typeof desktopFixedHeight === 'number') {
+ // @ts-ignore Web only -prf
+ style = addStyle(style, {
+ height: `calc(100vh - ${desktopFixedHeight}px)`,
+ })
+ } else {
+ style = addStyle(style, styles.fixedHeight)
+ }
if (!isMobile) {
// NOTE
// react native web produces *three* wrapping divs
@@ -85,7 +105,7 @@ export const FlatList = React.forwardRef(function FlatListImpl(
}
}
return (
- (
export const ScrollView = React.forwardRef(function ScrollViewImpl(
{contentContainerStyle, ...props}: React.PropsWithChildren,
- ref: React.Ref,
+ ref: React.Ref,
) {
const pal = usePalette('default')
- contentContainerStyle = addStyle(
- contentContainerStyle,
- styles.containerScroll,
- )
+ const {isMobile} = useWebMediaQueries()
+ if (!isMobile) {
+ contentContainerStyle = addStyle(
+ contentContainerStyle,
+ styles.containerScroll,
+ )
+ }
return (
-
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index b16a4239..f9a9387b 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -10,6 +10,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity)
+import {isWeb} from 'platform/detection'
export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
onPress,
@@ -47,7 +48,8 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
const styles = StyleSheet.create({
loadLatest: {
- position: 'absolute',
+ // @ts-ignore 'fixed' is web only -prf
+ position: isWeb ? 'fixed' : 'absolute',
left: 18,
bottom: 44,
borderWidth: 1,
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index 443885df..d224286b 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -74,7 +74,7 @@ export function PostHider({
accessibilityHint="">
-
+
{desc.name}
{!moderation.noOverride && (
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
index b7781e06..6b7f4e7e 100644
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -45,7 +45,7 @@ export function ProfileHeaderAlerts({
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
-
+
{desc.name}
diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
index 5abdf2f7..62415743 100644
--- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
+++ b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
@@ -3,8 +3,8 @@ import {AppBskyFeedDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
import {useStores} from 'state/index'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
-import {CustomFeed} from 'view/com/feeds/CustomFeed'
+import {FeedSourceModel} from 'state/models/content/feed-source'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
export function CustomFeedEmbed({
record,
@@ -13,12 +13,13 @@ export function CustomFeedEmbed({
}) {
const pal = usePalette('default')
const store = useStores()
- const item = useMemo(
- () => new CustomFeedModel(store, record),
- [store, record],
- )
+ const item = useMemo(() => {
+ const model = new FeedSourceModel(store, record.uri)
+ model.hydrateFeedGenerator(record)
+ return model
+ }, [store, record])
return (
-
}
- // list embed (e.g. mute lists; i.e. ListView)
+ // list embed
if (AppBskyGraphDefs.isListView(embed.record)) {
return
}
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
deleted file mode 100644
index f9383639..00000000
--- 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}
-
-
-
-
-
- Go Back
-
-
-
-
-
- )
- }
-
- 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?.isLiked ? (
-
- ) : (
-
- )}
-
- {currentFeed?.isSaved ? (
-
-
-
- ) : (
-
-
-
- Add{!isMobile && ' to My Feeds'}
-
-
- )}
- >
- ) : 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/Feeds.tsx b/src/view/screens/Feeds.tsx
index fc4d99cf..383bbcaa 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -2,7 +2,6 @@ import React from 'react'
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 {ErrorMessage} from 'view/com/util/error/ErrorMessage'
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 @@ export const FeedsScreen = withAuthRequired(
}
}, [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 @@ export const FeedsScreen = withAuthRequired(
>
)
} else if (item.type === 'saved-feed') {
- return (
-
- )
+ return
} else if (item.type === 'discover-feeds-header') {
return (
<>
@@ -187,7 +185,7 @@ export const FeedsScreen = withAuthRequired(
)
} 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 @@ export const HomeScreen = withAuthRequired(
{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.
+
+
+
+
+
+
+ New
+
+
+
+
+
+
+ )
+ }),
+)
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 23a808fe..d24bc145 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -66,9 +66,9 @@ export const ModerationScreen = withAuthRequired(
+ 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.
+
+
+
+
+
+
+ New
+
+
+
+
+
+
+ )
+ }),
+)
diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx
deleted file mode 100644
index bc933c24..00000000
--- 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
index 596bda57..c1ab6931 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -25,8 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
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 @@ export const ProfileScreen = withAuthRequired(
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
index 00000000..70e52bf7
--- /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}
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ )
+ }
+
+ 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 && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ )
+ }, [
+ pal,
+ feedOwnerDid,
+ rkey,
+ feedInfo,
+ isPinned,
+ onTogglePinned,
+ onToggleSaved,
+ dropdownItems,
+ ])
+
+ return (
+
+
+ {({onScroll, headerHeight, isScrolledDown}) => (
+
+ )}
+ {({onScroll, headerHeight}) => (
+
+
+
+ )}
+
+ store.shell.openComposer({})}
+ icon={
+
+ }
+ accessibilityRole="button"
+ accessibilityLabel="New post"
+ accessibilityHint=""
+ />
+
+ )
+ },
+)
+
+interface FeedSectionProps {
+ feed: PostsFeedModel
+ onScroll: OnScrollCb
+ headerHeight: number
+ isScrolledDown: boolean
+}
+const FeedSection = React.forwardRef(
+ function FeedSectionImpl(
+ {feed, onScroll, headerHeight, isScrolledDown},
+ ref,
+ ) {
+ const hasNew = feed.hasNewLatest && !feed.isRefreshing
+ const scrollElRef = React.useRef(null)
+
+ const onScrollToTop = useCallback(() => {
+ scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+ }, [scrollElRef, headerHeight])
+
+ const onPressLoadLatest = React.useCallback(() => {
+ onScrollToTop()
+ feed.refresh()
+ }, [feed, onScrollToTop])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderPostsEmpty = useCallback(() => {
+ return
+ }, [])
+
+ return (
+
+
+ {(isScrolledDown || hasNew) && (
+
+ )}
+
+ )
+ },
+)
+
+const AboutSection = observer(function AboutPageImpl({
+ feedOwnerDid,
+ feedRkey,
+ feedInfo,
+ onToggleLiked,
+}: {
+ feedOwnerDid: string
+ feedRkey: string
+ feedInfo: FeedSourceModel | undefined
+ onToggleLiked: () => void
+}) {
+ const pal = usePalette('default')
+
+ if (!feedInfo) {
+ return
+ }
+ return (
+
+ {feedInfo.descriptionRT ? (
+
+ ) : (
+
+ No description
+
+ )}
+
+
+ {feedInfo?.isLiked ? (
+
+ ) : (
+
+ )}
+
+ {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/CustomFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx
similarity index 84%
rename from src/view/screens/CustomFeedLikedBy.tsx
rename to src/view/screens/ProfileFeedLikedBy.tsx
index 49d0d048..2e9d12aa 100644
--- a/src/view/screens/CustomFeedLikedBy.tsx
+++ b/src/view/screens/ProfileFeedLikedBy.tsx
@@ -8,8 +8,8 @@ import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedB
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
-type Props = NativeStackScreenProps
-export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => {
+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)
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 11a847db..859f50be 100644
--- 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 (
-
-
-
+
+
+
+ Go Back
+
+
+
+
+
+ )
+ }
+
+ 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 ? (
+
+ ) : list.isModlist ? (
+ list.isBlocking ? (
+
+ ) : list.isMuting ? (
+
+ ) : (
+
+
+ Subscribe
+
+
+ )
+ ) : null}
+
+
+
+
+
+
+ )
+})
+
+interface FeedSectionProps {
+ feed: PostsFeedModel
+ onScroll: OnScrollCb
+ headerHeight: number
+ isScrolledDown: boolean
+}
+const FeedSection = React.forwardRef(
+ function FeedSectionImpl(
+ {feed, onScroll, headerHeight, isScrolledDown},
+ ref,
+ ) {
+ const hasNew = feed.hasNewLatest && !feed.isRefreshing
+ const scrollElRef = React.useRef(null)
+
+ const onScrollToTop = useCallback(() => {
+ scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+ }, [scrollElRef, headerHeight])
+
+ const onPressLoadLatest = React.useCallback(() => {
+ onScrollToTop()
+ feed.refresh()
+ }, [feed, onScrollToTop])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderPostsEmpty = useCallback(() => {
+ return
+ }, [])
+
+ return (
+
+
+ {(isScrolledDown || hasNew) && (
+
+ )}
+
+ )
+ },
+)
+
+interface AboutSectionProps {
+ list: ListModel
+ descriptionRT: RichTextAPI | null
+ creator: {did: string; handle: string} | undefined
+ isCurateList: boolean | undefined
+ isOwner: boolean | undefined
+ onPressAddUser: () => void
+ onScroll: OnScrollCb
+ headerHeight: number
+ isScrolledDown: boolean
+}
+const AboutSection = React.forwardRef(
+ function AboutSectionImpl(
+ {
+ list,
+ descriptionRT,
+ creator,
+ isCurateList,
+ isOwner,
+ onPressAddUser,
+ onScroll,
+ headerHeight,
+ isScrolledDown,
+ },
+ ref,
+ ) {
+ const pal = usePalette('default')
+ const {isMobile} = useWebMediaQueries()
+ const scrollElRef = React.useRef(null)
+
+ const onScrollToTop = useCallback(() => {
+ scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+ }, [scrollElRef, headerHeight])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderHeader = React.useCallback(() => {
+ if (!list.data) {
+ return
+ }
+ return (
+
+
+ {descriptionRT ? (
+
+ ) : (
+
+ No description
+
+ )}
+
+ {isCurateList ? 'User list' : 'Moderation list'} by{' '}
+ {isOwner ? (
+ 'you'
+ ) : (
+
+ )}
+
+
+
+ Users
+ {isOwner && (
+
+
+ Add
+
+ )}
+
+
+ )
+ }, [
+ pal,
+ list.data,
+ isMobile,
+ descriptionRT,
+ creator,
+ isCurateList,
+ isOwner,
+ onPressAddUser,
+ ])
+
+ const renderEmptyState = useCallback(() => {
+ return (
+
+ )
+ }, [])
+
+ return (
+
+
+ {isScrolledDown && (
+
+ )}
+
+ )
+ },
+)
+
+const styles = StyleSheet.create({
+ btn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingVertical: 7,
+ paddingHorizontal: 14,
+ borderRadius: 50,
+ marginLeft: 6,
},
})
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 5253c5bd..8f8cdc6c 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
+import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -25,9 +26,9 @@ import DraggableFlatList, {
ShadowDecorator,
ScaleDecorator,
} from 'react-native-draggable-flatlist'
-import {CustomFeed} from 'view/com/feeds/CustomFeed'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {FeedSourceModel} from 'state/models/content/feed-source'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics'
import {Link, TextLink} from 'view/com/util/Link'
@@ -41,7 +42,11 @@ export const SavedFeeds = withAuthRequired(
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
- const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
+ const savedFeeds = useMemo(() => {
+ const model = new SavedFeedsModel(store)
+ model.refresh()
+ return model
+ }, [store])
useFocusEffect(
useCallback(() => {
screen('SavedFeeds')
@@ -102,7 +107,7 @@ export const SavedFeeds = withAuthRequired(
const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
const onDragEnd = useCallback(
- async ({data}: {data: CustomFeedModel[]}) => {
+ async ({data}: {data: FeedSourceModel[]}) => {
try {
await savedFeeds.reorderPinnedFeeds(data)
} catch (e) {
@@ -123,8 +128,8 @@ export const SavedFeeds = withAuthRequired(
item.data.uri}
+ data={savedFeeds.pinned.concat(savedFeeds.unpinned)}
+ keyExtractor={item => item.uri}
refreshing={savedFeeds.isRefreshing}
refreshControl={
}
- renderItem={({item, drag}) => }
+ renderItem={({item, drag}) => (
+
+ )}
getItemLayout={(data, index) => ({
length: 77,
offset: 77 * index,
@@ -152,24 +159,25 @@ export const SavedFeeds = withAuthRequired(
)
const ListItem = observer(function ListItemImpl({
+ savedFeeds,
item,
drag,
}: {
- item: CustomFeedModel
+ savedFeeds: SavedFeedsModel
+ item: FeedSourceModel
drag: () => void
}) {
const pal = usePalette('default')
const store = useStores()
- const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
- const isPinned = savedFeeds.isPinned(item)
+ const isPinned = item.isPinned
const onTogglePinned = useCallback(() => {
Haptics.default()
- savedFeeds.togglePinnedFeed(item).catch(e => {
+ item.togglePin().catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
- }, [savedFeeds, item, store])
+ }, [item, store])
const onPressUp = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
@@ -222,8 +230,8 @@ const ListItem = observer(function ListItemImpl({
style={s.ml20}
/>
) : null}
- {
+ track('Menu:ItemClicked', {url: 'Lists'})
+ navigation.navigate('Lists')
+ store.shell.closeDrawer()
+ }, [navigation, track, store.shell])
+
const onPressModeration = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Moderation'})
navigation.navigate('Moderation')
@@ -276,6 +283,13 @@ export const DrawerContent = observer(function DrawerContentImpl() {
bold={isAtFeeds}
onPress={onPressMyFeeds}
/>
+ }
+ label="Lists"
+ accessibilityLabel="Lists"
+ accessibilityHint=""
+ onPress={onPressLists}
+ />
}
label="Moderation"
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 3f206388..3237d2cd 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -1,16 +1,17 @@
import React from 'react'
import {View, StyleSheet} from 'react-native'
import {useNavigationState} from '@react-navigation/native'
-import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
+import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems'
import {TextLink} from 'view/com/util/Link'
import {getCurrentRoute} from 'lib/routes/helpers'
export const DesktopFeeds = observer(function DesktopFeeds() {
const store = useStores()
const pal = usePalette('default')
+ const items = useDesktopRightNavItems(store.preferences.pinnedFeeds)
const route = useNavigationState(state => {
if (!state) {
@@ -22,20 +23,22 @@ export const DesktopFeeds = observer(function DesktopFeeds() {
return (
- {store.me.savedFeeds.pinned.map(feed => {
+ {items.map(item => {
try {
- const {hostname, rkey} = new AtUri(feed.uri)
- const href = `/profile/${hostname}/feed/${rkey}`
const params = route.params as Record
+ const routeName =
+ item.collection === 'app.bsky.feed.generator'
+ ? 'ProfileFeed'
+ : 'ProfileList'
return (
)
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 2679a664..39271605 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -31,8 +31,9 @@ import {
CogIcon,
CogIconSolid,
ComposeIcon2,
- HandIcon,
+ ListIcon,
HashtagIcon,
+ HandIcon,
} from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
@@ -319,13 +320,31 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
}
label="Notifications"
/>
+
+ }
+ iconFilled={
+
+ }
+ label="Lists"
+ />
}
iconFilled={
diff --git a/yarn.lock b/yarn.lock
index 906e8465..1611786f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8202,6 +8202,11 @@ deprecated-react-native-prop-types@4.1.0:
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"
@@ -18340,6 +18345,13 @@ use-callback-ref@^1.3.0:
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"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6"