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
parent
f9944b55e2
commit
f57a8cf8ba
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
|
@ -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(
|
|
@ -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)
|
||||||
|
|
|
@ -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)"
|
||||||
],
|
],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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': {}
|
||||||
|
|
|
@ -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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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])
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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('/')
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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()])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 []
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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, '@')}
|
||||||
|
|
|
@ -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},
|
|
||||||
})
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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}}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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 />
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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.'
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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' &&
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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()}
|
||||||
|
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export {FlatList, ScrollView, View as CenteredView} from 'react-native'
|
|
@ -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} />
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
export {View as CenteredView, FlatList, ScrollView} from 'react-native'
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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]}>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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)
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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={
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue