Lists updates: curate lists and blocklists (#1689)

* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
zio/stable
Paul Frazee 2023-11-01 16:15:40 -07:00 committed by GitHub
parent f9944b55e2
commit f57a8cf8ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 4090 additions and 1988 deletions

View File

@ -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()
})
})

View File

@ -2,7 +2,7 @@
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mute lists', () => { describe('Mod lists', () => {
beforeAll(async () => { beforeAll(async () => {
await createServer('?users&follows&labels') await createServer('?users&follows&labels')
await openApp({ 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 expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice() await loginAsAlice()
await element(by.id('e2eGotoModeration')).tap() 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 expect(element(by.id('list-Muted Users'))).toBeVisible()
await element(by.id('list-Muted Users')).tap() await element(by.id('list-Muted Users')).tap()
await expect( await expect(
@ -22,101 +22,128 @@ describe('Mute lists', () => {
).toBeVisible() ).toBeVisible()
}) })
it('Toggle subscription', async () => { it('Toggle mute subscription', async () => {
await element(by.id('unsubscribeListBtn')).tap() await element(by.id('unmuteBtn')).tap()
await element(by.id('subscribeListBtn')).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 () => { it('Edit display name and description via the edit modlist modal', async () => {
await element(by.id('editListBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 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')).clearText()
await element(by.id('editNameInput')).typeText('Bad Ppl') await element(by.id('editNameInput')).typeText('Bad Ppl')
await element(by.id('editDescriptionInput')).clearText() await element(by.id('editDescriptionInput')).clearText()
await element(by.id('editDescriptionInput')).typeText('They bad') await element(by.id('editDescriptionInput')).typeText('They bad')
await element(by.id('saveBtn')).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('listName'))).toHaveText('Bad Ppl') await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They bad') await expect(element(by.id('listDescription'))).toHaveText('They bad')
// have to wait for the toast to clear // have to wait for the toast to clear
await waitFor(element(by.id('editListBtn'))) await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() .toBeVisible()
.withTimeout(5000) .withTimeout(5000)
}) })
it('Remove description via the edit mutelist modal', async () => { it('Remove description via the edit modlist modal', async () => {
await element(by.id('editListBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 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('editDescriptionInput')).clearText()
await element(by.id('saveBtn')).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('listDescription'))).not.toBeVisible() await expect(element(by.id('listDescription'))).not.toBeVisible()
// have to wait for the toast to clear // have to wait for the toast to clear
await waitFor(element(by.id('editListBtn'))) await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() .toBeVisible()
.withTimeout(5000) .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 expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('editListBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() await element(by.text('Edit List Details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap() await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap() await element(by.text('Library')).tap()
await sleep(3e3) await sleep(3e3)
await element(by.id('saveBtn')).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('userAvatarImage'))).toExist() await expect(element(by.id('userAvatarImage'))).toExist()
// have to wait for the toast to clear // have to wait for the toast to clear
await waitFor(element(by.id('editListBtn'))) await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() .toBeVisible()
.withTimeout(5000) .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 expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('editListBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() await element(by.text('Edit List Details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap() await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap() await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).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() await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear // have to wait for the toast to clear
await waitFor(element(by.id('editListBtn'))) await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() .toBeVisible()
.withTimeout(5000) .withTimeout(5000)
}) })
it('Delete the mutelist', async () => { it('Delete the modlist', async () => {
await element(by.id('deleteListBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Delete List')).tap()
await element(by.id('confirmBtn')).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 () => { it('Create a new modlist', async () => {
await element(by.id('emptyMuteLists-button')).tap() await element(by.id('newModListBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).typeText('Bad Ppl') await element(by.id('editNameInput')).typeText('Bad Ppl')
await element(by.id('editDescriptionInput')).typeText('They bad') await element(by.id('editDescriptionInput')).typeText('They bad')
await element(by.id('saveBtn')).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('listName'))).toHaveText('Bad Ppl') await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They bad') 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('bottomBarProfileBtn')).tap()
await element(by.id('selector')).swipe('left') await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap() await element(by.id('selector-4')).tap()
await element(by.id('list-Bad Ppl')).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('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('bob') await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap() await element(by.id('searchAutoCompleteResult-bob.test')).tap()
@ -124,17 +151,17 @@ describe('Mute lists', () => {
await element(by.id('profileHeaderDropdownBtn')).tap() await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).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('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).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.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).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('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).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 () => { 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')).swipe('left')
await element(by.id('selector-3')).tap() await element(by.id('selector-3')).tap()
await element(by.id('list-Bad Ppl')).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.id('reportModal'))).toBeVisible()
await expect(element(by.text('Report List'))).toBeVisible() await expect(element(by.text('Report List'))).toBeVisible()
await element( await element(

View File

@ -181,8 +181,9 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric) e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric) e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric) e.GET("/notifications", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", 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/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric) e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/settings", server.WebGeneric) e.GET("/settings", server.WebGeneric)

View File

@ -157,6 +157,7 @@
"sentry-expo": "~7.0.1", "sentry-expo": "~7.0.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tlds": "^1.234.0", "tlds": "^1.234.0",
"use-deep-compare": "^1.1.0",
"zeego": "^1.6.2", "zeego": "^1.6.2",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
@ -236,6 +237,9 @@
"json", "json",
"node" "node"
], ],
"transform": {
"\\.[jt]sx?$": "babel-jest"
},
"transformIgnorePatterns": [ "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)" "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)"
], ],

View File

@ -43,16 +43,17 @@ import {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search' import {SearchScreen} from './view/screens/Search'
import {FeedsScreen} from './view/screens/Feeds' import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications' import {NotificationsScreen} from './view/screens/Notifications'
import {ListsScreen} from './view/screens/Lists'
import {ModerationScreen} from './view/screens/Moderation' 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 {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings' import {SettingsScreen} from './view/screens/Settings'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings' import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ProfileScreen} from './view/screens/Profile' import {ProfileScreen} from './view/screens/Profile'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows' import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {CustomFeedScreen} from './view/screens/CustomFeed' import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy' import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileListScreen} from './view/screens/ProfileList' import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread' import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostLikedByScreen} from './view/screens/PostLikedBy'
@ -95,15 +96,20 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => NotFoundScreen} getComponent={() => NotFoundScreen}
options={{title: title('Not Found')}} options={{title: title('Not Found')}}
/> />
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists')}}
/>
<Stack.Screen <Stack.Screen
name="Moderation" name="Moderation"
getComponent={() => ModerationScreen} getComponent={() => ModerationScreen}
options={{title: title('Moderation')}} options={{title: title('Moderation')}}
/> />
<Stack.Screen <Stack.Screen
name="ModerationMuteLists" name="ModerationModlists"
getComponent={() => ModerationMuteListsScreen} getComponent={() => ModerationModlistsScreen}
options={{title: title('Mute Lists')}} options={{title: title('Moderation Lists')}}
/> />
<Stack.Screen <Stack.Screen
name="ModerationMutedAccounts" name="ModerationMutedAccounts"
@ -150,7 +156,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen <Stack.Screen
name="ProfileList" name="ProfileList"
getComponent={() => ProfileListScreen} getComponent={() => ProfileListScreen}
options={{title: title('Mute List')}} options={{title: title('List')}}
/> />
<Stack.Screen <Stack.Screen
name="PostThread" name="PostThread"
@ -168,13 +174,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={({route}) => ({title: title(`Post by @${route.params.name}`)})} options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/> />
<Stack.Screen <Stack.Screen
name="CustomFeed" name="ProfileFeed"
getComponent={() => CustomFeedScreen} getComponent={() => ProfileFeedScreen}
options={{title: title('Feed')}} options={{title: title('Feed')}}
/> />
<Stack.Screen <Stack.Screen
name="CustomFeedLikedBy" name="ProfileFeedLikedBy"
getComponent={() => CustomFeedLikedByScreen} getComponent={() => ProfileFeedLikedByScreen}
options={{title: title('Liked by')}} options={{title: title('Liked by')}}
/> />
<Stack.Screen <Stack.Screen

View File

@ -97,10 +97,13 @@ interface TrackPropertiesMap {
// LISTS events // LISTS events
'Lists:onRefresh': {} 'Lists:onRefresh': {}
'Lists:onEndReached': {} 'Lists:onEndReached': {}
'CreateMuteList:AvatarSelected': {} 'CreateList:AvatarSelected': {}
'CreateMuteList:Save': {} // CAN BE SERVER 'CreateList:SaveCurateList': {} // CAN BE SERVER
'Lists:Subscribe': {} // CAN BE SERVER 'CreateList:SaveModList': {} // CAN BE SERVER
'Lists:Unsubscribe': {} // CAN BE SERVER 'Lists:Mute': {} // CAN BE SERVER
'Lists:Unmute': {} // CAN BE SERVER
'Lists:Block': {} // CAN BE SERVER
'Lists:Unblock': {} // CAN BE SERVER
// CUSTOM FEED events // CUSTOM FEED events
'CustomFeed:Save': {} 'CustomFeed:Save': {}
'CustomFeed:Unsave': {} 'CustomFeed:Unsave': {}

View File

@ -0,0 +1,45 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetListFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
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<FeedAPIResponse> {
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: [],
}
}
}

View File

@ -114,13 +114,8 @@ export class MergeFeedAPI implements FeedAPI {
} }
if (this.customFeeds.length === 0) { if (this.customFeeds.length === 0) {
this.customFeeds = shuffle( this.customFeeds = shuffle(
this.rootStore.me.savedFeeds.all.map( this.rootStore.preferences.savedFeeds.map(
feed => feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
new MergeFeedSource_Custom(
this.rootStore,
feed.uri,
feed.displayName,
),
), ),
) )
} }
@ -213,43 +208,56 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date minDate: Date
constructor( constructor(public rootStore: RootStoreModel, public feedUri: string) {
public rootStore: RootStoreModel,
public feedUri: string,
public feedDisplayName: string,
) {
super(rootStore) super(rootStore)
this.sourceInfo = { this.sourceInfo = {
displayName: feedDisplayName, displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri), uri: feedUriToHref(feedUri),
} }
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) 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( protected async _getFeed(
cursor: string | undefined, cursor: string | undefined,
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({ try {
cursor, const res = await this.rootStore.agent.app.bsky.feed.getFeed({
limit, cursor,
feed: this.feedUri, limit,
}) feed: this.feedUri,
// NOTE })
// some custom feeds fail to enforce the pagination limit // NOTE
// so we manually truncate here // some custom feeds fail to enforce the pagination limit
// -prf // so we manually truncate here
if (limit && res.data.feed.length > limit) { // -prf
res.data.feed = res.data.feed.slice(0, limit) 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
} }
} }

View File

@ -0,0 +1,25 @@
export interface AccumulateResponse<T> {
cursor?: string
items: T[]
}
export type AccumulateFetchFn<T> = (
cursor: string | undefined,
) => Promise<AccumulateResponse<T>>
export async function accumulate<T>(
fn: AccumulateFetchFn<T>,
pageLimit = 100,
): Promise<T[]> {
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
}

View File

@ -0,0 +1,24 @@
import {timeout} from './timeout'
export async function until(
retries: number,
delay: number,
cond: (v: any, err: any) => boolean,
fn: () => Promise<any>,
): Promise<boolean> {
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
}

View File

@ -1,24 +1,15 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {useStores} from 'state/index' 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 store = useStores()
const [item, setItem] = useState<CustomFeedModel | undefined>() const [item, setItem] = useState<FeedSourceModel | undefined>()
useEffect(() => { 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() { async function buildFeedItem() {
const view = await fetchView() const model = new FeedSourceModel(store, uri)
if (view) { await model.setup()
const temp = new CustomFeedModel(store, view) setItem(model)
setItem(temp)
}
} }
buildFeedItem() buildFeedItem()
}, [store, uri]) }, [store, uri])

View File

@ -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<RightNavItem[]>([])
const [lastUris, setLastUris] = useState<string[]>([])
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
}

View File

@ -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<string[]>(['Following'])
const [lastUris, setLastUris] = useState<string[]>([])
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
}

View File

@ -947,3 +947,30 @@ export function ShieldExclamation({
</Svg> </Svg>
) )
} }
export function ListIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1.5}
stroke="currentColor"
width={size}
height={size}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</Svg>
)
}

View File

@ -17,9 +17,18 @@ export function describeModerationCause(
} }
} }
if (cause.type === 'blocking') { if (cause.type === 'blocking') {
return { if (cause.source.type === 'list') {
name: 'User Blocked', return {
description: 'You have blocked this user. You cannot view their content.', 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') { if (cause.type === 'blocked-by') {

View File

@ -13,3 +13,15 @@ export function makeProfileLink(
...segments, ...segments,
].join('/') ].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('/')
}

View File

@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
export type CommonNavigatorParams = { export type CommonNavigatorParams = {
NotFound: undefined NotFound: undefined
Lists: undefined
Moderation: undefined Moderation: undefined
ModerationMuteLists: undefined ModerationModlists: undefined
ModerationMutedAccounts: undefined ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined ModerationBlockedAccounts: undefined
Settings: undefined Settings: undefined
@ -18,8 +19,8 @@ export type CommonNavigatorParams = {
PostThread: {name: string; rkey: string} PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string}
CustomFeed: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string}
CustomFeedLikedBy: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string}
Debug: undefined Debug: undefined
Log: undefined Log: undefined
Support: undefined Support: undefined

View File

@ -7,8 +7,9 @@ export const router = new Router({
Notifications: '/notifications', Notifications: '/notifications',
Settings: '/settings', Settings: '/settings',
LanguageSettings: '/settings/language', LanguageSettings: '/settings/language',
Lists: '/lists',
Moderation: '/moderation', Moderation: '/moderation',
ModerationMuteLists: '/moderation/mute-lists', ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts', ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts',
Profile: '/profile/:name', Profile: '/profile/:name',
@ -18,8 +19,8 @@ export const router = new Router({
PostThread: '/profile/:name/post/:rkey', PostThread: '/profile/:name/post/:rkey',
PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostLikedBy: '/profile/:name/post/:rkey/liked-by',
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
CustomFeed: '/profile/:name/feed/:rkey', ProfileFeed: '/profile/:name/feed/:rkey',
CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
Debug: '/sys/debug', Debug: '/sys/debug',
Log: '/sys/log', Log: '/sys/log',
AppPasswords: '/settings/app-passwords', AppPasswords: '/settings/app-passwords',

View File

@ -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')
}
}
}

View File

@ -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) { for (const uri of uris) {
await this.add(uri) await this.add(uri)
added.push(uri)
} }
for (const membership of this.memberships) { for (const membership of this.memberships) {
if (!uris.includes(membership.value.list)) { if (!uris.includes(membership.value.list)) {
await this.remove(membership.value.list) await this.remove(membership.value.list)
removed.push(membership.value.list)
} }
} }
return {added, removed}
} }
} }

View File

@ -1,10 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AtUri, AtUri,
AppBskyActorDefs,
AppBskyGraphGetList as GetList, AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs, AppBskyGraphDefs as GraphDefs,
AppBskyGraphList, AppBskyGraphList,
AppBskyGraphListitem, AppBskyGraphListitem,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {track} from 'lib/analytics/analytics' import {track} from 'lib/analytics/analytics'
import {until} from 'lib/async/until'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -37,19 +40,32 @@ export class ListModel {
loadMoreCursor?: string loadMoreCursor?: string
// data // data
list: GraphDefs.ListView | null = null data: GraphDefs.ListView | null = null
items: GraphDefs.ListItemView[] = [] items: GraphDefs.ListItemView[] = []
descriptionRT: RichText | null = null
static async createModList( static async createList(
rootStore: RootStoreModel, rootStore: RootStoreModel,
{ {
purpose,
name, name,
description, description,
avatar, 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 = { const record: AppBskyGraphList.Record = {
purpose: 'app.bsky.graph.defs#modlist', purpose,
name, name,
description, description,
avatar: undefined, avatar: undefined,
@ -69,7 +85,20 @@ export class ListModel {
}, },
record, 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 return res
} }
@ -95,16 +124,40 @@ export class ListModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get isOwner() { get isCuratelist() {
return this.list?.creator.did === this.rootStore.me.did return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
} }
get isSubscribed() { get isModlist() {
return this.list?.viewer?.muted 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() { 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 // 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({ async updateMetadata({
name, name,
description, description,
@ -146,7 +208,7 @@ export class ListModel {
description: string description: string
avatar: RNImage | null | undefined avatar: RNImage | null | undefined
}) { }) {
if (!this.list) { if (!this.data) {
return return
} }
if (!this.isOwner) { if (!this.isOwner) {
@ -183,7 +245,7 @@ export class ListModel {
} }
async delete() { async delete() {
if (!this.list) { if (!this.data) {
return return
} }
await this._resolveUri() await this._resolveUri()
@ -231,28 +293,140 @@ export class ListModel {
this.rootStore.emitListDeleted(this.uri) this.rootStore.emitListDeleted(this.uri)
} }
async subscribe() { async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (!this.list) { if (this.isMember(profile.did)) {
return return
} }
await this._resolveUri() await this.rootStore.agent.app.bsky.graph.listitem.create(
await this.rootStore.agent.app.bsky.graph.muteActorList({ {
list: this.list.uri, 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 return
} }
await this._resolveUri() await this._resolveUri()
await this.rootStore.agent.app.bsky.graph.unmuteActorList({ await this.rootStore.agent.muteModList(this.data.uri)
list: this.list.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) { _appendAll(res: GetList.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.list = res.data.list this.data = res.data.list
this.items = this.items.concat( this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject.did})), 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
}
} }
} }

View File

@ -22,7 +22,8 @@ export class ProfileViewerModel {
following?: string following?: string
followedBy?: string followedBy?: string
blockedBy?: boolean blockedBy?: boolean
blocking?: string; blocking?: string
blockingByList?: AppBskyGraphDefs.ListViewBasic;
[key: string]: unknown [key: string]: unknown
constructor() { constructor() {

View File

@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed' import {FeedSourceModel} from '../content/feed-source'
const DEFAULT_LIMIT = 50 const DEFAULT_LIMIT = 50
@ -16,7 +16,7 @@ export class FeedsDiscoveryModel {
loadMoreCursor: string | undefined = undefined loadMoreCursor: string | undefined = undefined
// data // data
feeds: CustomFeedModel[] = [] feeds: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@ -137,7 +137,9 @@ export class FeedsDiscoveryModel {
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. push data into feeds array // 1. push data into feeds array
for (const f of res.data.feeds) { 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 // 2. set loadMoreCursor
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor

View File

@ -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)
}
}

View File

@ -4,6 +4,7 @@ import {
AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetAuthorFeed as GetAuthorFeed,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
AppBskyFeedGetActorLikes as GetActorLikes, AppBskyFeedGetActorLikes as GetActorLikes,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle' 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 {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes' import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom' import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge' import {MergeFeedAPI} from 'lib/api/feed/merge'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -36,6 +38,7 @@ type QueryParams =
| GetAuthorFeed.QueryParams | GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams | GetActorLikes.QueryParams
| GetCustomFeed.QueryParams | GetCustomFeed.QueryParams
| GetListFeed.QueryParams
export class PostsFeedModel { export class PostsFeedModel {
// state // state
@ -66,7 +69,13 @@ export class PostsFeedModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', public feedType:
| 'home'
| 'following'
| 'author'
| 'custom'
| 'likes'
| 'list',
params: QueryParams, params: QueryParams,
options?: Options, options?: Options,
) { ) {
@ -99,11 +108,26 @@ export class PostsFeedModel {
rootStore, rootStore,
params as GetCustomFeed.QueryParams, params as GetCustomFeed.QueryParams,
) )
} else if (feedType === 'list') {
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
} else { } else {
this.api = new FollowingFeedAPI(rootStore) 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() { get hasContent() {
return this.slices.length !== 0 return this.slices.length !== 0
} }
@ -117,7 +141,7 @@ export class PostsFeedModel {
} }
get isLoadingMore() { get isLoadingMore() {
return this.isLoading && !this.isRefreshing return this.isLoading && !this.isRefreshing && this.hasContent
} }
setHasNewLatest(v: boolean) { setHasNewLatest(v: boolean) {

View File

@ -3,7 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed' import {FeedSourceModel} from '../content/feed-source'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -17,7 +17,7 @@ export class ActorFeedsModel {
loadMoreCursor?: string loadMoreCursor?: string
// data // data
feeds: CustomFeedModel[] = [] feeds: FeedSourceModel[] = []
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
@ -114,7 +114,9 @@ export class ActorFeedsModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) { 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)
} }
} }
} }

View File

@ -1,12 +1,9 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
AppBskyGraphGetLists as GetLists,
AppBskyGraphGetListMutes as GetListMutes,
AppBskyGraphDefs as GraphDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {accumulate} from 'lib/async/accumulate'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -25,7 +22,7 @@ export class ListsListModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
public source: 'my-modlists' | string, public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
) { ) {
makeAutoObservable( makeAutoObservable(
this, this,
@ -48,6 +45,26 @@ export class ListsListModel {
return this.hasLoaded && !this.hasContent 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. * Removes posts from the feed upon deletion.
*/ */
@ -76,44 +93,85 @@ export class ListsListModel {
} }
this._xLoading(replace) this._xLoading(replace)
try { try {
let res: GetLists.Response let cursor: string | undefined
if (this.source === 'my-modlists') { let lists: GraphDefs.ListView[] = []
res = { if (
success: true, this.source === 'mine' ||
headers: {}, this.source === 'my-curatelists' ||
data: { this.source === 'my-modlists'
subject: undefined, ) {
lists: [], 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([ const resultset = await Promise.all(promises)
fetchAllUserLists(this.rootStore, this.rootStore.me.did), for (const res of resultset) {
fetchAllMyMuteLists(this.rootStore), for (let list of res) {
]) if (
for (let list of res1.data.lists) { this.source === 'my-curatelists' &&
if (list.purpose === 'app.bsky.graph.defs#modlist') { list.purpose !== 'app.bsky.graph.defs#curatelist'
res.data.lists.push(list) ) {
} continue
} }
for (let list of res2.data.lists) { if (
if ( this.source === 'my-modlists' &&
list.purpose === 'app.bsky.graph.defs#modlist' && list.purpose !== 'app.bsky.graph.defs#modlist'
!res.data.lists.find(l => l.uri === list.uri) ) {
) { continue
res.data.lists.push(list) }
if (!lists.find(l => l.uri === list.uri)) {
lists.push(list)
}
} }
} }
} else { } else {
res = await this.rootStore.agent.app.bsky.graph.getLists({ const res = await this.rootStore.agent.app.bsky.graph.getLists({
actor: this.source, actor: this.source,
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor, cursor: replace ? undefined : this.loadMoreCursor,
}) })
lists = res.data.lists
cursor = res.data.cursor
} }
if (replace) { if (replace) {
this._replaceAll(res) this._replaceAll({lists, cursor})
} else { } else {
this._appendAll(res) this._appendAll({lists, cursor})
} }
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
@ -156,75 +214,28 @@ export class ListsListModel {
// helper functions // helper functions
// = // =
_replaceAll(res: GetLists.Response | GetListMutes.Response) { _replaceAll({
lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.lists = [] this.lists = []
this._appendAll(res) this._appendAll({lists, cursor})
} }
_appendAll(res: GetLists.Response | GetListMutes.Response) { _appendAll({
this.loadMoreCursor = res.data.cursor lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.loadMoreCursor = cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.lists = this.lists.concat( 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<GetLists.Response> {
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<GetListMutes.Response> {
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
}

View File

@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications' import {NotificationsFeedModel} from './feeds/notifications'
import {MyFollowsCache} from './cache/my-follows' import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {SavedFeedsModel} from './ui/saved-feeds'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
@ -22,7 +21,6 @@ export class MeModel {
followsCount: number | undefined followsCount: number | undefined
followersCount: number | undefined followersCount: number | undefined
mainFeed: PostsFeedModel mainFeed: PostsFeedModel
savedFeeds: SavedFeedsModel
notifications: NotificationsFeedModel notifications: NotificationsFeedModel
follows: MyFollowsCache follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = [] invites: ComAtprotoServerDefs.InviteCode[] = []
@ -45,7 +43,6 @@ export class MeModel {
}) })
this.notifications = new NotificationsFeedModel(this.rootStore) this.notifications = new NotificationsFeedModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore) this.follows = new MyFollowsCache(this.rootStore)
this.savedFeeds = new SavedFeedsModel(this.rootStore)
} }
clear() { clear() {

View File

@ -1,6 +1,7 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable, reaction} from 'mobx'
import {SavedFeedsModel} from './saved-feeds'
import {FeedsDiscoveryModel} from '../discovery/feeds' import {FeedsDiscoveryModel} from '../discovery/feeds'
import {CustomFeedModel} from '../feeds/custom-feed' import {FeedSourceModel} from '../content/feed-source'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
export type MyFeedsItem = export type MyFeedsItem =
@ -29,7 +30,7 @@ export type MyFeedsItem =
| { | {
_reactKey: string _reactKey: string
type: 'saved-feed' type: 'saved-feed'
feed: CustomFeedModel feed: FeedSourceModel
} }
| { | {
_reactKey: string _reactKey: string
@ -46,21 +47,19 @@ export type MyFeedsItem =
| { | {
_reactKey: string _reactKey: string
type: 'discover-feed' type: 'discover-feed'
feed: CustomFeedModel feed: FeedSourceModel
} }
export class MyFeedsUIModel { export class MyFeedsUIModel {
saved: SavedFeedsModel
discovery: FeedsDiscoveryModel discovery: FeedsDiscoveryModel
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this) makeAutoObservable(this)
this.saved = new SavedFeedsModel(this.rootStore)
this.discovery = new FeedsDiscoveryModel(this.rootStore) this.discovery = new FeedsDiscoveryModel(this.rootStore)
} }
get saved() {
return this.rootStore.me.savedFeeds
}
get isRefreshing() { get isRefreshing() {
return !this.saved.isLoading && this.saved.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() { async refresh() {
return Promise.all([this.saved.refresh(), this.discovery.refresh()]) return Promise.all([this.saved.refresh(), this.discovery.refresh()])
} }

View File

@ -194,7 +194,7 @@ export class PreferencesModel {
/** /**
* This function fetches preferences and sets defaults for missing items. * This function fetches preferences and sets defaults for missing items.
*/ */
async sync({clearCache}: {clearCache?: boolean} = {}) { async sync() {
await this.lock.acquireAsync() await this.lock.acquireAsync()
try { try {
// fetch preferences // fetch preferences
@ -252,8 +252,6 @@ export class PreferencesModel {
} finally { } finally {
this.lock.release() this.lock.release()
} }
await this.rootStore.me.savedFeeds.updateCache(clearCache)
} }
async syncLegacyPreferences() { async syncLegacyPreferences() {
@ -286,6 +284,9 @@ export class PreferencesModel {
} }
} }
// languages
// =
hasContentLanguage(code2: string) { hasContentLanguage(code2: string) {
return this.contentLanguages.includes(code2) return this.contentLanguages.includes(code2)
} }
@ -358,6 +359,9 @@ export class PreferencesModel {
return all.join(', ') return all.join(', ')
} }
// moderation
// =
async setContentLabelPref( async setContentLabelPref(
key: keyof LabelPreferencesModel, key: keyof LabelPreferencesModel,
value: LabelPreference, value: LabelPreference,
@ -409,6 +413,13 @@ export class PreferencesModel {
} }
} }
// feeds
// =
isPinnedFeed(uri: string) {
return this.pinnedFeeds.includes(uri)
}
async _optimisticUpdateSavedFeeds( async _optimisticUpdateSavedFeeds(
saved: string[], saved: string[],
pinned: string[], pinned: string[],
@ -474,6 +485,9 @@ export class PreferencesModel {
) )
} }
// other
// =
async setBirthDate(birthDate: Date) { async setBirthDate(birthDate: Date) {
this.birthDate = birthDate this.birthDate = birthDate
await this.lock.acquireAsync() await this.lock.acquireAsync()
@ -602,7 +616,7 @@ export class PreferencesModel {
} }
getFeedTuners( getFeedTuners(
feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
) { ) {
if (feedType === 'custom') { if (feedType === 'custom') {
return [ return [
@ -610,6 +624,9 @@ export class PreferencesModel {
FeedTuner.preferredLangOnly(this.contentLanguages), FeedTuner.preferredLangOnly(this.contentLanguages),
] ]
} }
if (feedType === 'list') {
return [FeedTuner.dedupReposts]
}
if (feedType === 'home' || feedType === 'following') { if (feedType === 'home' || feedType === 'following') {
const feedTuners = [] const feedTuners = []

View File

@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed' import {FeedSourceModel} from '../content/feed-source'
import {track} from 'lib/analytics/analytics' import {track} from 'lib/analytics/analytics'
export class SavedFeedsModel { export class SavedFeedsModel {
@ -13,7 +13,7 @@ export class SavedFeedsModel {
error = '' error = ''
// data // data
_feedModelCache: Record<string, CustomFeedModel> = {} all: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@ -38,20 +38,11 @@ export class SavedFeedsModel {
} }
get pinned() { get pinned() {
return this.rootStore.preferences.pinnedFeeds return this.all.filter(feed => feed.isPinned)
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
.filter(Boolean)
} }
get unpinned() { get unpinned() {
return this.rootStore.preferences.savedFeeds return this.all.filter(feed => !feed.isPinned)
.filter(uri => !this.isPinned(uri))
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
.filter(Boolean)
}
get all() {
return [...this.pinned, ...this.unpinned]
} }
get pinnedFeedNames() { get pinnedFeedNames() {
@ -61,121 +52,39 @@ export class SavedFeedsModel {
// public api // 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<string, CustomFeedModel> = {}
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 the preferences then reload all feed infos
*/ */
refresh = bundleAsync(async () => { refresh = bundleAsync(async () => {
this._xLoading(true) this._xLoading(true)
try { 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() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(e) this._xIdle(e)
} }
}) })
async save(feed: CustomFeedModel) { async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
try { this._updatePinSortOrder(feeds.map(f => f.uri))
await feed.save() await this.rootStore.preferences.setSavedFeeds(
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(
this.rootStore.preferences.savedFeeds, 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) { async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
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') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice() const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri) const index = pinned.indexOf(item.uri)
if (index === -1) { if (index === -1) {
@ -194,8 +103,9 @@ export class SavedFeedsModel {
this.rootStore.preferences.savedFeeds, this.rootStore.preferences.savedFeeds,
pinned, pinned,
) )
this._updatePinSortOrder()
track('CustomFeed:Reorder', { track('CustomFeed:Reorder', {
name: item.data.displayName, name: item.displayName,
uri: item.uri, uri: item.uri,
index: pinned.indexOf(item.uri), index: pinned.indexOf(item.uri),
}) })
@ -219,4 +129,20 @@ export class SavedFeedsModel {
this.rootStore.log.error('Failed to fetch user feeds', err) 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))
} }

View File

@ -1,4 +1,4 @@
import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile' import {ProfileModel} from '../content/profile'
@ -60,17 +60,25 @@ export type ReportModal = {
| {did: string} | {did: string}
) )
export interface CreateOrEditMuteListModal { export interface CreateOrEditListModal {
name: 'create-or-edit-mute-list' name: 'create-or-edit-list'
purpose?: string
list?: ListModel list?: ListModel
onSave?: (uri: string) => void onSave?: (uri: string) => void
} }
export interface ListAddRemoveUserModal { export interface UserAddRemoveListsModal {
name: 'list-add-remove-user' name: 'user-add-remove-lists'
subject: string subject: string
displayName: 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 { export interface EditImageModal {
@ -180,8 +188,11 @@ export type Modal =
// Moderation // Moderation
| ModerationDetailsModal | ModerationDetailsModal
| ReportModal | ReportModal
| CreateOrEditMuteListModal
| ListAddRemoveUserModal // Lists
| CreateOrEditListModal
| UserAddRemoveListsModal
| ListAddUserModal
// Posts // Posts
| AltTextImageModal | AltTextImageModal

View File

@ -12,7 +12,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {useStores} from 'state/index' 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' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
type Props = { type Props = {
@ -39,7 +39,9 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
} }
return (feeds.length ? feeds : []).map(feed => { 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) { } catch (e) {
return [] return []

View File

@ -3,6 +3,7 @@ import {View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text' 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 {Button} from 'view/com/util/forms/Button'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
@ -10,12 +11,12 @@ import {HeartIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeHandle} from 'lib/strings/handles' 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({ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
item, item,
}: { }: {
item: CustomFeedModel item: FeedSourceModel
}) { }) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
@ -54,7 +55,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
}, },
]}> ]}>
<View style={{marginTop: 2}}> <View style={{marginTop: 2}}>
<UserAvatar type="algo" size={42} avatar={item.data.avatar} /> <UserAvatar type="algo" size={42} avatar={item.avatar} />
</View> </View>
<View style={{flex: isMobile ? 1 : undefined}}> <View style={{flex: isMobile ? 1 : undefined}}>
<Text <Text
@ -65,11 +66,11 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
</Text> </Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
by {sanitizeHandle(item.data.creator.handle, '@')} by {sanitizeHandle(item.creatorHandle, '@')}
</Text> </Text>
{item.data.description ? ( {item.descriptionRT ? (
<Text <RichText
type="xl" type="xl"
style={[ style={[
pal.text, pal.text,
@ -79,9 +80,9 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
marginBottom: 18, marginBottom: 18,
}, },
]} ]}
numberOfLines={6}> richText={item.descriptionRT}
{item.data.description} numberOfLines={6}
</Text> />
) : null} ) : null}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
@ -129,7 +130,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
style={[pal.textLight, {position: 'relative', top: 2}]} style={[pal.textLight, {position: 'relative', top: 2}]}
/> />
<Text type="lg-medium" style={[pal.text, pal.textLight]}> <Text type="lg-medium" style={[pal.text, pal.textLight]}>
{item.data.likeCount || 0} {item.likeCount || 0}
</Text> </Text>
</View> </View>
</View> </View>

View File

@ -111,10 +111,6 @@ export const FeedPage = observer(function FeedPageImpl({
store.shell.openComposer({}) store.shell.openComposer({})
}, [store, track]) }, [store, track])
const onPressTryAgain = React.useCallback(() => {
feed.refresh()
}, [feed])
const onPressLoadLatest = React.useCallback(() => { const onPressLoadLatest = React.useCallback(() => {
scrollToTop() scrollToTop()
feed.refresh() feed.refresh()
@ -179,10 +175,8 @@ export const FeedPage = observer(function FeedPageImpl({
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>
<Feed <Feed
testID={testID ? `${testID}-feed` : undefined} testID={testID ? `${testID}-feed` : undefined}
key="default"
feed={feed} feed={feed}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll} onScroll={onMainScroll}
scrollEventThrottle={100} scrollEventThrottle={100}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}

View File

@ -2,11 +2,12 @@ import React from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {FeedSourceModel} from 'state/models/content/feed-source'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -15,14 +16,14 @@ import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
export const CustomFeed = observer(function CustomFeedImpl({ export const FeedSourceCard = observer(function FeedSourceCardImpl({
item, item,
style, style,
showSaveBtn = false, showSaveBtn = false,
showDescription = false, showDescription = false,
showLikes = false, showLikes = false,
}: { }: {
item: CustomFeedModel item: FeedSourceModel
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showSaveBtn?: boolean showSaveBtn?: boolean
showDescription?: boolean showDescription?: boolean
@ -40,7 +41,7 @@ export const CustomFeed = observer(function CustomFeedImpl({
message: `Remove ${item.displayName} from my feeds?`, message: `Remove ${item.displayName} from my feeds?`,
onPressConfirm: async () => { onPressConfirm: async () => {
try { try {
await store.me.savedFeeds.unsave(item) await item.unsave()
Toast.show('Removed from my feeds') Toast.show('Removed from my feeds')
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') Toast.show('There was an issue contacting your server')
@ -50,7 +51,7 @@ export const CustomFeed = observer(function CustomFeedImpl({
}) })
} else { } else {
try { try {
await store.me.savedFeeds.save(item) await item.save()
Toast.show('Added to my feeds') Toast.show('Added to my feeds')
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') Toast.show('There was an issue contacting your server')
@ -65,22 +66,29 @@ export const CustomFeed = observer(function CustomFeedImpl({
accessibilityRole="button" accessibilityRole="button"
style={[styles.container, pal.border, style]} style={[styles.container, pal.border, style]}
onPress={() => { onPress={() => {
navigation.push('CustomFeed', { if (item.type === 'feed-generator') {
name: item.data.creator.did, navigation.push('ProfileFeed', {
rkey: new AtUri(item.data.uri).rkey, 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}>
<View style={[styles.headerContainer]}> <View style={[styles.headerContainer]}>
<View style={[s.mr10]}> <View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={item.data.avatar} /> <UserAvatar type="algo" size={36} avatar={item.avatar} />
</View> </View>
<View style={[styles.headerTextContainer]}> <View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}> <Text style={[pal.text, s.bold]} numberOfLines={3}>
{item.displayName} {item.displayName}
</Text> </Text>
<Text style={[pal.textLight]} numberOfLines={3}> <Text style={[pal.textLight]} numberOfLines={3}>
by {sanitizeHandle(item.data.creator.handle, '@')} by {sanitizeHandle(item.creatorHandle, '@')}
</Text> </Text>
</View> </View>
{showSaveBtn && ( {showSaveBtn && (
@ -112,16 +120,18 @@ export const CustomFeed = observer(function CustomFeedImpl({
)} )}
</View> </View>
{showDescription && item.data.description ? ( {showDescription && item.descriptionRT ? (
<Text style={[pal.textLight, styles.description]} numberOfLines={3}> <RichText
{item.data.description} style={[pal.textLight, styles.description]}
</Text> richText={item.descriptionRT}
numberOfLines={3}
/>
) : null} ) : null}
{showLikes ? ( {showLikes ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}> <Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {item.data.likeCount || 0}{' '} Liked by {item.likeCount || 0}{' '}
{pluralize(item.data.likeCount || 0, 'user')} {pluralize(item.likeCount || 0, 'user')}
</Text> </Text>
) : null} ) : null}
</Pressable> </Pressable>

View File

@ -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 = [
<Button
key="subscribeListBtn"
testID={muted ? 'unsubscribeListBtn' : 'subscribeListBtn'}
type={muted ? 'inverted' : 'primary'}
label={muted ? 'Unsubscribe' : 'Subscribe & Mute'}
accessibilityLabel={muted ? 'Unsubscribe' : 'Subscribe and mute'}
accessibilityHint=""
onPress={onToggleSubscribed}
/>,
isOwner && (
<Button
key="editListBtn"
testID="editListBtn"
type="default"
label="Edit List"
accessibilityLabel="Edit list"
accessibilityHint=""
onPress={onPressEditList}
/>
),
isOwner && (
<Button
key="deleteListBtn"
testID="deleteListBtn"
type="default"
accessibilityLabel="Delete list"
accessibilityHint=""
onPress={onPressDeleteList}>
<FontAwesomeIcon icon={['far', 'trash-can']} style={[pal.text]} />
</Button>
),
<Button
key="shareListBtn"
testID="shareListBtn"
type="default"
accessibilityLabel="Share list"
accessibilityHint=""
onPress={onPressShareList}>
<FontAwesomeIcon icon={'share'} style={[pal.text]} />
</Button>,
!isOwner && (
<Button
key="reportListBtn"
testID="reportListBtn"
type="default"
accessibilityLabel="Report list"
accessibilityHint=""
onPress={onPressReportList}>
<FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} />
</Button>
),
]
// 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 <View style={styles.headerBtns}>{buttons}</View>
}
const styles = StyleSheet.create({
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
})

View File

@ -76,7 +76,10 @@ export const ListCard = ({
{sanitizeDisplayName(list.name)} {sanitizeDisplayName(list.name)}
</Text> </Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{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 {list.creator.did === store.me.did
? 'you' ? 'you'
: sanitizeHandle(list.creator.handle, '@')} : sanitizeHandle(list.creator.handle, '@')}

View File

@ -3,34 +3,26 @@ import {
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
StyleProp, StyleProp,
StyleSheet,
View, View,
ViewStyle, ViewStyle,
FlatList,
} from 'react-native' } 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 {observer} from 'mobx-react-lite'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {ProfileCard} from '../profile/ProfileCard' import {ProfileCard} from '../profile/ProfileCard'
import {Button} from '../util/forms/Button' 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 {ListModel} from 'state/models/content/list'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {ListActions} from './ListActions' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeHandle} from 'lib/strings/handles'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const HEADER_ITEM = {_reactKey: '__header__'}
const EMPTY_ITEM = {_reactKey: '__empty__'} const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@ -39,36 +31,35 @@ export const ListItems = observer(function ListItemsImpl({
list, list,
style, style,
scrollElRef, scrollElRef,
onScroll,
onPressTryAgain, onPressTryAgain,
onToggleSubscribed, renderHeader,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
renderEmptyState, renderEmptyState,
testID, testID,
scrollEventThrottle,
headerOffset = 0, headerOffset = 0,
desktopFixedHeightOffset,
}: { }: {
list: ListModel list: ListModel
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollCb
onPressTryAgain?: () => void onPressTryAgain?: () => void
onToggleSubscribed: () => void renderHeader: () => JSX.Element
onPressEditList: () => void renderEmptyState: () => JSX.Element
onPressDeleteList: () => void
onPressShareList: () => void
onPressReportList: () => void
renderEmptyState?: () => JSX.Element
testID?: string testID?: string
scrollEventThrottle?: number
headerOffset?: number headerOffset?: number
desktopFixedHeightOffset?: number
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {track} = useAnalytics() const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false) const [isRefreshing, setIsRefreshing] = React.useState(false)
const {isMobile} = useWebMediaQueries()
const data = React.useMemo(() => { const data = React.useMemo(() => {
let items: any[] = [HEADER_ITEM] let items: any[] = []
if (list.hasLoaded) { if (list.hasLoaded) {
if (list.hasError) { if (list.hasError) {
items = items.concat([ERROR_ITEM]) items = items.concat([ERROR_ITEM])
@ -124,11 +115,18 @@ export const ListItems = observer(function ListItemsImpl({
const onPressEditMembership = React.useCallback( const onPressEditMembership = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => { (profile: AppBskyActorDefs.ProfileViewBasic) => {
store.shell.openModal({ store.shell.openModal({
name: 'list-add-remove-user', name: 'user-add-remove-lists',
subject: profile.did, subject: profile.did,
displayName: profile.displayName || profile.handle, displayName: profile.displayName || profile.handle,
onUpdate() { onAdd(listUri: string) {
list.refresh() 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 ( return (
<Button <Button
testID={`user-${profile.handle}-editBtn`}
type="default" type="default"
label="Edit" label="Edit"
onPress={() => onPressEditMembership(profile)} onPress={() => onPressEditMembership(profile)}
@ -157,22 +156,7 @@ export const ListItems = observer(function ListItemsImpl({
const renderItem = React.useCallback( const renderItem = React.useCallback(
({item}: {item: any}) => { ({item}: {item: any}) => {
if (item === EMPTY_ITEM) { if (item === EMPTY_ITEM) {
if (renderEmptyState) { return renderEmptyState()
return renderEmptyState()
}
return <View />
} else if (item === HEADER_ITEM) {
return list.list ? (
<ListHeader
list={list.list}
isOwner={list.isOwner}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/>
) : null
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
return ( return (
<ErrorMessage <ErrorMessage
@ -197,178 +181,59 @@ export const ListItems = observer(function ListItemsImpl({
}`} }`}
profile={(item as AppBskyGraphDefs.ListItemView).subject} profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton} renderButton={renderMemberButton}
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
/> />
) )
}, },
[ [
renderMemberButton, renderMemberButton,
renderEmptyState, renderEmptyState,
list.list,
list.isOwner,
list.error, list.error,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
onPressTryAgain, onPressTryAgain,
onPressRetryLoadMore, onPressRetryLoadMore,
isMobile,
], ],
) )
const Footer = React.useCallback( const Footer = React.useCallback(
() => () => (
list.isLoading ? ( <View style={{paddingTop: 20, paddingBottom: 200}}>
<View style={styles.feedFooter}> {list.isLoading && <ActivityIndicator />}
<ActivityIndicator /> </View>
</View> ),
) : ( [list.isLoading],
<View />
),
[list],
) )
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{data.length > 0 && ( <FlatList
<FlatList testID={testID ? `${testID}-flatlist` : undefined}
testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef}
ref={scrollElRef} data={data}
data={data} keyExtractor={(item: any) => item._reactKey}
keyExtractor={item => item._reactKey} renderItem={renderItem}
renderItem={renderItem} ListHeaderComponent={renderHeader}
ListFooterComponent={Footer} ListFooterComponent={Footer}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isRefreshing} refreshing={isRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
progressViewOffset={headerOffset} progressViewOffset={headerOffset}
/> />
} }
contentContainerStyle={s.contentContainer} contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}} style={{paddingTop: headerOffset}}
onEndReached={onEndReached} onScroll={onScroll}
onEndReachedThreshold={0.6} onEndReached={onEndReached}
removeClippedSubviews={true} onEndReachedThreshold={0.6}
contentOffset={{x: 0, y: headerOffset * -1}} scrollEventThrottle={scrollEventThrottle}
// @ts-ignore our .web version only -prf removeClippedSubviews={true}
desktopFixedHeight contentOffset={{x: 0, y: headerOffset * -1}}
/> // @ts-ignore our .web version only -prf
)} desktopFixedHeight={desktopFixedHeightOffset || true}
/>
</View> </View>
) )
}) })
const ListHeader = observer(function ListHeaderImpl({
list,
isOwner,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
}: {
list: AppBskyGraphDefs.ListView
isOwner: boolean
onToggleSubscribed: () => void
onPressEditList: () => void
onPressDeleteList: () => void
onPressShareList: () => void
onPressReportList: () => void
}) {
const pal = usePalette('default')
const store = useStores()
const {isDesktop} = useWebMediaQueries()
const descriptionRT = React.useMemo(
() =>
list?.description &&
new RichText({
text: list.description,
facets: (list.descriptionFacets || [])?.slice(),
}),
[list],
)
return (
<>
<View style={[styles.header, pal.border]}>
<View style={s.flex1}>
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
{list.name}
</Text>
{list && (
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
by{' '}
{list.creator.did === store.me.did ? (
'you'
) : (
<TextLink
text={sanitizeHandle(list.creator.handle, '@')}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
)}
</Text>
)}
{descriptionRT && (
<RichTextCom
testID="listDescription"
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktop && (
<ListActions
isOwner={isOwner}
muted={list.viewer?.muted}
onPressDeleteList={onPressDeleteList}
onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/>
)}
</View>
<View>
<UserAvatar type="list" avatar={list.avatar} size={64} />
</View>
</View>
<View
style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
<View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
Muted users
</Text>
</View>
</View>
</>
)
})
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
borderTopWidth: 1,
},
headerDescription: {
flex: 1,
marginTop: 8,
},
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
},
feedFooter: {paddingTop: 20},
})

View File

@ -1,57 +1,44 @@
import React, {MutableRefObject} from 'react' import React from 'react'
import { import {
ActivityIndicator,
FlatList as RNFlatList,
RefreshControl, RefreshControl,
StyleProp, StyleProp,
StyleSheet, StyleSheet,
View, View,
ViewStyle, ViewStyle,
FlatList,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {ListCard} from './ListCard' import {ListCard} from './ListCard'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ListsListModel} from 'state/models/lists/lists-list' import {ListsListModel} from 'state/models/lists/lists-list'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {FlatList} from '../util/Views.web'
import {s} from 'lib/styles' import {s} from 'lib/styles'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING = {_reactKey: '__loading__'}
const CREATENEW_ITEM = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const ListsList = observer(function ListsListImpl({ export const ListsList = observer(function ListsListImpl({
listsList, listsList,
showAddBtns, inline,
style, style,
scrollElRef,
onPressTryAgain, onPressTryAgain,
onPressCreateNew,
renderItem, renderItem,
renderEmptyState,
testID, testID,
headerOffset = 0,
}: { }: {
listsList: ListsListModel listsList: ListsListModel
showAddBtns?: boolean inline?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCreateNew: () => void
onPressTryAgain?: () => void onPressTryAgain?: () => void
renderItem?: (list: GraphDefs.ListView) => JSX.Element renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
renderEmptyState?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {track} = useAnalytics() const {track} = useAnalytics()
@ -59,33 +46,27 @@ export const ListsList = observer(function ListsListImpl({
const data = React.useMemo(() => { const data = React.useMemo(() => {
let items: any[] = [] let items: any[] = []
if (listsList.hasLoaded) { if (listsList.hasError) {
if (listsList.hasError) { items = items.concat([ERROR_ITEM])
items = items.concat([ERROR_ITEM]) }
} if (!listsList.hasLoaded && listsList.isLoading) {
if (listsList.isEmpty) { items = items.concat([LOADING])
items = items.concat([EMPTY_ITEM]) } else if (listsList.isEmpty) {
} else { items = items.concat([EMPTY])
if (showAddBtns) { } else {
items = items.concat([CREATENEW_ITEM]) items = items.concat(listsList.lists)
} }
items = items.concat(listsList.lists) if (listsList.loadMoreError) {
} items = items.concat([LOAD_MORE_ERROR_ITEM])
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (listsList.isLoading) {
items = items.concat([LOADING_ITEM])
} }
return items return items
}, [ }, [
listsList.hasError, listsList.hasError,
listsList.hasLoaded, listsList.hasLoaded,
listsList.isLoading, listsList.isLoading,
listsList.isEmpty,
listsList.lists, listsList.lists,
listsList.isEmpty,
listsList.loadMoreError, listsList.loadMoreError,
showAddBtns,
]) ])
// events // events
@ -119,14 +100,15 @@ export const ListsList = observer(function ListsListImpl({
// = // =
const renderItemInner = React.useCallback( const renderItemInner = React.useCallback(
({item}: {item: any}) => { ({item, index}: {item: any; index: number}) => {
if (item === EMPTY_ITEM) { if (item === EMPTY) {
if (renderEmptyState) { return (
return renderEmptyState() <View
} testID="listsEmpty"
return <View /> style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
} else if (item === CREATENEW_ITEM) { <Text style={pal.textLight}>You have no lists.</Text>
return <CreateNewItem onPress={onPressCreateNew} /> </View>
)
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
return ( return (
<ErrorMessage <ErrorMessage
@ -141,11 +123,15 @@ export const ListsList = observer(function ListsListImpl({
onPress={onPressRetryLoadMore} onPress={onPressRetryLoadMore}
/> />
) )
} else if (item === LOADING_ITEM) { } else if (item === LOADING) {
return <ProfileCardFeedLoadingPlaceholder /> return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
} }
return renderItem ? ( return renderItem ? (
renderItem(item) renderItem(item, index)
) : ( ) : (
<ListCard <ListCard
list={item} list={item}
@ -154,24 +140,17 @@ export const ListsList = observer(function ListsListImpl({
/> />
) )
}, },
[ [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
listsList,
onPressTryAgain,
onPressRetryLoadMore,
onPressCreateNew,
renderItem,
renderEmptyState,
],
) )
const FlatListCom = inline ? RNFlatList : FlatList
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{data.length > 0 && ( {data.length > 0 && (
<FlatList <FlatListCom
testID={testID ? `${testID}-flatlist` : undefined} testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner} renderItem={renderItemInner}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@ -179,15 +158,12 @@ export const ListsList = observer(function ListsListImpl({
onRefresh={onRefresh} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/> />
} }
contentContainerStyle={[s.contentContainer]} contentContainerStyle={[s.contentContainer]}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={0.6} onEndReachedThreshold={0.6}
removeClippedSubviews={true} removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight
/> />
@ -196,36 +172,9 @@ export const ListsList = observer(function ListsListImpl({
) )
}) })
function CreateNewItem({onPress}: {onPress: () => void}) {
const pal = usePalette('default')
return (
<View style={[styles.createNewContainer]}>
<Button type="default" onPress={onPress} style={styles.createNewButton}>
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
<Text type="button" style={pal.text}>
New Mute List
</Text>
</Button>
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
createNewContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 16,
},
createNewButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
feedFooter: {paddingTop: 20},
item: { item: {
paddingHorizontal: 18, paddingHorizontal: 18,
paddingVertical: 4,
}, },
}) })

View File

@ -1,4 +1,4 @@
import React, {useState, useCallback} from 'react' import React, {useState, useCallback, useMemo} from 'react'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import { import {
ActivityIndicator, ActivityIndicator,
@ -31,9 +31,11 @@ const MAX_DESCRIPTION = 300 // todo
export const snapPoints = ['fullscreen'] export const snapPoints = ['fullscreen']
export function Component({ export function Component({
purpose,
onSave, onSave,
list, list,
}: { }: {
purpose?: string
onSave?: (uri: string) => void onSave?: (uri: string) => void
list?: ListModel list?: ListModel
}) { }) {
@ -44,12 +46,24 @@ export function Component({
const theme = useTheme() const theme = useTheme()
const {track} = useAnalytics() const {track} = useAnalytics()
const activePurpose = useMemo(() => {
if (list?.data?.purpose) {
return list.data.purpose
}
if (purpose) {
return purpose
}
return 'app.bsky.graph.defs#curatelist'
}, [list, purpose])
const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
const purposeLabel = isCurateList ? 'User' : 'Moderation'
const [isProcessing, setProcessing] = useState<boolean>(false) const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>(list?.list?.name || '') const [name, setName] = useState<string>(list?.data?.name || '')
const [description, setDescription] = useState<string>( const [description, setDescription] = useState<string>(
list?.list?.description || '', list?.data?.description || '',
) )
const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar) const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
@ -63,7 +77,7 @@ export function Component({
setAvatar(undefined) setAvatar(undefined)
return return
} }
track('CreateMuteList:AvatarSelected') track('CreateList:AvatarSelected')
try { try {
const finalImg = await compressIfNeeded(img, 1000000) const finalImg = await compressIfNeeded(img, 1000000)
setNewAvatar(finalImg) setNewAvatar(finalImg)
@ -76,7 +90,11 @@ export function Component({
) )
const onPressSave = useCallback(async () => { const onPressSave = useCallback(async () => {
track('CreateMuteList:Save') if (isCurateList) {
track('CreateList:SaveCurateList')
} else {
track('CreateList:SaveModList')
}
const nameTrimmed = name.trim() const nameTrimmed = name.trim()
if (!nameTrimmed) { if (!nameTrimmed) {
setError('Name is required') setError('Name is required')
@ -93,22 +111,23 @@ export function Component({
description: description.trim(), description: description.trim(),
avatar: newAvatar, avatar: newAvatar,
}) })
Toast.show('Mute list updated') Toast.show(`${purposeLabel} list updated`)
onSave?.(list.uri) onSave?.(list.uri)
} else { } else {
const res = await ListModel.createModList(store, { const res = await ListModel.createList(store, {
purpose: activePurpose,
name, name,
description, description,
avatar: newAvatar, avatar: newAvatar,
}) })
Toast.show('Mute list created') Toast.show(`${purposeLabel} list created`)
onSave?.(res.uri) onSave?.(res.uri)
} }
store.shell.closeModal() store.shell.closeModal()
} catch (e: any) { } catch (e: any) {
if (isNetworkError(e)) { if (isNetworkError(e)) {
setError( setError(
'Failed to create the mute list. Check your internet connection and try again.', 'Failed to create the list. Check your internet connection and try again.',
) )
} else { } else {
setError(cleanError(e)) setError(cleanError(e))
@ -122,6 +141,9 @@ export function Component({
error, error,
onSave, onSave,
store, store,
activePurpose,
isCurateList,
purposeLabel,
name, name,
description, description,
newAvatar, newAvatar,
@ -137,9 +159,9 @@ export function Component({
paddingHorizontal: isMobile ? 16 : 0, paddingHorizontal: isMobile ? 16 : 0,
}, },
]} ]}
testID="createOrEditMuteListModal"> testID="createOrEditListModal">
<Text style={[styles.title, pal.text]}> <Text style={[styles.title, pal.text]}>
{list ? 'Edit Mute List' : 'New Mute List'} {list ? 'Edit' : 'New'} {purposeLabel} List
</Text> </Text>
{error !== '' && ( {error !== '' && (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
@ -163,7 +185,9 @@ export function Component({
<TextInput <TextInput
testID="editNameInput" testID="editNameInput"
style={[styles.textInput, pal.border, pal.text]} style={[styles.textInput, pal.border, pal.text]}
placeholder="e.g. spammers" placeholder={
isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers'
}
placeholderTextColor={colors.gray4} placeholderTextColor={colors.gray4}
value={name} value={name}
onChangeText={v => setName(enforceLen(v, MAX_NAME))} onChangeText={v => setName(enforceLen(v, MAX_NAME))}
@ -180,7 +204,11 @@ export function Component({
<TextInput <TextInput
testID="editDescriptionInput" testID="editDescriptionInput"
style={[styles.textArea, pal.border, pal.text]} style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. users that repeatedly reply with ads." placeholder={
isCurateList
? 'e.g. The posters who never miss.'
: 'e.g. Users that repeatedly reply with ads.'
}
placeholderTextColor={colors.gray4} placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
multiline multiline
@ -203,7 +231,7 @@ export function Component({
onPress={onPressSave} onPress={onPressSave}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Save" accessibilityLabel="Save"
accessibilityHint="Creates the mute list"> accessibilityHint="">
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -0,0 +1,281 @@
import React, {useEffect, useCallback, useState, useMemo} from 'react'
import {
ActivityIndicator,
Pressable,
SafeAreaView,
StyleSheet,
View,
} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ScrollView, TextInput} from './util'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {ListModel} from 'state/models/content/list'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
export const snapPoints = ['90%']
export const Component = observer(function Component({
list,
onAdd,
}: {
list: ListModel
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const [query, setQuery] = useState('')
const autocompleteView = useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store),
[store],
)
// initial setup
useEffect(() => {
autocompleteView.setup().then(() => {
autocompleteView.setPrefix('')
})
autocompleteView.setActive(true)
list.loadAll()
}, [autocompleteView, list])
const onChangeQuery = useCallback(
(text: string) => {
setQuery(text)
autocompleteView.setPrefix(text)
},
[setQuery, autocompleteView],
)
const onPressCancelSearch = useCallback(
() => onChangeQuery(''),
[onChangeQuery],
)
return (
<SafeAreaView
testID="listAddUserModal"
style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}>
<View
style={[
s.flex1,
isMobile && {paddingHorizontal: 18, paddingBottom: 40},
]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
Add User to List
</Text>
</View>
<View style={[styles.searchContainer, pal.border]}>
<FontAwesomeIcon icon="search" size={16} />
<TextInput
testID="searchInput"
style={[styles.searchInput, pal.border, pal.text]}
placeholder="Search for users"
placeholderTextColor={pal.colors.textLight}
value={query}
onChangeText={onChangeQuery}
accessible={true}
accessibilityLabel="Search"
accessibilityHint=""
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
{query ? (
<Pressable
onPress={onPressCancelSearch}
accessibilityRole="button"
accessibilityLabel="Cancel search"
accessibilityHint="Exits inputting search query"
onAccessibilityEscape={onPressCancelSearch}>
<FontAwesomeIcon
icon="xmark"
size={16}
color={pal.colors.textLight}
/>
</Pressable>
) : undefined}
</View>
<ScrollView style={[s.flex1]}>
{autocompleteView.suggestions.length ? (
<>
{autocompleteView.suggestions.slice(0, 40).map((item, i) => (
<UserResult
key={item.did}
list={list}
profile={item}
noBorder={i === 0}
onAdd={onAdd}
/>
))}
</>
) : (
<Text
type="xl"
style={[
pal.textLight,
{paddingHorizontal: 12, paddingVertical: 16},
]}>
No results found for {autocompleteView.prefix}
</Text>
)}
</ScrollView>
<View style={[styles.btnContainer]}>
<Button
testID="doneBtn"
type="primary"
onPress={() => store.shell.closeModal()}
accessibilityLabel="Done"
accessibilityHint=""
label="Done"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</View>
</SafeAreaView>
)
})
function UserResult({
profile,
list,
noBorder,
onAdd,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
list: ListModel
noBorder: boolean
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
}) {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState(false)
const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
const onPressAdd = useCallback(async () => {
setIsProcessing(true)
try {
await list.addMember(profile)
Toast.show('Added to list')
setIsAdded(true)
onAdd?.(profile)
} catch (e) {
Toast.show(cleanError(e))
} finally {
setIsProcessing(false)
}
}, [list, profile, setIsProcessing, setIsAdded, onAdd])
return (
<View
style={[
pal.border,
{
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: noBorder ? 0 : 1,
paddingVertical: 8,
paddingHorizontal: 8,
},
]}>
<View
style={{
alignSelf: 'baseline',
width: 54,
paddingLeft: 4,
paddingTop: 10,
}}>
<UserAvatar size={40} avatar={profile.avatar} />
</View>
<View
style={{
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
}}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</View>
<View>
{isAdded ? (
<FontAwesomeIcon icon="check" />
) : isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID={`user-${profile.handle}-addBtn`}
type="default"
label="Add"
onPress={onPressAdd}
/>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
fixedHeight: {
// @ts-ignore web only -prf
height: '80vh',
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
},
searchInput: {
fontSize: 16,
flex: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
},
})

View File

@ -16,8 +16,9 @@ import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as CreateOrEditListModal from './CreateOrEditList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddUser'
import * as AltImageModal from './AltImage' import * as AltImageModal from './AltImage'
import * as EditImageModal from './AltImage' import * as EditImageModal from './AltImage'
import * as ReportModal from './report/Modal' import * as ReportModal from './report/Modal'
@ -101,12 +102,15 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'report') { } else if (activeModal?.name === 'report') {
snapPoints = ReportModal.snapPoints snapPoints = ReportModal.snapPoints
element = <ReportModal.Component {...activeModal} /> element = <ReportModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-mute-list') { } else if (activeModal?.name === 'create-or-edit-list') {
snapPoints = CreateOrEditMuteListModal.snapPoints snapPoints = CreateOrEditListModal.snapPoints
element = <CreateOrEditMuteListModal.Component {...activeModal} /> element = <CreateOrEditListModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-remove-user') { } else if (activeModal?.name === 'user-add-remove-lists') {
snapPoints = ListAddRemoveUserModal.snapPoints snapPoints = UserAddRemoveListsModal.snapPoints
element = <ListAddRemoveUserModal.Component {...activeModal} /> element = <UserAddRemoveListsModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-user') {
snapPoints = ListAddUserModal.snapPoints
element = <ListAddUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'delete-account') { } else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component /> element = <DeleteAccountModal.Component />

View File

@ -11,8 +11,9 @@ import * as EditProfileModal from './EditProfile'
import * as ProfilePreviewModal from './ProfilePreview' import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as ReportModal from './report/Modal' import * as ReportModal from './report/Modal'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as CreateOrEditListModal from './CreateOrEditList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as UserAddRemoveLists from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddUser'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
@ -79,10 +80,12 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ServerInputModal.Component {...modal} /> element = <ServerInputModal.Component {...modal} />
} else if (modal.name === 'report') { } else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} /> element = <ReportModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-mute-list') { } else if (modal.name === 'create-or-edit-list') {
element = <CreateOrEditMuteListModal.Component {...modal} /> element = <CreateOrEditListModal.Component {...modal} />
} else if (modal.name === 'list-add-remove-user') { } else if (modal.name === 'user-add-remove-lists') {
element = <ListAddRemoveUserModal.Component {...modal} /> element = <UserAddRemoveLists.Component {...modal} />
} else if (modal.name === 'list-add-user') {
element = <ListAddUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') { } else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} /> element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') { } else if (modal.name === 'delete-account') {

View File

@ -31,8 +31,25 @@ export function Component({
description = description =
'Moderator has chosen to set a general warning on the content.' 'Moderator has chosen to set a general warning on the content.'
} else if (moderation.cause.type === 'blocking') { } else if (moderation.cause.type === 'blocking') {
name = 'User Blocked' if (moderation.cause.source.type === 'list') {
description = 'You have blocked this user. You cannot view their content.' const list = moderation.cause.source.list
name = 'User Blocked by List'
description = (
<>
This user is included in the{' '}
<TextLink
type="2xl"
href={listUriToHref(list.uri)}
text={list.name}
style={pal.link}
/>{' '}
list which you have blocked.
</>
)
} else {
name = 'User Blocked'
description = 'You have blocked this user. You cannot view their content.'
}
} else if (moderation.cause.type === 'blocked-by') { } else if (moderation.cause.type === 'blocked-by') {
name = 'User Blocks You' name = 'User Blocks You'
description = 'This user has blocked you. You cannot view their content.' description = 'This user has blocked you. You cannot view their content.'

View File

@ -1,6 +1,6 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
@ -11,7 +11,6 @@ import {UserAvatar} from '../util/UserAvatar'
import {ListsList} from '../lists/ListsList' import {ListsList} from '../lists/ListsList'
import {ListsListModel} from 'state/models/lists/lists-list' import {ListsListModel} from 'state/models/lists/lists-list'
import {ListMembershipModel} from 'state/models/content/list-membership' import {ListMembershipModel} from 'state/models/content/list-membership'
import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -24,14 +23,16 @@ import isEqual from 'lodash.isequal'
export const snapPoints = ['fullscreen'] export const snapPoints = ['fullscreen']
export const Component = observer(function ListAddRemoveUserImpl({ export const Component = observer(function UserAddRemoveListsImpl({
subject, subject,
displayName, displayName,
onUpdate, onAdd,
onRemove,
}: { }: {
subject: string subject: string
displayName: string displayName: string
onUpdate?: () => void onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -71,25 +72,22 @@ export const Component = observer(function ListAddRemoveUserImpl({
}, [store]) }, [store])
const onPressSave = useCallback(async () => { const onPressSave = useCallback(async () => {
let changes
try { try {
await memberships.updateTo(selected) changes = await memberships.updateTo(selected)
} catch (err) { } catch (err) {
store.log.error('Failed to update memberships', {err}) store.log.error('Failed to update memberships', {err})
return return
} }
Toast.show('Lists updated') Toast.show('Lists updated')
onUpdate?.() for (const uri of changes.added) {
onAdd?.(uri)
}
for (const uri of changes.removed) {
onRemove?.(uri)
}
store.shell.closeModal() store.shell.closeModal()
}, [store, selected, memberships, onUpdate]) }, [store, selected, memberships, onAdd, onRemove])
const onPressNewMuteList = useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-mute-list',
onSave: (_uri: string) => {
listsList.refresh()
},
})
}, [store, listsList])
const onToggleSelected = useCallback( const onToggleSelected = useCallback(
(uri: string) => { (uri: string) => {
@ -103,7 +101,7 @@ export const Component = observer(function ListAddRemoveUserImpl({
) )
const renderItem = useCallback( const renderItem = useCallback(
(list: GraphDefs.ListView) => { (list: GraphDefs.ListView, index: number) => {
const isSelected = selected.includes(list.uri) const isSelected = selected.includes(list.uri)
return ( return (
<Pressable <Pressable
@ -111,7 +109,10 @@ export const Component = observer(function ListAddRemoveUserImpl({
style={[ style={[
styles.listItem, styles.listItem,
pal.border, pal.border,
{opacity: membershipsLoaded ? 1 : 0.5}, {
opacity: membershipsLoaded ? 1 : 0.5,
borderTopWidth: index === 0 ? 0 : 1,
},
]} ]}
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
list.name list.name
@ -131,7 +132,11 @@ export const Component = observer(function ListAddRemoveUserImpl({
{sanitizeDisplayName(list.name)} {sanitizeDisplayName(list.name)}
</Text> </Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{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 {list.creator.did === store.me.did
? 'you' ? 'you'
: sanitizeHandle(list.creator.handle, '@')} : sanitizeHandle(list.creator.handle, '@')}
@ -166,30 +171,19 @@ export const Component = observer(function ListAddRemoveUserImpl({
], ],
) )
const renderEmptyState = React.useCallback(() => {
return (
<EmptyStateWithButton
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection // Only show changes button if there are some items on the list to choose from AND user has made changes in selection
const canSaveChanges = const canSaveChanges =
!listsList.isEmpty && !isEqual(selected, originalSelections) !listsList.isEmpty && !isEqual(selected, originalSelections)
return ( return (
<View testID="listAddRemoveUserModal" style={s.hContentRegion}> <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> <Text style={[styles.title, pal.text]}>
Update {displayName} in Lists
</Text>
<ListsList <ListsList
listsList={listsList} listsList={listsList}
showAddBtns inline
onPressCreateNew={onPressNewMuteList}
renderItem={renderItem} renderItem={renderItem}
renderEmptyState={renderEmptyState}
style={[styles.list, pal.border]} style={[styles.list, pal.border]}
/> />
<View style={[styles.btns, pal.border]}> <View style={[styles.btns, pal.border]}>
@ -258,7 +252,6 @@ const styles = StyleSheet.create({
listItem: { listItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderTopWidth: 1,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 10, paddingVertical: 10,
}, },

View File

@ -1,10 +1,11 @@
import React, {useMemo} from 'react' import React from 'react'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/pager/TabBar' import {TabBar} from 'view/com/pager/TabBar'
import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
@ -27,10 +28,7 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) { ) {
const store = useStores() const store = useStores()
const items = useMemo( const items = useHomeTabs(store.preferences.pinnedFeeds)
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames],
)
const pal = usePalette('default') const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()

View File

@ -1,9 +1,10 @@
import React, {useMemo} from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/pager/TabBar' import {TabBar} from 'view/com/pager/TabBar'
import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Link} from '../util/Link' import {Link} from '../util/Link'
@ -18,9 +19,9 @@ import Animated from 'react-native-reanimated'
export const FeedsTabBar = observer(function FeedsTabBarImpl( export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) { ) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const items = useHomeTabs(store.preferences.pinnedFeeds)
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()
@ -28,15 +29,6 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
store.shell.openDrawer() store.shell.openDrawer()
}, [store]) }, [store])
const items = useMemo(
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames],
)
const tabBarKey = useMemo(() => {
return items.join(',')
}, [items])
return ( return (
<Animated.View <Animated.View
style={[ style={[
@ -81,7 +73,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
</View> </View>
</View> </View>
<TabBar <TabBar
key={tabBarKey} key={items.join(',')}
onPressSelected={props.onPressSelected} onPressSelected={props.onPressSelected}
selectedPage={props.selectedPage} selectedPage={props.selectedPage}
onSelect={props.onSelect} onSelect={props.onSelect}

View File

@ -1,6 +1,10 @@
import React, {forwardRef} from 'react' import React, {forwardRef} from 'react'
import {Animated, View} from 'react-native' import {Animated, View} from 'react-native'
import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view' import PagerView, {
PagerViewOnPageSelectedEvent,
PagerViewOnPageScrollEvent,
PageScrollStateChangedNativeEvent,
} from 'react-native-pager-view'
import {s} from 'lib/styles' import {s} from 'lib/styles'
export type PageSelectedEvent = PagerViewOnPageSelectedEvent export type PageSelectedEvent = PagerViewOnPageSelectedEvent
@ -21,6 +25,7 @@ interface Props {
initialPage?: number initialPage?: number
renderTabBar: RenderTabBarFn renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
onPageSelecting?: (index: number) => void
testID?: string testID?: string
} }
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
@ -31,11 +36,15 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
initialPage = 0, initialPage = 0,
renderTabBar, renderTabBar,
onPageSelected, onPageSelected,
onPageSelecting,
testID, testID,
}: React.PropsWithChildren<Props>, }: React.PropsWithChildren<Props>,
ref, ref,
) { ) {
const [selectedPage, setSelectedPage] = React.useState(0) const [selectedPage, setSelectedPage] = React.useState(0)
const lastOffset = React.useRef(0)
const lastDirection = React.useRef(0)
const scrollState = React.useRef('')
const pagerView = React.useRef<PagerView>(null) const pagerView = React.useRef<PagerView>(null)
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
@ -50,15 +59,61 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
[setSelectedPage, onPageSelected], [setSelectedPage, onPageSelected],
) )
const onPageScroll = React.useCallback(
(e: PagerViewOnPageScrollEvent) => {
const {position, offset} = e.nativeEvent
if (offset === 0) {
// offset hits 0 in some awkward spots so we ignore it
return
}
// NOTE
// we want to call `onPageSelecting` as soon as the scroll-gesture
// enters the "settling" phase, which means the user has released it
// we can't infer directionality from the scroll information, so we
// track the offset changes. if the offset delta is consistent with
// the existing direction during the settling phase, we can say for
// certain where it's going and can fire
// -prf
if (scrollState.current === 'settling') {
if (lastDirection.current === -1 && offset < lastOffset.current) {
onPageSelecting?.(position)
lastDirection.current = 0
} else if (
lastDirection.current === 1 &&
offset > lastOffset.current
) {
onPageSelecting?.(position + 1)
lastDirection.current = 0
}
} else {
if (offset < lastOffset.current) {
lastDirection.current = -1
} else if (offset > lastOffset.current) {
lastDirection.current = 1
}
}
lastOffset.current = offset
},
[lastOffset, lastDirection, onPageSelecting],
)
const onPageScrollStateChanged = React.useCallback(
(e: PageScrollStateChangedNativeEvent) => {
scrollState.current = e.nativeEvent.pageScrollState
},
[scrollState],
)
const onTabBarSelect = React.useCallback( const onTabBarSelect = React.useCallback(
(index: number) => { (index: number) => {
pagerView.current?.setPage(index) pagerView.current?.setPage(index)
onPageSelecting?.(index)
}, },
[pagerView], [pagerView, onPageSelecting],
) )
return ( return (
<View testID={testID}> <View testID={testID} style={s.flex1}>
{tabBarPosition === 'top' && {tabBarPosition === 'top' &&
renderTabBar({ renderTabBar({
selectedPage, selectedPage,
@ -66,9 +121,11 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
})} })}
<AnimatedPagerView <AnimatedPagerView
ref={pagerView} ref={pagerView}
style={s.h100pct} style={s.flex1}
initialPage={initialPage} initialPage={initialPage}
onPageSelected={onPageSelectedInner}> onPageScrollStateChanged={onPageScrollStateChanged}
onPageSelected={onPageSelectedInner}
onPageScroll={onPageScroll}>
{children} {children}
</AnimatedPagerView> </AnimatedPagerView>
{tabBarPosition === 'bottom' && {tabBarPosition === 'bottom' &&

View File

@ -13,6 +13,7 @@ interface Props {
initialPage?: number initialPage?: number
renderTabBar: RenderTabBarFn renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
onPageSelecting?: (index: number) => void
} }
export const Pager = React.forwardRef(function PagerImpl( export const Pager = React.forwardRef(function PagerImpl(
{ {
@ -21,6 +22,7 @@ export const Pager = React.forwardRef(function PagerImpl(
initialPage = 0, initialPage = 0,
renderTabBar, renderTabBar,
onPageSelected, onPageSelected,
onPageSelecting,
}: React.PropsWithChildren<Props>, }: React.PropsWithChildren<Props>,
ref, ref,
) { ) {
@ -34,21 +36,20 @@ export const Pager = React.forwardRef(function PagerImpl(
(index: number) => { (index: number) => {
setSelectedPage(index) setSelectedPage(index)
onPageSelected?.(index) onPageSelected?.(index)
onPageSelecting?.(index)
}, },
[setSelectedPage, onPageSelected], [setSelectedPage, onPageSelected, onPageSelecting],
) )
return ( return (
<View> <View style={s.hContentRegion}>
{tabBarPosition === 'top' && {tabBarPosition === 'top' &&
renderTabBar({ renderTabBar({
selectedPage, selectedPage,
onSelect: onTabBarSelect, onSelect: onTabBarSelect,
})} })}
{React.Children.map(children, (child, i) => ( {React.Children.map(children, (child, i) => (
<View <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
style={selectedPage === i ? undefined : s.hidden}
key={`page-${i}`}>
{child} {child}
</View> </View>
))} ))}

View File

@ -0,0 +1,212 @@
import * as React from 'react'
import {LayoutChangeEvent, StyleSheet} from 'react-native'
import Animated, {
Easing,
useAnimatedReaction,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
const SCROLLED_DOWN_LIMIT = 200
interface PagerWithHeaderChildParams {
headerHeight: number
onScroll: OnScrollCb
isScrolledDown: boolean
}
export interface PagerWithHeaderProps {
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[]
renderHeader?: () => JSX.Element
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
items,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
}: PagerWithHeaderProps,
ref,
) {
const {isMobile} = useWebMediaQueries()
const [currentPage, setCurrentPage] = React.useState(0)
const scrollYs = React.useRef<Record<number, number>>({})
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerHeight, setHeaderHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState(
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
)
// react to scroll updates
function onScrollUpdate(v: number) {
// track each page's current scroll position
scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight)
// update the 'is scrolled down' value
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
}
useAnimatedReaction(
() => scrollY.value,
v => runOnJS(onScrollUpdate)(v),
)
// capture the header bar sizing
const onTabBarLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setTabBarHeight(evt.nativeEvent.layout.height)
},
[setTabBarHeight],
)
const onHeaderLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setHeaderHeight(evt.nativeEvent.layout.height)
},
[setHeaderHeight],
)
// render the the header and tab bar
const headerTransform = useAnimatedStyle(
() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerHeight - tabBarHeight) * -1,
0,
),
},
],
}),
[scrollY, headerHeight, tabBarHeight],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<Animated.View
onLayout={onHeaderLayout}
style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
{renderHeader?.()}
<TabBar
items={items}
selectedPage={currentPage}
onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected}
onLayout={onTabBarLayout}
/>
</Animated.View>
)
},
[
items,
renderHeader,
headerTransform,
currentPage,
onCurrentPageSelected,
isMobile,
onTabBarLayout,
onHeaderLayout,
],
)
// props to pass into children render functions
const onScroll = useAnimatedScrollHandler({
onScroll(e) {
scrollY.value = e.contentOffset.y
},
})
const childProps = React.useMemo<PagerWithHeaderChildParams>(() => {
return {
headerHeight,
onScroll,
isScrolledDown,
}
}, [headerHeight, onScroll, isScrolledDown])
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback(
(index: number) => {
setCurrentPage(index)
if (scrollY.value > headerHeight) {
scrollY.value = headerHeight
}
scrollY.value = withTiming(scrollYs.current[index] || 0, {
duration: 170,
easing: Easing.inOut(Easing.quad),
})
},
[scrollY, setCurrentPage, scrollYs, headerHeight],
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}
tabBarPosition="top">
{toArray(children)
.filter(Boolean)
.map(child => {
if (child) {
return child(childProps)
}
return null
})}
</Pager>
)
},
)
const styles = StyleSheet.create({
tabBarMobile: {
position: 'absolute',
zIndex: 1,
top: 0,
left: 0,
width: '100%',
},
tabBarDesktop: {
position: 'absolute',
zIndex: 1,
top: 0,
// @ts-ignore Web only -prf
left: 'calc(50% - 299px)',
width: 598,
},
})
function toArray<T>(v: T | T[]): T[] {
if (Array.isArray(v)) {
return v
}
return [v]
}

View File

@ -13,7 +13,8 @@ export interface TabBarProps {
items: string[] items: string[]
indicatorColor?: string indicatorColor?: string
onSelect?: (index: number) => void onSelect?: (index: number) => void
onPressSelected?: () => void onPressSelected?: (index: number) => void
onLayout?: (evt: LayoutChangeEvent) => void
} }
export function TabBar({ export function TabBar({
@ -23,6 +24,7 @@ export function TabBar({
indicatorColor, indicatorColor,
onSelect, onSelect,
onPressSelected, onPressSelected,
onLayout,
}: TabBarProps) { }: TabBarProps) {
const pal = usePalette('default') const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null) const scrollElRef = useRef<ScrollView>(null)
@ -44,7 +46,7 @@ export function TabBar({
(index: number) => { (index: number) => {
onSelect?.(index) onSelect?.(index)
if (index === selectedPage) { if (index === selectedPage) {
onPressSelected?.() onPressSelected?.(index)
} }
}, },
[onSelect, selectedPage, onPressSelected], [onSelect, selectedPage, onPressSelected],
@ -66,7 +68,7 @@ export function TabBar({
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
return ( return (
<View testID={testID} style={[pal.view, styles.outer]}> <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}>
<DraggableScrollView <DraggableScrollView
horizontal={true} horizontal={true}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
@ -118,10 +120,7 @@ const desktopStyles = StyleSheet.create({
const mobileStyles = StyleSheet.create({ const mobileStyles = StyleSheet.create({
outer: { outer: {
flex: 1,
flexDirection: 'row', flexDirection: 'row',
backgroundColor: 'transparent',
maxWidth: '100%',
}, },
contentContainer: { contentContainer: {
columnGap: isWeb ? 0 : 20, columnGap: isWeb ? 0 : 20,

View File

@ -29,26 +29,26 @@ export const Feed = observer(function Feed({
feed, feed,
style, style,
scrollElRef, scrollElRef,
onPressTryAgain,
onScroll, onScroll,
scrollEventThrottle, scrollEventThrottle,
renderEmptyState, renderEmptyState,
renderEndOfFeed, renderEndOfFeed,
testID, testID,
headerOffset = 0, headerOffset = 0,
desktopFixedHeightOffset,
ListHeaderComponent, ListHeaderComponent,
extraData, extraData,
}: { }: {
feed: PostsFeedModel feed: PostsFeedModel
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb onScroll?: OnScrollCb
scrollEventThrottle?: number scrollEventThrottle?: number
renderEmptyState: () => JSX.Element renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number
desktopFixedHeightOffset?: number
ListHeaderComponent?: () => JSX.Element ListHeaderComponent?: () => JSX.Element
extraData?: any extraData?: any
}) { }) {
@ -71,6 +71,8 @@ export const Feed = observer(function Feed({
if (feed.loadMoreError) { if (feed.loadMoreError) {
feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
} }
} else {
feedItems.push(LOADING_ITEM)
} }
return feedItems return feedItems
}, [ }, [
@ -106,6 +108,10 @@ export const Feed = observer(function Feed({
} }
}, [feed, track]) }, [feed, track])
const onPressTryAgain = React.useCallback(() => {
feed.refresh()
}, [feed])
const onPressRetryLoadMore = React.useCallback(() => { const onPressRetryLoadMore = React.useCallback(() => {
feed.retryLoadMore() feed.retryLoadMore()
}, [feed]) }, [feed])
@ -158,7 +164,7 @@ export const Feed = observer(function Feed({
<FlatList <FlatList
testID={testID ? `${testID}-flatlist` : undefined} testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef} ref={scrollElRef}
data={!feed.hasLoaded ? [LOADING_ITEM] : data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
ListFooterComponent={FeedFooter} ListFooterComponent={FeedFooter}
@ -183,7 +189,9 @@ export const Feed = observer(function Feed({
contentOffset={{x: 0, y: headerOffset * -1}} contentOffset={{x: 0, y: headerOffset * -1}}
extraData={extraData} extraData={extraData}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight={
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
}
/> />
</View> </View>
) )

View File

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
@ -29,6 +29,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
noBorder, noBorder,
followers, followers,
renderButton, renderButton,
style,
}: { }: {
testID?: string testID?: string
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
@ -36,6 +37,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
style?: StyleProp<ViewStyle>
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -50,6 +52,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
pal.border, pal.border,
noBorder && styles.outerNoBorder, noBorder && styles.outerNoBorder,
!noBg && pal.view, !noBg && pal.view,
style,
]} ]}
href={makeProfileLink(profile)} href={makeProfileLink(profile)}
title={profile.handle} title={profile.handle}
@ -93,7 +96,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
{profile.description as string} {profile.description as string}
</Text> </Text>
</View> </View>
) : undefined} ) : null}
<FollowersList followers={followers} /> <FollowersList followers={followers} />
</Link> </Link>
) )
@ -220,10 +223,10 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
layoutAvi: { layoutAvi: {
alignSelf: 'baseline',
width: 54, width: 54,
paddingLeft: 4, paddingLeft: 4,
paddingTop: 8, paddingTop: 10,
paddingBottom: 10,
}, },
avi: { avi: {
width: 40, width: 40,

View File

@ -181,7 +181,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
const onPressAddRemoveLists = React.useCallback(() => { const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked') track('ProfileHeader:AddToListsButtonClicked')
store.shell.openModal({ store.shell.openModal({
name: 'list-add-remove-user', name: 'user-add-remove-lists',
subject: view.did, subject: view.did,
displayName: view.displayName || view.handle, displayName: view.displayName || view.handle,
}) })
@ -276,21 +276,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
}, },
}, },
] ]
if (!isMe) { items.push({label: 'separator'})
items.push({label: 'separator'}) items.push({
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self! testID: 'profileHeaderDropdownListAddRemoveBtn',
items.push({ label: 'Add to Lists',
testID: 'profileHeaderDropdownListAddRemoveBtn', onPress: onPressAddRemoveLists,
label: 'Add to Lists', icon: {
onPress: onPressAddRemoveLists, ios: {
icon: { name: 'list.bullet',
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
}, },
}) android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!view.viewer.blocking) { if (!view.viewer.blocking) {
items.push({ items.push({
testID: 'profileHeaderDropdownMuteBtn', testID: 'profileHeaderDropdownMuteBtn',
@ -307,20 +306,22 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
}, },
}) })
} }
items.push({ if (!view.viewer.blockingByList) {
testID: 'profileHeaderDropdownBlockBtn', items.push({
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', testID: 'profileHeaderDropdownBlockBtn',
onPress: view.viewer.blocking label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
? onPressUnblockAccount onPress: view.viewer.blocking
: onPressBlockAccount, ? onPressUnblockAccount
icon: { : onPressBlockAccount,
ios: { icon: {
name: 'person.fill.xmark', ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
}, },
android: 'ic_menu_close_clear_cancel', })
web: 'user-slash', }
},
})
items.push({ items.push({
testID: 'profileHeaderDropdownReportBtn', testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account', label: 'Report Account',
@ -339,6 +340,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
isMe, isMe,
view.viewer.muted, view.viewer.muted,
view.viewer.blocking, view.viewer.blocking,
view.viewer.blockingByList,
onPressShare, onPressShare,
onPressUnmuteAccount, onPressUnmuteAccount,
onPressMuteAccount, onPressMuteAccount,
@ -371,17 +373,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : view.viewer.blocking ? ( ) : view.viewer.blocking ? (
<TouchableOpacity view.viewer.blockingByList ? null : (
testID="unblockBtn" <TouchableOpacity
onPress={onPressUnblockAccount} testID="unblockBtn"
style={[styles.btn, styles.mainBtn, pal.btn]} onPress={onPressUnblockAccount}
accessibilityRole="button" style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityLabel="Unblock" accessibilityRole="button"
accessibilityHint=""> accessibilityLabel="Unblock"
<Text type="button" style={[pal.text, s.bold]}> accessibilityHint="">
Unblock <Text type="button" style={[pal.text, s.bold]}>
</Text> Unblock
</TouchableOpacity> </Text>
</TouchableOpacity>
)
) : !view.viewer.blockedBy ? ( ) : !view.viewer.blockedBy ? (
<> <>
{!isProfilePreview && ( {!isProfilePreview && (

View File

@ -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<NavigationProp>()
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 (
<CenteredView style={pal.view}>
{isMobile && (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
paddingTop: isNative ? 0 : 8,
paddingBottom: 8,
paddingHorizontal: isMobile ? 12 : 14,
},
pal.border,
]}>
<Pressable
testID="headerDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
</Pressable>
<View style={{flex: 1}} />
{children}
</View>
)}
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingTop: 14,
paddingBottom: 6,
paddingHorizontal: isMobile ? 12 : 14,
}}>
<Pressable
testID="headerAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel="View the avatar"
accessibilityHint=""
style={{width: 58}}>
<UserAvatar type={avatarType} size={58} avatar={avatar} />
</Pressable>
<View style={{flex: 1}}>
{isLoading ? (
<LoadingPlaceholder
width={200}
height={32}
style={{marginVertical: 6}}
/>
) : (
<TextLink
testID="headerTitle"
type="title-xl"
href={href}
style={[pal.text, {fontWeight: 'bold'}]}
text={title || ''}
onPress={() => store.emitScreenSoftReset()}
numberOfLines={4}
/>
)}
{isLoading ? (
<LoadingPlaceholder width={50} height={8} />
) : (
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
by{' '}
{!creator ? (
'—'
) : isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(creator.handle, '@')}
href={makeProfileLink(creator)}
style={pal.textLight}
/>
)}
</Text>
)}
</View>
{!isMobile && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}>
{children}
</View>
)}
</View>
</CenteredView>
)
})
const styles = StyleSheet.create({
backBtn: {
width: 20,
height: 30,
},
backBtnWide: {
width: 20,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
})

View File

@ -65,6 +65,12 @@ export function TestCtrls() {
accessibilityRole="button" accessibilityRole="button"
style={BTN} style={BTN}
/> />
<Pressable
testID="e2eGotoLists"
onPress={() => navigate('Lists')}
accessibilityRole="button"
style={BTN}
/>
<Pressable <Pressable
testID="e2eToggleMergefeed" testID="e2eToggleMergefeed"
onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()}

View File

@ -25,7 +25,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) {
name: 'trash', name: 'trash',
}, },
android: 'ic_delete', android: 'ic_delete',
web: 'trash', web: ['far', 'trash-can'],
}, },
}, },
] ]

View File

@ -83,19 +83,14 @@ export function PostLoadingPlaceholder({
export function PostFeedLoadingPlaceholder() { export function PostFeedLoadingPlaceholder() {
return ( return (
<> <View>
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> <PostLoadingPlaceholder />
<PostLoadingPlaceholder /> </View>
<PostLoadingPlaceholder />
<PostLoadingPlaceholder />
<PostLoadingPlaceholder />
<PostLoadingPlaceholder />
</>
) )
} }

View File

@ -17,10 +17,10 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {UserPreviewLink} from './UserPreviewLink' import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
type Type = 'user' | 'algo' | 'list' export type UserAvatarType = 'user' | 'algo' | 'list'
interface BaseUserAvatarProps { interface BaseUserAvatarProps {
type?: Type type?: UserAvatarType
size: number size: number
avatar?: string | null avatar?: string | null
} }
@ -41,7 +41,7 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
const BLUR_AMOUNT = isWeb ? 5 : 100 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') { 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. // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return ( return (
@ -261,7 +261,7 @@ export function EditableUserAvatar({
name: 'trash', name: 'trash',
}, },
android: 'ic_delete', android: 'ic_delete',
web: 'trash', web: ['far', 'trash-can'],
}, },
onPress: async () => { onPress: async () => {
onSelectNewAvatar(null) onSelectNewAvatar(null)

View File

@ -91,7 +91,7 @@ export function UserBanner({
name: 'trash', name: 'trash',
}, },
android: 'ic_delete', android: 'ic_delete',
web: 'trash', web: ['far', 'trash-can'],
}, },
onPress: () => { onPress: () => {
onSelectNewBanner?.(null) onSelectNewBanner?.(null)

View File

@ -124,7 +124,6 @@ function DesktopWebHeader({
<CenteredView <CenteredView
style={[ style={[
styles.header, styles.header,
styles.headerFixed,
styles.desktopHeader, styles.desktopHeader,
pal.border, pal.border,
{ {
@ -158,7 +157,6 @@ const Container = observer(function ContainerImpl({
<View <View
style={[ style={[
styles.header, styles.header,
styles.headerFixed,
pal.view, pal.view,
pal.border, pal.border,
showBorder && styles.border, showBorder && styles.border,
@ -190,11 +188,6 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
width: '100%', width: '100%',
}, },
headerFixed: {
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
},
headerFloating: { headerFloating: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -202,6 +195,9 @@ const styles = StyleSheet.create({
}, },
desktopHeader: { desktopHeader: {
paddingVertical: 12, paddingVertical: 12,
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
}, },
border: { border: {
borderBottomWidth: 1, borderBottomWidth: 1,

1
src/view/com/util/Views.d.ts vendored 100644
View File

@ -0,0 +1 @@
export {FlatList, ScrollView, View as CenteredView} from 'react-native'

View File

@ -0,0 +1,9 @@
import React from 'react'
import {View} from 'react-native'
import Animated from 'react-native-reanimated'
export const FlatList = Animated.FlatList
export const ScrollView = Animated.ScrollView
export function CenteredView(props) {
return <View {...props} />
}

View File

@ -1 +0,0 @@
export {View as CenteredView, FlatList, ScrollView} from 'react-native'

View File

@ -14,9 +14,7 @@
import React from 'react' import React from 'react'
import { import {
FlatList as RNFlatList,
FlatListProps, FlatListProps,
ScrollView as RNScrollView,
ScrollViewProps, ScrollViewProps,
StyleSheet, StyleSheet,
View, View,
@ -25,16 +23,29 @@ import {
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import Animated from 'react-native-reanimated'
interface AddedProps { interface AddedProps {
desktopFixedHeight?: boolean desktopFixedHeight?: boolean | number
} }
export function CenteredView({ export function CenteredView({
style, style,
sideBorders,
...props ...props
}: React.PropsWithChildren<ViewProps>) { }: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) {
style = addStyle(style, styles.container) 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 <View style={style} {...props} /> return <View style={style} {...props} />
} }
@ -46,14 +57,16 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
desktopFixedHeight, desktopFixedHeight,
...props ...props
}: React.PropsWithChildren<FlatListProps<ItemT> & AddedProps>, }: React.PropsWithChildren<FlatListProps<ItemT> & AddedProps>,
ref: React.Ref<RNFlatList>, ref: React.Ref<Animated.FlatList<ItemT>>,
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
contentContainerStyle = addStyle( if (!isMobile) {
contentContainerStyle, contentContainerStyle = addStyle(
styles.containerScroll, contentContainerStyle,
) styles.containerScroll,
)
}
if (contentOffset && contentOffset?.y !== 0) { if (contentOffset && contentOffset?.y !== 0) {
// NOTE // NOTE
// we use paddingTop & contentOffset to space around the floating header // we use paddingTop & contentOffset to space around the floating header
@ -68,7 +81,14 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
}) })
} }
if (desktopFixedHeight) { 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) { if (!isMobile) {
// NOTE // NOTE
// react native web produces *three* wrapping divs // react native web produces *three* wrapping divs
@ -85,7 +105,7 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
} }
} }
return ( return (
<RNFlatList <Animated.FlatList
ref={ref} ref={ref}
contentContainerStyle={[ contentContainerStyle={[
contentContainerStyle, contentContainerStyle,
@ -101,21 +121,25 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
export const ScrollView = React.forwardRef(function ScrollViewImpl( export const ScrollView = React.forwardRef(function ScrollViewImpl(
{contentContainerStyle, ...props}: React.PropsWithChildren<ScrollViewProps>, {contentContainerStyle, ...props}: React.PropsWithChildren<ScrollViewProps>,
ref: React.Ref<RNScrollView>, ref: React.Ref<Animated.ScrollView>,
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
contentContainerStyle = addStyle( const {isMobile} = useWebMediaQueries()
contentContainerStyle, if (!isMobile) {
styles.containerScroll, contentContainerStyle = addStyle(
) contentContainerStyle,
styles.containerScroll,
)
}
return ( return (
<RNScrollView <Animated.ScrollView
contentContainerStyle={[ contentContainerStyle={[
contentContainerStyle, contentContainerStyle,
pal.border, pal.border,
styles.contentContainer, styles.contentContainer,
]} ]}
// @ts-ignore something is wrong with the reanimated types -prf
ref={ref} ref={ref}
{...props} {...props}
/> />

View File

@ -10,6 +10,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
const AnimatedTouchableOpacity = const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity) Animated.createAnimatedComponent(TouchableOpacity)
import {isWeb} from 'platform/detection'
export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
onPress, onPress,
@ -47,7 +48,8 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loadLatest: { loadLatest: {
position: 'absolute', // @ts-ignore 'fixed' is web only -prf
position: isWeb ? 'fixed' : 'absolute',
left: 18, left: 18,
bottom: 44, bottom: 44,
borderWidth: 1, borderWidth: 1,

View File

@ -74,7 +74,7 @@ export function PostHider({
accessibilityHint=""> accessibilityHint="">
<ShieldExclamation size={18} style={pal.text} /> <ShieldExclamation size={18} style={pal.text} />
</Pressable> </Pressable>
<Text type="lg" style={pal.text}> <Text type="lg" style={[{flex: 1}, pal.text]} numberOfLines={1}>
{desc.name} {desc.name}
</Text> </Text>
{!moderation.noOverride && ( {!moderation.noOverride && (

View File

@ -45,7 +45,7 @@ export function ProfileHeaderAlerts({
accessibilityHint="" accessibilityHint=""
style={[styles.container, pal.viewLight, style]}> style={[styles.container, pal.viewLight, style]}>
<ShieldExclamation style={pal.text} size={24} /> <ShieldExclamation style={pal.text} size={24} />
<Text type="lg" style={pal.text}> <Text type="lg" style={[{flex: 1}, pal.text]}>
{desc.name} {desc.name}
</Text> </Text>
<Text type="lg" style={[pal.link, styles.learnMoreBtn]}> <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>

View File

@ -3,8 +3,8 @@ import {AppBskyFeedDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {FeedSourceModel} from 'state/models/content/feed-source'
import {CustomFeed} from 'view/com/feeds/CustomFeed' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
export function CustomFeedEmbed({ export function CustomFeedEmbed({
record, record,
@ -13,12 +13,13 @@ export function CustomFeedEmbed({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const item = useMemo( const item = useMemo(() => {
() => new CustomFeedModel(store, record), const model = new FeedSourceModel(store, record.uri)
[store, record], model.hydrateFeedGenerator(record)
) return model
}, [store, record])
return ( return (
<CustomFeed <FeedSourceCard
item={item} item={item}
style={[pal.view, pal.border, styles.customFeedOuter]} style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes showLikes

View File

@ -75,7 +75,7 @@ export function PostEmbeds({
return <CustomFeedEmbed record={embed.record} /> return <CustomFeedEmbed record={embed.record} />
} }
// list embed (e.g. mute lists; i.e. ListView) // list embed
if (AppBskyGraphDefs.isListView(embed.record)) { if (AppBskyGraphDefs.isListView(embed.record)) {
return <ListEmbed item={embed.record} /> return <ListEmbed item={embed.record} />
} }

View File

@ -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<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired(
observer(function CustomFeedScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>()
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 (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load feed
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return feedOwnerDid ? (
<CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}),
)
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<NavigationProp>()
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<FlatList>(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 (
<View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}>
<EmptyState icon="feed" message="This feed is empty!" />
</View>
)
}, [pal.border])
return (
<View style={s.hContentRegion}>
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<Text type="title-lg" style={styles.headerText} numberOfLines={1}>
{currentFeed ? (
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={currentFeed?.displayName || ''}
onPress={() => store.emitScreenSoftReset()}
/>
) : (
'Loading...'
)}
</Text>
{currentFeed ? (
<>
<Button
type="default-light"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}
style={styles.headerBtn}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{currentFeed?.isSaved ? (
<Button
type="default-light"
accessibilityLabel={
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}
style={styles.headerBtn}>
<FontAwesomeIcon
icon="thumb-tack"
size={17}
color={isPinned ? colors.blue3 : pal.colors.textLight}
style={styles.top1}
/>
</Button>
) : (
<Button
type="inverted"
onPress={onToggleSaved}
accessibilityLabel="Add to my feeds"
accessibilityHint=""
style={styles.headerAddBtn}>
<FontAwesomeIcon
icon="plus"
color={palInverted.colors.text}
size={19}
/>
<Text type="button" style={palInverted.text}>
Add{!isMobile && ' to My Feeds'}
</Text>
</Button>
)}
</>
) : null}
<NativeDropdown
testID="feedHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View
style={{
paddingLeft: 12,
paddingRight: isMobile ? 12 : 0,
}}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.textLight}
/>
</View>
</NativeDropdown>
</SimpleViewHeader>
<Feed
scrollElRef={scrollElRef}
feed={algoFeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState}
extraData={[uri, isPinned]}
style={!isTabletOrDesktop ? {flex: 1} : undefined}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onSoftReset}
label="Scroll to top"
showIndicator={false}
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
},
)
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,
},
})

View File

@ -2,7 +2,6 @@ import React from 'react'
import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} 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 {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader' import {ViewHeader} from 'view/com/util/ViewHeader'
import {FAB} from 'view/com/util/fab/FAB' 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 debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' 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 {FlatList} from 'view/com/util/Views'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {CustomFeed} from 'view/com/feeds/CustomFeed' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired( export const FeedsScreen = withAuthRequired(
@ -52,6 +52,10 @@ export const FeedsScreen = withAuthRequired(
} }
}, [store, myFeeds]), }, [store, myFeeds]),
) )
React.useEffect(() => {
// watch for changes to saved/pinned feeds
return myFeeds.registerListeners()
}, [myFeeds])
const onPressCompose = React.useCallback(() => { const onPressCompose = React.useCallback(() => {
store.shell.openComposer({}) store.shell.openComposer({})
@ -139,13 +143,7 @@ export const FeedsScreen = withAuthRequired(
</> </>
) )
} else if (item.type === 'saved-feed') { } else if (item.type === 'saved-feed') {
return ( return <SavedFeed feed={item.feed} />
<SavedFeed
uri={item.feed.uri}
avatar={item.feed.data.avatar}
displayName={item.feed.displayName}
/>
)
} else if (item.type === 'discover-feeds-header') { } else if (item.type === 'discover-feeds-header') {
return ( return (
<> <>
@ -187,7 +185,7 @@ export const FeedsScreen = withAuthRequired(
) )
} else if (item.type === 'discover-feed') { } else if (item.type === 'discover-feed') {
return ( return (
<CustomFeed <FeedSourceCard
item={item.feed} item={item.feed}
showSaveBtn showSaveBtn
showDescription showDescription
@ -257,33 +255,43 @@ export const FeedsScreen = withAuthRequired(
}), }),
) )
function SavedFeed({ function SavedFeed({feed}: {feed: FeedSourceModel}) {
uri,
avatar,
displayName,
}: {
uri: string
avatar: string | undefined
displayName: string
}) {
const pal = usePalette('default') const pal = usePalette('default')
const urip = new AtUri(uri)
const href = `/profile/${urip.hostname}/feed/${urip.rkey}`
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
return ( return (
<Link <Link
testID={`saved-feed-${displayName}`} testID={`saved-feed-${feed.displayName}`}
href={href} href={feed.href}
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
hoverStyle={pal.viewLight} hoverStyle={pal.viewLight}
accessibilityLabel={displayName} accessibilityLabel={feed.displayName}
accessibilityHint="" accessibilityHint=""
asAnchor asAnchor
anchorNoUnderline> anchorNoUnderline>
<UserAvatar type="algo" size={28} avatar={avatar} /> {feed.error ? (
<Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}> <View
{displayName} style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
</Text> <FontAwesomeIcon
icon="exclamation-circle"
color={pal.colors.textLight}
/>
</View>
) : (
<UserAvatar type="algo" size={28} avatar={feed.avatar} />
)}
<View
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
{feed.displayName}
</Text>
{feed.error && (
<View style={[styles.offlineSlug, pal.borderDark]}>
<Text type="xs" style={pal.textLight}>
Feed offline
</Text>
</View>
)}
</View>
{isMobile && ( {isMobile && (
<FontAwesomeIcon <FontAwesomeIcon
icon="chevron-right" icon="chevron-right"
@ -342,4 +350,10 @@ const styles = StyleSheet.create({
savedFeedMobile: { savedFeedMobile: {
paddingVertical: 10, paddingVertical: 10,
}, },
offlineSlug: {
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
}) })

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {useWindowDimensions} from 'react-native' import {useWindowDimensions} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
@ -30,29 +29,29 @@ export const HomeScreen = withAuthRequired(
>([]) >([])
React.useEffect(() => { React.useEffect(() => {
const {pinned} = store.me.savedFeeds const pinned = store.preferences.pinnedFeeds
if ( if (isEqual(pinned, requestedCustomFeeds)) {
isEqual(
pinned.map(p => p.uri),
requestedCustomFeeds,
)
) {
// no changes // no changes
return return
} }
const feeds = [] const feeds = []
for (const feed of pinned) { for (const uri of pinned) {
const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) if (uri.includes('app.bsky.feed.generator')) {
feeds.push(model) 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) pagerRef.current?.setPage(0)
setCustomFeeds(feeds) setCustomFeeds(feeds)
setRequestedCustomFeeds(pinned.map(p => p.uri)) setRequestedCustomFeeds(pinned)
}, [ }, [
store, store,
store.me.savedFeeds.pinned, store.preferences.pinnedFeeds,
customFeeds, customFeeds,
setCustomFeeds, setCustomFeeds,
pagerRef, pagerRef,
@ -124,7 +123,7 @@ export const HomeScreen = withAuthRequired(
{customFeeds.map((f, index) => { {customFeeds.map((f, index) => {
return ( return (
<FeedPage <FeedPage
key={(f.params as GetCustomFeed.QueryParams).feed} key={f.reactKey}
testID="customFeedPage" testID="customFeedPage"
isPageFocused={selectedPage === 1 + index} isPageFocused={selectedPage === 1 + index}
feed={f} feed={f}

View File

@ -0,0 +1,92 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useStores} from 'state/index'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListsList} from 'view/com/lists/ListsList'
import {Text} from 'view/com/util/text/Text'
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 {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
export const ListsScreen = withAuthRequired(
observer(function ListsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
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 (
<View style={s.hContentRegion} testID="listsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
User Lists
</Text>
<Text style={pal.textLight}>
Public, shareable lists which can drive feeds.
</Text>
</View>
<View>
<Button
testID="newUserListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<ListsList listsList={listsLists} />
</View>
)
}),
)

View File

@ -66,9 +66,9 @@ export const ModerationScreen = withAuthRequired(
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Link <Link
testID="mutelistsBtn" testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]} style={[styles.linkCard, pal.view]}
href="/moderation/mute-lists"> href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}> <View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="users-slash" icon="users-slash"
@ -76,7 +76,7 @@ export const ModerationScreen = withAuthRequired(
/> />
</View> </View>
<Text type="lg" style={pal.text}> <Text type="lg" style={pal.text}>
Mute lists Moderation lists
</Text> </Text>
</Link> </Link>
<Link <Link

View File

@ -0,0 +1,92 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useStores} from 'state/index'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListsList} from 'view/com/lists/ListsList'
import {Text} from 'view/com/util/text/Text'
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 {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
export const ModerationModlistsScreen = withAuthRequired(
observer(function ModerationModlistsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
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 (
<View style={s.hContentRegion} testID="moderationModlistsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
Moderation Lists
</Text>
<Text style={pal.textLight}>
Public, shareable lists of users to mute or block in bulk.
</Text>
</View>
<View>
<Button
testID="newModListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<ListsList listsList={mutelists} />
</View>
)
}),
)

View File

@ -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<NavigationProp>()
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 (
<EmptyStateWithButton
testID="emptyMuteLists"
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
const renderHeaderButton = React.useCallback(
() => (
<Button
type="primary-light"
onPress={onPressNewMuteList}
style={styles.createBtn}>
<FontAwesomeIcon
icon="plus"
style={pal.link as FontAwesomeIconStyle}
size={18}
/>
</Button>
),
[onPressNewMuteList, pal],
)
return (
<CenteredView
style={[
styles.container,
pal.view,
pal.border,
isTabletOrDesktop && styles.containerDesktop,
]}
testID="moderationMutelistsScreen">
<ViewHeader
title="Mute Lists"
showOnDesktop
renderButton={renderHeaderButton}
/>
<ListsList
listsList={mutelists}
showAddBtns={isTabletOrDesktop}
renderEmptyState={renderEmptyState}
onPressCreateNew={onPressNewMuteList}
/>
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
paddingBottom: 0,
},
createBtn: {
width: 40,
},
})

View File

@ -25,8 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {CustomFeed} from 'view/com/feeds/CustomFeed' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {FeedSourceModel} from 'state/models/content/feed-source'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names' import {combinedDisplayName} from 'lib/strings/display-names'
@ -189,9 +189,14 @@ export const ProfileScreen = withAuthRequired(
style={styles.emptyState} style={styles.emptyState}
/> />
) )
} else if (item instanceof CustomFeedModel) { } else if (item instanceof FeedSourceModel) {
return ( return (
<CustomFeed item={item} showSaveBtn showLikes showDescription /> <FeedSourceCard
item={item}
showSaveBtn
showLikes
showDescription
/>
) )
} }
// if section is posts or posts & replies // if section is posts or posts & replies

View File

@ -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<CommonNavigatorParams, 'ProfileFeed'>
export const ProfileFeedScreen = withAuthRequired(
observer(function ProfileFeedScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>()
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 (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load feed
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return feedOwnerDid ? (
<ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}),
)
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<SectionRef>(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 (
<ProfileSubpageHeader
isLoading={!feedInfo?.hasLoaded}
href={makeCustomFeedLink(feedOwnerDid, rkey)}
title={feedInfo?.displayName}
avatar={feedInfo?.avatar}
isOwner={feedInfo?.isOwner}
creator={
feedInfo
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
: undefined
}
avatarType="algo">
{feedInfo && (
<>
<Button
type="default"
label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
onPress={onToggleSaved}
style={styles.btn}
/>
<Button
type={isPinned ? 'default' : 'inverted'}
label={isPinned ? 'Unpin' : 'Pin to home'}
onPress={onTogglePinned}
style={styles.btn}
/>
</>
)}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.text}
/>
</View>
</NativeDropdown>
</ProfileSubpageHeader>
)
}, [
pal,
feedOwnerDid,
rkey,
feedInfo,
isPinned,
onTogglePinned,
onToggleSaved,
dropdownItems,
])
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection
key="1"
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight}) => (
<ScrollView
key="2"
onScroll={onScroll}
scrollEventThrottle={1}
contentContainerStyle={{paddingTop: headerHeight}}>
<AboutSection
feedOwnerDid={feedOwnerDid}
feedRkey={rkey}
feedInfo={feedInfo}
onToggleLiked={onToggleLiked}
/>
</ScrollView>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
},
)
interface FeedSectionProps {
feed: PostsFeedModel
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(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 <EmptyState icon="feed" message="This feed is empty!" />
}, [])
return (
<View>
<Feed
feed={feed}
scrollElRef={scrollElRef}
onScroll={onScroll}
scrollEventThrottle={5}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
/>
)}
</View>
)
},
)
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 <View />
}
return (
<View
style={[
{
borderTopWidth: 1,
paddingVertical: 20,
paddingHorizontal: 20,
gap: 12,
},
pal.border,
]}>
{feedInfo.descriptionRT ? (
<RichText
testID="listDescription"
type="lg"
style={pal.text}
richText={feedInfo.descriptionRT}
/>
) : (
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
No description
</Text>
)}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Button
type="default"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}
style={{paddingHorizontal: 10}}>
{feedInfo?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{typeof feedInfo.likeCount === 'number' && (
<TextLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
text={`Liked by ${feedInfo.likeCount} ${pluralize(
feedInfo.likeCount,
'user',
)}`}
style={[pal.textLight, s.semiBold]}
/>
)}
</View>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
Created by{' '}
{feedInfo.isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(feedInfo.creatorHandle, '@')}
href={makeProfileLink({
did: feedInfo.creatorDid,
handle: feedInfo.creatorHandle,
})}
style={pal.textLight}
/>
)}
</Text>
</View>
)
})
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,
},
})

View File

@ -8,8 +8,8 @@ import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedB
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers' import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores() const store = useStores()
const {name, rkey} = route.params const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)

View File

@ -1,166 +1,802 @@
import React from 'react' import React, {useCallback, useMemo} from 'react'
import {StyleSheet} from 'react-native' import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {RichText as RichTextAPI} from '@atproto/api'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' 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 {CenteredView} from 'view/com/util/Views'
import {ListItems} from 'view/com/lists/ListItems'
import {EmptyState} from 'view/com/util/EmptyState' 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 * 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 {ListModel} from 'state/models/content/list'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {ListActions} from 'view/com/lists/ListActions' import {resolveName} from 'lib/api'
import {s} from 'lib/styles' 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<CommonNavigatorParams, 'ProfileList'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired( export const ProfileListScreen = withAuthRequired(
observer(function ProfileListScreenImpl({route}: Props) { observer(function ProfileListScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isTabletOrDesktop} = useWebMediaQueries()
const pal = usePalette('default')
const {name, rkey} = route.params
const list: ListModel = React.useMemo(() => { const {name: handleOrDid} = props.route.params
const model = new ListModel(
store,
`at://${name}/app.bsky.graph.list/${rkey}`,
)
return model
}, [store, name, rkey])
useSetTitle(list.list?.name)
useFocusEffect( const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
React.useCallback(() => { const [error, setError] = React.useState<string | undefined>()
store.shell.setMinimalShellMode(false)
list.loadMore(true)
}, [store, list]),
)
const onToggleSubscribed = React.useCallback(async () => { const onPressBack = useCallback(() => {
try { if (navigation.canGoBack()) {
if (list.list?.viewer?.muted) { navigation.goBack()
await list.unsubscribe() } else {
} else { navigation.navigate('Home')
await list.subscribe()
}
} catch (err) {
Toast.show(
'There was an an issue updating your subscription, please check your internet connection and try again.',
)
store.log.error('Failed up update subscription', {err})
} }
}, [store, list]) }, [navigation])
const onPressEditList = React.useCallback(() => { React.useEffect(() => {
store.shell.openModal({ /*
name: 'create-or-edit-mute-list', * We must resolve the DID of the list owner before we can fetch the list.
list, */
onSave() { async function fetchDid() {
list.refresh() try {
}, const did = await resolveName(store, handleOrDid)
}) setListOwnerDid(did)
}, [store, list]) } 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(() => { fetchDid()
store.shell.openModal({ }, [store, handleOrDid, setListOwnerDid])
name: 'confirm',
title: 'Delete List',
message: 'Are you sure?',
async onPressConfirm() {
await list.delete()
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
})
}, [store, list, navigation])
const onPressReportList = React.useCallback(() => { if (error) {
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 <EmptyState icon="users-slash" message="This list is empty!" />
}, [])
const renderHeaderBtns = React.useCallback(() => {
return ( return (
<ListActions <CenteredView>
muted={list.list?.viewer?.muted} <View
isOwner={list.isOwner} style={[
onPressDeleteList={onPressDeleteList} pal.view,
onPressEditList={onPressEditList} pal.border,
onToggleSubscribed={onToggleSubscribed} {
onPressShareList={onPressShareList} margin: 10,
onPressReportList={onPressReportList} paddingHorizontal: 18,
reversed={true} paddingVertical: 14,
/> borderRadius: 6,
) },
}, [ ]}>
list.isOwner, <Text type="title-lg" style={[pal.text, s.mb10]}>
list.list?.viewer?.muted, Could not load list
onPressDeleteList, </Text>
onPressEditList, <Text type="md" style={[pal.text, s.mb20]}>
onPressShareList, {error}
onToggleSubscribed, </Text>
onPressReportList,
])
return ( <View style={{flexDirection: 'row'}}>
<CenteredView <Button
style={[ type="default"
styles.container, accessibilityLabel="Go Back"
isTabletOrDesktop && styles.containerDesktop, accessibilityHint="Return to previous page"
pal.view, onPress={onPressBack}
pal.border, style={{flexShrink: 1}}>
]} <Text type="button" style={pal.text}>
testID="moderationMutelistsScreen"> Go Back
<ViewHeader title="" renderButton={renderHeaderBtns} /> </Text>
<ListItems </Button>
list={list} </View>
renderEmptyState={renderEmptyState} </View>
onToggleSubscribed={onToggleSubscribed} </CenteredView>
onPressEditList={onPressEditList} )
onPressDeleteList={onPressDeleteList} }
onPressReportList={onPressReportList}
onPressShareList={onPressShareList} return listOwnerDid ? (
style={[s.flex1]} <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} />
/> ) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView> </CenteredView>
) )
}), }),
) )
const styles = StyleSheet.create({ export const ProfileListScreenInner = observer(
container: { function ProfileListScreenInnerImpl({
flex: 1, route,
paddingBottom: 100, listOwnerDid,
}: Props & {listOwnerDid: string}) {
const store = useStores()
const {rkey} = route.params
const feedSectionRef = React.useRef<SectionRef>(null)
const aboutSectionRef = React.useRef<SectionRef>(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 <Header rkey={rkey} list={list} />
}, [rkey, list])
if (list.isCuratelist) {
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES_CURATE}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection
key="1"
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection
key="2"
ref={aboutSectionRef}
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
isOwner={list.isOwner}
onPressAddUser={onPressAddUser}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
}
if (list.isModlist) {
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES_MOD}
renderHeader={renderHeader}>
{({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection
key="2"
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
isOwner={list.isOwner}
onPressAddUser={onPressAddUser}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
}
return <Header rkey={rkey} list={list} />
}, },
containerDesktop: { )
borderLeftWidth: 1,
borderRightWidth: 1, const Header = observer(function HeaderImpl({
paddingBottom: 0, rkey,
list,
}: {
rkey: string
list: ListModel
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
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 (
<ProfileSubpageHeader
isLoading={!list.hasLoaded}
href={makeListLink(
list.data?.creator.handle || list.data?.creator.did || '',
rkey,
)}
title={list.data?.name || 'User list'}
avatar={list.data?.avatar}
isOwner={list.isOwner}
creator={list.data?.creator}
avatarType="list">
{list.isCuratelist ? (
<Button
testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
type={list.isPinned ? 'default' : 'inverted'}
label={list.isPinned ? 'Unpin' : 'Pin to home'}
onPress={onTogglePinned}
/>
) : list.isModlist ? (
list.isBlocking ? (
<Button
testID="unblockBtn"
type="default"
label="Unblock"
onPress={onUnsubscribeBlock}
/>
) : list.isMuting ? (
<Button
testID="unmuteBtn"
type="default"
label="Unmute"
onPress={onUnsubscribeMute}
/>
) : (
<NativeDropdown
testID="subscribeBtn"
items={subscribeDropdownItems}
accessibilityLabel="Subscribe to this list"
accessibilityHint="">
<View style={[palInverted.view, styles.btn]}>
<Text style={palInverted.text}>Subscribe</Text>
</View>
</NativeDropdown>
)
) : null}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
</View>
</NativeDropdown>
</ProfileSubpageHeader>
)
})
interface FeedSectionProps {
feed: PostsFeedModel
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(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 <EmptyState icon="feed" message="This feed is empty!" />
}, [])
return (
<View>
<Feed
testID="listFeed"
feed={feed}
scrollElRef={scrollElRef}
onScroll={onScroll}
scrollEventThrottle={1}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
/>
)}
</View>
)
},
)
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<SectionRef, AboutSectionProps>(
function AboutSectionImpl(
{
list,
descriptionRT,
creator,
isCurateList,
isOwner,
onPressAddUser,
onScroll,
headerHeight,
isScrolledDown,
},
ref,
) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderHeader = React.useCallback(() => {
if (!list.data) {
return <View />
}
return (
<View>
<View
style={[
{
borderTopWidth: 1,
padding: isMobile ? 14 : 20,
gap: 12,
},
pal.border,
]}>
{descriptionRT ? (
<RichText
testID="listDescription"
type="lg"
style={pal.text}
richText={descriptionRT}
/>
) : (
<Text
testID="listDescriptionEmpty"
type="lg"
style={[{fontStyle: 'italic'}, pal.textLight]}>
No description
</Text>
)}
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{isCurateList ? 'User list' : 'Moderation list'} by{' '}
{isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(creator?.handle || '', '@')}
href={creator ? makeProfileLink(creator) : ''}
style={pal.textLight}
/>
)}
</Text>
</View>
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: isMobile ? 14 : 20,
paddingBottom: isMobile ? 14 : 18,
},
]}>
<Text type="lg-bold">Users</Text>
{isOwner && (
<Pressable
testID="addUserBtn"
accessibilityRole="button"
accessibilityLabel="Add a user to this list"
accessibilityHint=""
onPress={onPressAddUser}
style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<FontAwesomeIcon
icon="user-plus"
color={pal.colors.link}
size={16}
/>
<Text style={pal.link}>Add</Text>
</Pressable>
)}
</View>
</View>
)
}, [
pal,
list.data,
isMobile,
descriptionRT,
creator,
isCurateList,
isOwner,
onPressAddUser,
])
const renderEmptyState = useCallback(() => {
return (
<EmptyState
icon="users-slash"
message="This list is empty!"
style={{paddingTop: 40}}
/>
)
}, [])
return (
<View>
<ListItems
testID="listItems"
scrollElRef={scrollElRef}
renderHeader={renderHeader}
renderEmptyState={renderEmptyState}
list={list}
headerOffset={headerHeight}
onScroll={onScroll}
scrollEventThrottle={1}
/>
{isScrolledDown && (
<LoadLatestBtn
onPress={onScrollToTop}
label="Scroll to top"
showIndicator={false}
/>
)}
</View>
)
},
)
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
}, },
}) })

View File

@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types' import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader' import {ViewHeader} from 'view/com/util/ViewHeader'
@ -25,9 +26,9 @@ import DraggableFlatList, {
ShadowDecorator, ShadowDecorator,
ScaleDecorator, ScaleDecorator,
} from 'react-native-draggable-flatlist' } 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {Link, TextLink} from 'view/com/util/Link' import {Link, TextLink} from 'view/com/util/Link'
@ -41,7 +42,11 @@ export const SavedFeeds = withAuthRequired(
const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics() const {screen} = useAnalytics()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) const savedFeeds = useMemo(() => {
const model = new SavedFeedsModel(store)
model.refresh()
return model
}, [store])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
screen('SavedFeeds') screen('SavedFeeds')
@ -102,7 +107,7 @@ export const SavedFeeds = withAuthRequired(
const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
const onDragEnd = useCallback( const onDragEnd = useCallback(
async ({data}: {data: CustomFeedModel[]}) => { async ({data}: {data: FeedSourceModel[]}) => {
try { try {
await savedFeeds.reorderPinnedFeeds(data) await savedFeeds.reorderPinnedFeeds(data)
} catch (e) { } catch (e) {
@ -123,8 +128,8 @@ export const SavedFeeds = withAuthRequired(
<ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> <ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
<DraggableFlatList <DraggableFlatList
containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]} containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]}
data={savedFeeds.all} data={savedFeeds.pinned.concat(savedFeeds.unpinned)}
keyExtractor={item => item.data.uri} keyExtractor={item => item.uri}
refreshing={savedFeeds.isRefreshing} refreshing={savedFeeds.isRefreshing}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@ -134,7 +139,9 @@ export const SavedFeeds = withAuthRequired(
titleColor={pal.colors.text} titleColor={pal.colors.text}
/> />
} }
renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} renderItem={({item, drag}) => (
<ListItem savedFeeds={savedFeeds} item={item} drag={drag} />
)}
getItemLayout={(data, index) => ({ getItemLayout={(data, index) => ({
length: 77, length: 77,
offset: 77 * index, offset: 77 * index,
@ -152,24 +159,25 @@ export const SavedFeeds = withAuthRequired(
) )
const ListItem = observer(function ListItemImpl({ const ListItem = observer(function ListItemImpl({
savedFeeds,
item, item,
drag, drag,
}: { }: {
item: CustomFeedModel savedFeeds: SavedFeedsModel
item: FeedSourceModel
drag: () => void drag: () => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) const isPinned = item.isPinned
const isPinned = savedFeeds.isPinned(item)
const onTogglePinned = useCallback(() => { const onTogglePinned = useCallback(() => {
Haptics.default() Haptics.default()
savedFeeds.togglePinnedFeed(item).catch(e => { item.togglePin().catch(e => {
Toast.show('There was an issue contacting the server') Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e}) store.log.error('Failed to toggle pinned feed', {e})
}) })
}, [savedFeeds, item, store]) }, [item, store])
const onPressUp = useCallback( const onPressUp = useCallback(
() => () =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => { savedFeeds.movePinnedFeed(item, 'up').catch(e => {
@ -222,8 +230,8 @@ const ListItem = observer(function ListItemImpl({
style={s.ml20} style={s.ml20}
/> />
) : null} ) : null}
<CustomFeed <FeedSourceCard
key={item.data.uri} key={item.uri}
item={item} item={item}
showSaveBtn showSaveBtn
style={styles.noBorder} style={styles.noBorder}

View File

@ -29,6 +29,7 @@ import {
MagnifyingGlassIcon2Solid, MagnifyingGlassIcon2Solid,
UserIconSolid, UserIconSolid,
HashtagIcon, HashtagIcon,
ListIcon,
HandIcon, HandIcon,
} from 'lib/icons' } from 'lib/icons'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
@ -106,6 +107,12 @@ export const DrawerContent = observer(function DrawerContentImpl() {
[onPressTab], [onPressTab],
) )
const onPressLists = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Lists'})
navigation.navigate('Lists')
store.shell.closeDrawer()
}, [navigation, track, store.shell])
const onPressModeration = React.useCallback(() => { const onPressModeration = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Moderation'}) track('Menu:ItemClicked', {url: 'Moderation'})
navigation.navigate('Moderation') navigation.navigate('Moderation')
@ -276,6 +283,13 @@ export const DrawerContent = observer(function DrawerContentImpl() {
bold={isAtFeeds} bold={isAtFeeds}
onPress={onPressMyFeeds} onPress={onPressMyFeeds}
/> />
<MenuItem
icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />}
label="Lists"
accessibilityLabel="Lists"
accessibilityHint=""
onPress={onPressLists}
/>
<MenuItem <MenuItem
icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />}
label="Moderation" label="Moderation"

View File

@ -1,16 +1,17 @@
import React from 'react' import React from 'react'
import {View, StyleSheet} from 'react-native' import {View, StyleSheet} from 'react-native'
import {useNavigationState} from '@react-navigation/native' import {useNavigationState} from '@react-navigation/native'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems'
import {TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
export const DesktopFeeds = observer(function DesktopFeeds() { export const DesktopFeeds = observer(function DesktopFeeds() {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const items = useDesktopRightNavItems(store.preferences.pinnedFeeds)
const route = useNavigationState(state => { const route = useNavigationState(state => {
if (!state) { if (!state) {
@ -22,20 +23,22 @@ export const DesktopFeeds = observer(function DesktopFeeds() {
return ( return (
<View style={[styles.container, pal.view, pal.border]}> <View style={[styles.container, pal.view, pal.border]}>
<FeedItem href="/" title="Following" current={route.name === 'Home'} /> <FeedItem href="/" title="Following" current={route.name === 'Home'} />
{store.me.savedFeeds.pinned.map(feed => { {items.map(item => {
try { try {
const {hostname, rkey} = new AtUri(feed.uri)
const href = `/profile/${hostname}/feed/${rkey}`
const params = route.params as Record<string, string> const params = route.params as Record<string, string>
const routeName =
item.collection === 'app.bsky.feed.generator'
? 'ProfileFeed'
: 'ProfileList'
return ( return (
<FeedItem <FeedItem
key={feed.uri} key={item.uri}
href={href} href={item.href}
title={feed.displayName} title={item.displayName}
current={ current={
route.name === 'CustomFeed' && route.name === routeName &&
params.name === hostname && params.name === item.hostname &&
params.rkey === rkey params.rkey === item.rkey
} }
/> />
) )

View File

@ -31,8 +31,9 @@ import {
CogIcon, CogIcon,
CogIconSolid, CogIconSolid,
ComposeIcon2, ComposeIcon2,
HandIcon, ListIcon,
HashtagIcon, HashtagIcon,
HandIcon,
} from 'lib/icons' } from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
@ -319,13 +320,31 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
} }
label="Notifications" label="Notifications"
/> />
<NavItem
href="/lists"
icon={
<ListIcon
style={pal.text}
size={isDesktop ? 26 : 30}
strokeWidth={2}
/>
}
iconFilled={
<ListIcon
style={pal.text}
size={isDesktop ? 26 : 30}
strokeWidth={3}
/>
}
label="Lists"
/>
<NavItem <NavItem
href="/moderation" href="/moderation"
icon={ icon={
<HandIcon <HandIcon
strokeWidth={5.5}
style={pal.text} style={pal.text}
size={isDesktop ? 24 : 27} size={isDesktop ? 24 : 27}
strokeWidth={5.5}
/> />
} }
iconFilled={ iconFilled={

View File

@ -8202,6 +8202,11 @@ deprecated-react-native-prop-types@4.1.0:
invariant "*" invariant "*"
prop-types "*" 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: dequal@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.1.tgz#dbbf9795ec626e9da8bd68782f4add1d23700d8b" resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.1.tgz#dbbf9795ec626e9da8bd68782f4add1d23700d8b"
@ -18340,6 +18345,13 @@ use-callback-ref@^1.3.0:
dependencies: dependencies:
tslib "^2.0.0" 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: use-latest-callback@^0.1.5:
version "0.1.6" version "0.1.6"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6"