Bluesky app fork with some witchin' additions 💫

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>

authored by

Paul Frazee
Eric Bailey
and committed by
GitHub
f57a8cf8 f9944b55

+4181 -2079
+208
__e2e__/tests/curate-lists.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' 4 + 5 + describe('Curate lists', () => { 6 + beforeAll(async () => { 7 + await createServer('?users&follows&posts') 8 + await openApp({ 9 + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 10 + }) 11 + }) 12 + 13 + it('Login and create a curatelists', async () => { 14 + await expect(element(by.id('signInButton'))).toBeVisible() 15 + await loginAsAlice() 16 + await element(by.id('e2eGotoLists')).tap() 17 + await element(by.id('newUserListBtn')).tap() 18 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 19 + await element(by.id('editNameInput')).typeText('Good Ppl') 20 + await element(by.id('editDescriptionInput')).typeText('They good') 21 + await element(by.id('saveBtn')).tap() 22 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 23 + await element(by.text('About')).tap() 24 + await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl') 25 + await expect(element(by.id('listDescription'))).toHaveText('They good') 26 + }) 27 + 28 + it('Edit display name and description via the edit curatelist modal', async () => { 29 + await element(by.id('headerDropdownBtn')).tap() 30 + await element(by.text('Edit List Details')).tap() 31 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 32 + await element(by.id('editNameInput')).clearText() 33 + await element(by.id('editNameInput')).typeText('Bad Ppl') 34 + await element(by.id('editDescriptionInput')).clearText() 35 + await element(by.id('editDescriptionInput')).typeText('They bad') 36 + await element(by.id('saveBtn')).tap() 37 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 38 + await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl') 39 + await expect(element(by.id('listDescription'))).toHaveText('They bad') 40 + // have to wait for the toast to clear 41 + await waitFor(element(by.id('headerDropdownBtn'))) 42 + .toBeVisible() 43 + .withTimeout(5000) 44 + }) 45 + 46 + it('Remove description via the edit curatelist modal', async () => { 47 + await element(by.id('headerDropdownBtn')).tap() 48 + await element(by.text('Edit List Details')).tap() 49 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 50 + await element(by.id('editDescriptionInput')).clearText() 51 + await element(by.id('saveBtn')).tap() 52 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 53 + await expect(element(by.id('listDescription'))).not.toBeVisible() 54 + // have to wait for the toast to clear 55 + await waitFor(element(by.id('headerDropdownBtn'))) 56 + .toBeVisible() 57 + .withTimeout(5000) 58 + }) 59 + 60 + it('Set avi via the edit curatelist modal', async () => { 61 + await expect(element(by.id('userAvatarFallback'))).toExist() 62 + await element(by.id('headerDropdownBtn')).tap() 63 + await element(by.text('Edit List Details')).tap() 64 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 65 + await element(by.id('changeAvatarBtn')).tap() 66 + await element(by.text('Library')).tap() 67 + await sleep(3e3) 68 + await element(by.id('saveBtn')).tap() 69 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 70 + await expect(element(by.id('userAvatarImage'))).toExist() 71 + // have to wait for the toast to clear 72 + await waitFor(element(by.id('headerDropdownBtn'))) 73 + .toBeVisible() 74 + .withTimeout(5000) 75 + }) 76 + 77 + it('Remove avi via the edit curatelist modal', async () => { 78 + await expect(element(by.id('userAvatarImage'))).toExist() 79 + await element(by.id('headerDropdownBtn')).tap() 80 + await element(by.text('Edit List Details')).tap() 81 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 82 + await element(by.id('changeAvatarBtn')).tap() 83 + await element(by.text('Remove')).tap() 84 + await element(by.id('saveBtn')).tap() 85 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 86 + await expect(element(by.id('userAvatarFallback'))).toExist() 87 + // have to wait for the toast to clear 88 + await waitFor(element(by.id('headerDropdownBtn'))) 89 + .toBeVisible() 90 + .withTimeout(5000) 91 + }) 92 + 93 + it('Delete the curatelist', async () => { 94 + await element(by.id('headerDropdownBtn')).tap() 95 + await element(by.text('Delete List')).tap() 96 + await element(by.id('confirmBtn')).tap() 97 + await expect(element(by.id('listsEmpty'))).toBeVisible() 98 + }) 99 + 100 + it('Create a new curatelist', async () => { 101 + await element(by.id('newUserListBtn')).tap() 102 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 103 + await element(by.id('editNameInput')).typeText('Good Ppl') 104 + await element(by.id('editDescriptionInput')).typeText('They good') 105 + await element(by.id('saveBtn')).tap() 106 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 107 + await element(by.text('About')).tap() 108 + await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl') 109 + await expect(element(by.id('listDescription'))).toHaveText('They good') 110 + }) 111 + 112 + it('Adds users on curatelists from the list', async () => { 113 + await element(by.text('About')).tap() 114 + await element(by.id('addUserBtn')).tap() 115 + await expect(element(by.id('listAddUserModal'))).toBeVisible() 116 + await waitFor(element(by.id('user-bob.test-addBtn'))) 117 + .toBeVisible() 118 + .withTimeout(5000) 119 + await element(by.id('user-bob.test-addBtn')).tap() 120 + await element(by.id('doneBtn')).tap() 121 + await expect(element(by.id('listAddUserModal'))).not.toBeVisible() 122 + await expect(element(by.id('user-bob.test'))).toBeVisible() 123 + }) 124 + 125 + it('Shows posts by the users in the list', async () => { 126 + await element(by.text('Posts')).tap() 127 + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() 128 + }) 129 + 130 + it('Pins the list', async () => { 131 + await element(by.id('pinBtn')).tap() 132 + await element(by.id('e2eGotoHome')).tap() 133 + await element(by.id('homeScreenFeedTabs-Good Ppl')).tap() 134 + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() 135 + 136 + await element(by.id('bottomBarFeedsBtn')).tap() 137 + await element(by.id('saved-feed-Good Ppl')).tap() 138 + await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible() 139 + 140 + await element(by.id('unpinBtn')).tap() 141 + await element(by.id('bottomBarHomeBtn')).tap() 142 + await expect( 143 + element(by.id('homeScreenFeedTabs-Good Ppl')), 144 + ).not.toBeVisible() 145 + 146 + await element(by.id('e2eGotoLists')).tap() 147 + await element(by.id('list-Good Ppl')).tap() 148 + }) 149 + 150 + it('Removes users on curatelists from the list', async () => { 151 + await element(by.text('About')).tap() 152 + await expect(element(by.id('user-bob.test'))).toBeVisible() 153 + await element(by.id('user-bob.test-editBtn')).tap() 154 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 155 + await element(by.id('toggleBtn-Good Ppl')).tap() 156 + await element(by.id('saveBtn')).tap() 157 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 158 + }) 159 + 160 + it('Shows the curatelist on my profile', async () => { 161 + await element(by.id('bottomBarProfileBtn')).tap() 162 + await element(by.id('selector')).swipe('left') 163 + await element(by.id('selector-4')).tap() 164 + await element(by.id('list-Good Ppl')).tap() 165 + }) 166 + 167 + it('Adds and removes users on curatelists from the profile', async () => { 168 + await element(by.id('bottomBarSearchBtn')).tap() 169 + await element(by.id('searchTextInput')).typeText('bob') 170 + await element(by.id('searchAutoCompleteResult-bob.test')).tap() 171 + await expect(element(by.id('profileView'))).toBeVisible() 172 + 173 + await element(by.id('profileHeaderDropdownBtn')).tap() 174 + await element(by.text('Add to Lists')).tap() 175 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 176 + await element(by.id('toggleBtn-Good Ppl')).tap() 177 + await element(by.id('saveBtn')).tap() 178 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 179 + 180 + await element(by.id('profileHeaderDropdownBtn')).tap() 181 + await element(by.text('Add to Lists')).tap() 182 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 183 + await element(by.id('toggleBtn-Good Ppl')).tap() 184 + await element(by.id('saveBtn')).tap() 185 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 186 + }) 187 + 188 + it('Can report a user list', async () => { 189 + await element(by.id('e2eGotoSettings')).tap() 190 + await element(by.id('signOutBtn')).tap() 191 + await loginAsBob() 192 + await element(by.id('bottomBarSearchBtn')).tap() 193 + await element(by.id('searchTextInput')).typeText('alice') 194 + await element(by.id('searchAutoCompleteResult-alice.test')).tap() 195 + await element(by.id('selector')).swipe('left') 196 + await element(by.id('selector-3')).tap() 197 + await element(by.id('list-Good Ppl')).tap() 198 + await element(by.id('headerDropdownBtn')).tap() 199 + await element(by.text('Report List')).tap() 200 + await expect(element(by.id('reportModal'))).toBeVisible() 201 + await expect(element(by.text('Report List'))).toBeVisible() 202 + await element( 203 + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), 204 + ).tap() 205 + await element(by.id('sendReportBtn')).tap() 206 + await expect(element(by.id('reportModal'))).not.toBeVisible() 207 + }) 208 + })
+187
__e2e__/tests/mod-lists.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' 4 + 5 + describe('Mod lists', () => { 6 + beforeAll(async () => { 7 + await createServer('?users&follows&labels') 8 + await openApp({ 9 + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 10 + }) 11 + }) 12 + 13 + it('Login and view my modlists', async () => { 14 + await expect(element(by.id('signInButton'))).toBeVisible() 15 + await loginAsAlice() 16 + await element(by.id('e2eGotoModeration')).tap() 17 + await element(by.id('moderationlistsBtn')).tap() 18 + await expect(element(by.id('list-Muted Users'))).toBeVisible() 19 + await element(by.id('list-Muted Users')).tap() 20 + await expect( 21 + element(by.id('user-muted-by-list-account.test')), 22 + ).toBeVisible() 23 + }) 24 + 25 + it('Toggle mute subscription', async () => { 26 + await element(by.id('unmuteBtn')).tap() 27 + await element(by.id('subscribeBtn')).tap() 28 + await element(by.text('Mute accounts')).tap() 29 + await element(by.id('confirmBtn')).tap() 30 + }) 31 + 32 + it('Edit display name and description via the edit modlist modal', async () => { 33 + await element(by.id('headerDropdownBtn')).tap() 34 + await element(by.text('Edit List Details')).tap() 35 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 36 + await element(by.id('editNameInput')).clearText() 37 + await element(by.id('editNameInput')).typeText('Bad Ppl') 38 + await element(by.id('editDescriptionInput')).clearText() 39 + await element(by.id('editDescriptionInput')).typeText('They bad') 40 + await element(by.id('saveBtn')).tap() 41 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 42 + await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl') 43 + await expect(element(by.id('listDescription'))).toHaveText('They bad') 44 + // have to wait for the toast to clear 45 + await waitFor(element(by.id('headerDropdownBtn'))) 46 + .toBeVisible() 47 + .withTimeout(5000) 48 + }) 49 + 50 + it('Remove description via the edit modlist modal', async () => { 51 + await element(by.id('headerDropdownBtn')).tap() 52 + await element(by.text('Edit List Details')).tap() 53 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 54 + await element(by.id('editDescriptionInput')).clearText() 55 + await element(by.id('saveBtn')).tap() 56 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 57 + await expect(element(by.id('listDescription'))).not.toBeVisible() 58 + // have to wait for the toast to clear 59 + await waitFor(element(by.id('headerDropdownBtn'))) 60 + .toBeVisible() 61 + .withTimeout(5000) 62 + }) 63 + 64 + it('Set avi via the edit modlist modal', async () => { 65 + await expect(element(by.id('userAvatarFallback'))).toExist() 66 + await element(by.id('headerDropdownBtn')).tap() 67 + await element(by.text('Edit List Details')).tap() 68 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 69 + await element(by.id('changeAvatarBtn')).tap() 70 + await element(by.text('Library')).tap() 71 + await sleep(3e3) 72 + await element(by.id('saveBtn')).tap() 73 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 74 + await expect(element(by.id('userAvatarImage'))).toExist() 75 + // have to wait for the toast to clear 76 + await waitFor(element(by.id('headerDropdownBtn'))) 77 + .toBeVisible() 78 + .withTimeout(5000) 79 + }) 80 + 81 + it('Remove avi via the edit modlist modal', async () => { 82 + await expect(element(by.id('userAvatarImage'))).toExist() 83 + await element(by.id('headerDropdownBtn')).tap() 84 + await element(by.text('Edit List Details')).tap() 85 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 86 + await element(by.id('changeAvatarBtn')).tap() 87 + await element(by.text('Remove')).tap() 88 + await element(by.id('saveBtn')).tap() 89 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 90 + await expect(element(by.id('userAvatarFallback'))).toExist() 91 + // have to wait for the toast to clear 92 + await waitFor(element(by.id('headerDropdownBtn'))) 93 + .toBeVisible() 94 + .withTimeout(5000) 95 + }) 96 + 97 + it('Delete the modlist', async () => { 98 + await element(by.id('headerDropdownBtn')).tap() 99 + await element(by.text('Delete List')).tap() 100 + await element(by.id('confirmBtn')).tap() 101 + await expect(element(by.id('listsEmpty'))).toBeVisible() 102 + }) 103 + 104 + it('Create a new modlist', async () => { 105 + await element(by.id('newModListBtn')).tap() 106 + await expect(element(by.id('createOrEditListModal'))).toBeVisible() 107 + await element(by.id('editNameInput')).typeText('Bad Ppl') 108 + await element(by.id('editDescriptionInput')).typeText('They bad') 109 + await element(by.id('saveBtn')).tap() 110 + await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() 111 + await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl') 112 + await expect(element(by.id('listDescription'))).toHaveText('They bad') 113 + }) 114 + 115 + it('Adds and removes users on modlists from the list', async () => { 116 + await element(by.id('addUserBtn')).tap() 117 + await expect(element(by.id('listAddUserModal'))).toBeVisible() 118 + await waitFor(element(by.id('user-warn-posts.test-addBtn'))) 119 + .toBeVisible() 120 + .withTimeout(5000) 121 + await element(by.id('user-warn-posts.test-addBtn')).tap() 122 + await element(by.id('doneBtn')).tap() 123 + await expect(element(by.id('listAddUserModal'))).not.toBeVisible() 124 + await element(by.id('listItems-flatlist')).swipe( 125 + 'down', 126 + 'slow', 127 + 1, 128 + 0.5, 129 + 0.5, 130 + ) 131 + await expect(element(by.id('user-warn-posts.test'))).toBeVisible() 132 + await element(by.id('user-warn-posts.test-editBtn')).tap() 133 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 134 + await element(by.id('toggleBtn-Bad Ppl')).tap() 135 + await element(by.id('saveBtn')).tap() 136 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 137 + }) 138 + 139 + it('Shows the modlist on my profile', async () => { 140 + await element(by.id('bottomBarProfileBtn')).tap() 141 + await element(by.id('selector')).swipe('left') 142 + await element(by.id('selector-4')).tap() 143 + await element(by.id('list-Bad Ppl')).tap() 144 + }) 145 + 146 + it('Adds and removes users on modlists from the profile', async () => { 147 + await element(by.id('bottomBarSearchBtn')).tap() 148 + await element(by.id('searchTextInput')).typeText('bob') 149 + await element(by.id('searchAutoCompleteResult-bob.test')).tap() 150 + await expect(element(by.id('profileView'))).toBeVisible() 151 + 152 + await element(by.id('profileHeaderDropdownBtn')).tap() 153 + await element(by.text('Add to Lists')).tap() 154 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 155 + await element(by.id('toggleBtn-Bad Ppl')).tap() 156 + await element(by.id('saveBtn')).tap() 157 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 158 + 159 + await element(by.id('profileHeaderDropdownBtn')).tap() 160 + await element(by.text('Add to Lists')).tap() 161 + await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible() 162 + await element(by.id('toggleBtn-Bad Ppl')).tap() 163 + await element(by.id('saveBtn')).tap() 164 + await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible() 165 + }) 166 + 167 + it('Can report a mute list', async () => { 168 + await element(by.id('e2eGotoSettings')).tap() 169 + await element(by.id('signOutBtn')).tap() 170 + await loginAsBob() 171 + await element(by.id('bottomBarSearchBtn')).tap() 172 + await element(by.id('searchTextInput')).typeText('alice') 173 + await element(by.id('searchAutoCompleteResult-alice.test')).tap() 174 + await element(by.id('selector')).swipe('left') 175 + await element(by.id('selector-3')).tap() 176 + await element(by.id('list-Bad Ppl')).tap() 177 + await element(by.id('headerDropdownBtn')).tap() 178 + await element(by.text('Report List')).tap() 179 + await expect(element(by.id('reportModal'))).toBeVisible() 180 + await expect(element(by.text('Report List'))).toBeVisible() 181 + await element( 182 + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), 183 + ).tap() 184 + await element(by.id('sendReportBtn')).tap() 185 + await expect(element(by.id('reportModal'))).not.toBeVisible() 186 + }) 187 + })
-159
__e2e__/tests/mute-lists.test.ts
··· 1 - /* eslint-env detox/detox */ 2 - 3 - import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' 4 - 5 - describe('Mute lists', () => { 6 - beforeAll(async () => { 7 - await createServer('?users&follows&labels') 8 - await openApp({ 9 - permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 10 - }) 11 - }) 12 - 13 - it('Login and view my mutelists', async () => { 14 - await expect(element(by.id('signInButton'))).toBeVisible() 15 - await loginAsAlice() 16 - await element(by.id('e2eGotoModeration')).tap() 17 - await element(by.id('mutelistsBtn')).tap() 18 - await expect(element(by.id('list-Muted Users'))).toBeVisible() 19 - await element(by.id('list-Muted Users')).tap() 20 - await expect( 21 - element(by.id('user-muted-by-list-account.test')), 22 - ).toBeVisible() 23 - }) 24 - 25 - it('Toggle subscription', async () => { 26 - await element(by.id('unsubscribeListBtn')).tap() 27 - await element(by.id('subscribeListBtn')).tap() 28 - }) 29 - 30 - it('Edit display name and description via the edit mutelist modal', async () => { 31 - await element(by.id('editListBtn')).tap() 32 - await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 33 - await element(by.id('editNameInput')).clearText() 34 - await element(by.id('editNameInput')).typeText('Bad Ppl') 35 - await element(by.id('editDescriptionInput')).clearText() 36 - await element(by.id('editDescriptionInput')).typeText('They bad') 37 - await element(by.id('saveBtn')).tap() 38 - await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 39 - await expect(element(by.id('listName'))).toHaveText('Bad Ppl') 40 - await expect(element(by.id('listDescription'))).toHaveText('They bad') 41 - // have to wait for the toast to clear 42 - await waitFor(element(by.id('editListBtn'))) 43 - .toBeVisible() 44 - .withTimeout(5000) 45 - }) 46 - 47 - it('Remove description via the edit mutelist modal', async () => { 48 - await element(by.id('editListBtn')).tap() 49 - await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 50 - await element(by.id('editDescriptionInput')).clearText() 51 - await element(by.id('saveBtn')).tap() 52 - await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 53 - await expect(element(by.id('listDescription'))).not.toBeVisible() 54 - // have to wait for the toast to clear 55 - await waitFor(element(by.id('editListBtn'))) 56 - .toBeVisible() 57 - .withTimeout(5000) 58 - }) 59 - 60 - it('Set avi via the edit mutelist modal', async () => { 61 - await expect(element(by.id('userAvatarFallback'))).toExist() 62 - await element(by.id('editListBtn')).tap() 63 - await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 64 - await element(by.id('changeAvatarBtn')).tap() 65 - await element(by.text('Library')).tap() 66 - await sleep(3e3) 67 - await element(by.id('saveBtn')).tap() 68 - await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 69 - await expect(element(by.id('userAvatarImage'))).toExist() 70 - // have to wait for the toast to clear 71 - await waitFor(element(by.id('editListBtn'))) 72 - .toBeVisible() 73 - .withTimeout(5000) 74 - }) 75 - 76 - it('Remove avi via the edit mutelist modal', async () => { 77 - await expect(element(by.id('userAvatarImage'))).toExist() 78 - await element(by.id('editListBtn')).tap() 79 - await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 80 - await element(by.id('changeAvatarBtn')).tap() 81 - await element(by.text('Remove')).tap() 82 - await element(by.id('saveBtn')).tap() 83 - await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 84 - await expect(element(by.id('userAvatarFallback'))).toExist() 85 - // have to wait for the toast to clear 86 - await waitFor(element(by.id('editListBtn'))) 87 - .toBeVisible() 88 - .withTimeout(5000) 89 - }) 90 - 91 - it('Delete the mutelist', async () => { 92 - await element(by.id('deleteListBtn')).tap() 93 - await element(by.id('confirmBtn')).tap() 94 - await expect(element(by.id('emptyMuteLists'))).toBeVisible() 95 - }) 96 - 97 - it('Create a new mutelist', async () => { 98 - await element(by.id('emptyMuteLists-button')).tap() 99 - await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() 100 - await element(by.id('editNameInput')).typeText('Bad Ppl') 101 - await element(by.id('editDescriptionInput')).typeText('They bad') 102 - await element(by.id('saveBtn')).tap() 103 - await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() 104 - await expect(element(by.id('listName'))).toHaveText('Bad Ppl') 105 - await expect(element(by.id('listDescription'))).toHaveText('They bad') 106 - // have to wait for the toast to clear 107 - await waitFor(element(by.id('editListBtn'))) 108 - .toBeVisible() 109 - .withTimeout(5000) 110 - }) 111 - 112 - it('Shows the mutelist on my profile', async () => { 113 - await element(by.id('bottomBarProfileBtn')).tap() 114 - await element(by.id('selector')).swipe('left') 115 - await element(by.id('selector-4')).tap() 116 - await element(by.id('list-Bad Ppl')).tap() 117 - }) 118 - 119 - it('Adds and removes users on mutelists', async () => { 120 - await element(by.id('bottomBarSearchBtn')).tap() 121 - await element(by.id('searchTextInput')).typeText('bob') 122 - await element(by.id('searchAutoCompleteResult-bob.test')).tap() 123 - await expect(element(by.id('profileView'))).toBeVisible() 124 - 125 - await element(by.id('profileHeaderDropdownBtn')).tap() 126 - await element(by.text('Add to Lists')).tap() 127 - await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() 128 - await element(by.id('toggleBtn-Bad Ppl')).tap() 129 - await element(by.id('saveBtn')).tap() 130 - await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() 131 - 132 - await element(by.id('profileHeaderDropdownBtn')).tap() 133 - await element(by.text('Add to Lists')).tap() 134 - await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() 135 - await element(by.id('toggleBtn-Bad Ppl')).tap() 136 - await element(by.id('saveBtn')).tap() 137 - await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() 138 - }) 139 - 140 - it('Can report a mute list', async () => { 141 - await element(by.id('e2eGotoSettings')).tap() 142 - await element(by.id('signOutBtn')).tap() 143 - await loginAsBob() 144 - await element(by.id('bottomBarSearchBtn')).tap() 145 - await element(by.id('searchTextInput')).typeText('alice') 146 - await element(by.id('searchAutoCompleteResult-alice.test')).tap() 147 - await element(by.id('selector')).swipe('left') 148 - await element(by.id('selector-3')).tap() 149 - await element(by.id('list-Bad Ppl')).tap() 150 - await element(by.id('reportListBtn')).tap() 151 - await expect(element(by.id('reportModal'))).toBeVisible() 152 - await expect(element(by.text('Report List'))).toBeVisible() 153 - await element( 154 - by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), 155 - ).tap() 156 - await element(by.id('sendReportBtn')).tap() 157 - await expect(element(by.id('reportModal'))).not.toBeVisible() 158 - }) 159 - })
+2 -1
bskyweb/cmd/bskyweb/server.go
··· 181 181 e.GET("/search", server.WebGeneric) 182 182 e.GET("/feeds", server.WebGeneric) 183 183 e.GET("/notifications", server.WebGeneric) 184 + e.GET("/lists", server.WebGeneric) 184 185 e.GET("/moderation", server.WebGeneric) 185 - e.GET("/moderation/mute-lists", server.WebGeneric) 186 + e.GET("/moderation/modlists", server.WebGeneric) 186 187 e.GET("/moderation/muted-accounts", server.WebGeneric) 187 188 e.GET("/moderation/blocked-accounts", server.WebGeneric) 188 189 e.GET("/settings", server.WebGeneric)
+4
package.json
··· 157 157 "sentry-expo": "~7.0.1", 158 158 "tippy.js": "^6.3.7", 159 159 "tlds": "^1.234.0", 160 + "use-deep-compare": "^1.1.0", 160 161 "zeego": "^1.6.2", 161 162 "zod": "^3.20.2" 162 163 }, ··· 236 237 "json", 237 238 "node" 238 239 ], 240 + "transform": { 241 + "\\.[jt]sx?$": "babel-jest" 242 + }, 239 243 "transformIgnorePatterns": [ 240 244 "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)" 241 245 ],
+17 -11
src/Navigation.tsx
··· 43 43 import {SearchScreen} from './view/screens/Search' 44 44 import {FeedsScreen} from './view/screens/Feeds' 45 45 import {NotificationsScreen} from './view/screens/Notifications' 46 + import {ListsScreen} from './view/screens/Lists' 46 47 import {ModerationScreen} from './view/screens/Moderation' 47 - import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' 48 + import {ModerationModlistsScreen} from './view/screens/ModerationModlists' 48 49 import {NotFoundScreen} from './view/screens/NotFound' 49 50 import {SettingsScreen} from './view/screens/Settings' 50 51 import {LanguageSettingsScreen} from './view/screens/LanguageSettings' 51 52 import {ProfileScreen} from './view/screens/Profile' 52 53 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' 53 54 import {ProfileFollowsScreen} from './view/screens/ProfileFollows' 54 - import {CustomFeedScreen} from './view/screens/CustomFeed' 55 - import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy' 55 + import {ProfileFeedScreen} from './view/screens/ProfileFeed' 56 + import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' 56 57 import {ProfileListScreen} from './view/screens/ProfileList' 57 58 import {PostThreadScreen} from './view/screens/PostThread' 58 59 import {PostLikedByScreen} from './view/screens/PostLikedBy' ··· 96 97 options={{title: title('Not Found')}} 97 98 /> 98 99 <Stack.Screen 100 + name="Lists" 101 + component={ListsScreen} 102 + options={{title: title('Lists')}} 103 + /> 104 + <Stack.Screen 99 105 name="Moderation" 100 106 getComponent={() => ModerationScreen} 101 107 options={{title: title('Moderation')}} 102 108 /> 103 109 <Stack.Screen 104 - name="ModerationMuteLists" 105 - getComponent={() => ModerationMuteListsScreen} 106 - options={{title: title('Mute Lists')}} 110 + name="ModerationModlists" 111 + getComponent={() => ModerationModlistsScreen} 112 + options={{title: title('Moderation Lists')}} 107 113 /> 108 114 <Stack.Screen 109 115 name="ModerationMutedAccounts" ··· 150 156 <Stack.Screen 151 157 name="ProfileList" 152 158 getComponent={() => ProfileListScreen} 153 - options={{title: title('Mute List')}} 159 + options={{title: title('List')}} 154 160 /> 155 161 <Stack.Screen 156 162 name="PostThread" ··· 168 174 options={({route}) => ({title: title(`Post by @${route.params.name}`)})} 169 175 /> 170 176 <Stack.Screen 171 - name="CustomFeed" 172 - getComponent={() => CustomFeedScreen} 177 + name="ProfileFeed" 178 + getComponent={() => ProfileFeedScreen} 173 179 options={{title: title('Feed')}} 174 180 /> 175 181 <Stack.Screen 176 - name="CustomFeedLikedBy" 177 - getComponent={() => CustomFeedLikedByScreen} 182 + name="ProfileFeedLikedBy" 183 + getComponent={() => ProfileFeedLikedByScreen} 178 184 options={{title: title('Liked by')}} 179 185 /> 180 186 <Stack.Screen
+7 -4
src/lib/analytics/types.ts
··· 97 97 // LISTS events 98 98 'Lists:onRefresh': {} 99 99 'Lists:onEndReached': {} 100 - 'CreateMuteList:AvatarSelected': {} 101 - 'CreateMuteList:Save': {} // CAN BE SERVER 102 - 'Lists:Subscribe': {} // CAN BE SERVER 103 - 'Lists:Unsubscribe': {} // CAN BE SERVER 100 + 'CreateList:AvatarSelected': {} 101 + 'CreateList:SaveCurateList': {} // CAN BE SERVER 102 + 'CreateList:SaveModList': {} // CAN BE SERVER 103 + 'Lists:Mute': {} // CAN BE SERVER 104 + 'Lists:Unmute': {} // CAN BE SERVER 105 + 'Lists:Block': {} // CAN BE SERVER 106 + 'Lists:Unblock': {} // CAN BE SERVER 104 107 // CUSTOM FEED events 105 108 'CustomFeed:Save': {} 106 109 'CustomFeed:Unsave': {}
+45
src/lib/api/feed/list.ts
··· 1 + import { 2 + AppBskyFeedDefs, 3 + AppBskyFeedGetListFeed as GetListFeed, 4 + } from '@atproto/api' 5 + import {RootStoreModel} from 'state/index' 6 + import {FeedAPI, FeedAPIResponse} from './types' 7 + 8 + export class ListFeedAPI implements FeedAPI { 9 + cursor: string | undefined 10 + 11 + constructor( 12 + public rootStore: RootStoreModel, 13 + public params: GetListFeed.QueryParams, 14 + ) {} 15 + 16 + reset() { 17 + this.cursor = undefined 18 + } 19 + 20 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 21 + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ 22 + ...this.params, 23 + limit: 1, 24 + }) 25 + return res.data.feed[0] 26 + } 27 + 28 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 29 + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ 30 + ...this.params, 31 + cursor: this.cursor, 32 + limit, 33 + }) 34 + if (res.success) { 35 + this.cursor = res.data.cursor 36 + return { 37 + cursor: res.data.cursor, 38 + feed: res.data.feed, 39 + } 40 + } 41 + return { 42 + feed: [], 43 + } 44 + } 45 + }
+41 -33
src/lib/api/feed/merge.ts
··· 114 114 } 115 115 if (this.customFeeds.length === 0) { 116 116 this.customFeeds = shuffle( 117 - this.rootStore.me.savedFeeds.all.map( 118 - feed => 119 - new MergeFeedSource_Custom( 120 - this.rootStore, 121 - feed.uri, 122 - feed.displayName, 123 - ), 117 + this.rootStore.preferences.savedFeeds.map( 118 + feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), 124 119 ), 125 120 ) 126 121 } ··· 213 208 class MergeFeedSource_Custom extends MergeFeedSource { 214 209 minDate: Date 215 210 216 - constructor( 217 - public rootStore: RootStoreModel, 218 - public feedUri: string, 219 - public feedDisplayName: string, 220 - ) { 211 + constructor(public rootStore: RootStoreModel, public feedUri: string) { 221 212 super(rootStore) 222 213 this.sourceInfo = { 223 - displayName: feedDisplayName, 214 + displayName: feedUri.split('/').pop() || '', 224 215 uri: feedUriToHref(feedUri), 225 216 } 226 217 this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) 218 + this.rootStore.agent.app.bsky.feed 219 + .getFeedGenerator({ 220 + feed: feedUri, 221 + }) 222 + .then( 223 + res => { 224 + if (this.sourceInfo) { 225 + this.sourceInfo.displayName = res.data.view.displayName 226 + } 227 + }, 228 + _err => {}, 229 + ) 227 230 } 228 231 229 232 protected async _getFeed( 230 233 cursor: string | undefined, 231 234 limit: number, 232 235 ): Promise<AppBskyFeedGetTimeline.Response> { 233 - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 234 - cursor, 235 - limit, 236 - feed: this.feedUri, 237 - }) 238 - // NOTE 239 - // some custom feeds fail to enforce the pagination limit 240 - // so we manually truncate here 241 - // -prf 242 - if (limit && res.data.feed.length > limit) { 243 - res.data.feed = res.data.feed.slice(0, limit) 244 - } 245 - // filter out older posts 246 - res.data.feed = res.data.feed.filter( 247 - post => new Date(post.post.indexedAt) > this.minDate, 248 - ) 249 - // attach source info 250 - for (const post of res.data.feed) { 251 - post.__source = this.sourceInfo 236 + try { 237 + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 238 + cursor, 239 + limit, 240 + feed: this.feedUri, 241 + }) 242 + // NOTE 243 + // some custom feeds fail to enforce the pagination limit 244 + // so we manually truncate here 245 + // -prf 246 + if (limit && res.data.feed.length > limit) { 247 + res.data.feed = res.data.feed.slice(0, limit) 248 + } 249 + // filter out older posts 250 + res.data.feed = res.data.feed.filter( 251 + post => new Date(post.post.indexedAt) > this.minDate, 252 + ) 253 + // attach source info 254 + for (const post of res.data.feed) { 255 + post.__source = this.sourceInfo 256 + } 257 + return res 258 + } catch { 259 + // dont bubble custom-feed errors 260 + return {success: false, headers: {}, data: {feed: []}} 252 261 } 253 - return res 254 262 } 255 263 }
+25
src/lib/async/accumulate.ts
··· 1 + export interface AccumulateResponse<T> { 2 + cursor?: string 3 + items: T[] 4 + } 5 + 6 + export type AccumulateFetchFn<T> = ( 7 + cursor: string | undefined, 8 + ) => Promise<AccumulateResponse<T>> 9 + 10 + export async function accumulate<T>( 11 + fn: AccumulateFetchFn<T>, 12 + pageLimit = 100, 13 + ): Promise<T[]> { 14 + let cursor: string | undefined 15 + let acc: T[] = [] 16 + for (let i = 0; i < pageLimit; i++) { 17 + const res = await fn(cursor) 18 + cursor = res.cursor 19 + acc = acc.concat(res.items) 20 + if (!cursor) { 21 + break 22 + } 23 + } 24 + return acc 25 + }
+24
src/lib/async/until.ts
··· 1 + import {timeout} from './timeout' 2 + 3 + export async function until( 4 + retries: number, 5 + delay: number, 6 + cond: (v: any, err: any) => boolean, 7 + fn: () => Promise<any>, 8 + ): Promise<boolean> { 9 + while (retries > 0) { 10 + try { 11 + const v = await fn() 12 + if (cond(v, undefined)) { 13 + return true 14 + } 15 + } catch (e: any) { 16 + if (cond(undefined, e)) { 17 + return true 18 + } 19 + } 20 + await timeout(delay) 21 + retries-- 22 + } 23 + return false 24 + }
+6 -15
src/lib/hooks/useCustomFeed.ts
··· 1 1 import {useEffect, useState} from 'react' 2 2 import {useStores} from 'state/index' 3 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 3 + import {FeedSourceModel} from 'state/models/content/feed-source' 4 4 5 - export function useCustomFeed(uri: string): CustomFeedModel | undefined { 5 + export function useCustomFeed(uri: string): FeedSourceModel | undefined { 6 6 const store = useStores() 7 - const [item, setItem] = useState<CustomFeedModel | undefined>() 7 + const [item, setItem] = useState<FeedSourceModel | undefined>() 8 8 useEffect(() => { 9 - async function fetchView() { 10 - const res = await store.agent.app.bsky.feed.getFeedGenerator({ 11 - feed: uri, 12 - }) 13 - const view = res.data.view 14 - return view 15 - } 16 9 async function buildFeedItem() { 17 - const view = await fetchView() 18 - if (view) { 19 - const temp = new CustomFeedModel(store, view) 20 - setItem(temp) 21 - } 10 + const model = new FeedSourceModel(store, uri) 11 + await model.setup() 12 + setItem(model) 22 13 } 23 14 buildFeedItem() 24 15 }, [store, uri])
+51
src/lib/hooks/useDesktopRightNavItems.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import {useStores} from 'state/index' 3 + import isEqual from 'lodash.isequal' 4 + import {AtUri} from '@atproto/api' 5 + import {FeedSourceModel} from 'state/models/content/feed-source' 6 + 7 + interface RightNavItem { 8 + uri: string 9 + href: string 10 + hostname: string 11 + collection: string 12 + rkey: string 13 + displayName: string 14 + } 15 + 16 + export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { 17 + const store = useStores() 18 + const [items, setItems] = useState<RightNavItem[]>([]) 19 + const [lastUris, setLastUris] = useState<string[]>([]) 20 + 21 + useEffect(() => { 22 + if (isEqual(uris, lastUris)) { 23 + // no changes 24 + return 25 + } 26 + 27 + async function fetchFeedInfo() { 28 + const models = uris 29 + .slice(0, 25) 30 + .map(uri => new FeedSourceModel(store, uri)) 31 + await Promise.all(models.map(m => m.setup())) 32 + setItems( 33 + models.map(model => { 34 + const {hostname, collection, rkey} = new AtUri(model.uri) 35 + return { 36 + uri: model.uri, 37 + href: model.href, 38 + hostname, 39 + collection, 40 + rkey, 41 + displayName: model.displayName, 42 + } 43 + }), 44 + ) 45 + setLastUris(uris) 46 + } 47 + fetchFeedInfo() 48 + }, [store, uris, lastUris, setLastUris, setItems]) 49 + 50 + return items 51 + }
+29
src/lib/hooks/useHomeTabs.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import {useStores} from 'state/index' 3 + import isEqual from 'lodash.isequal' 4 + import {FeedSourceModel} from 'state/models/content/feed-source' 5 + 6 + export function useHomeTabs(uris: string[]): string[] { 7 + const store = useStores() 8 + const [tabs, setTabs] = useState<string[]>(['Following']) 9 + const [lastUris, setLastUris] = useState<string[]>([]) 10 + 11 + useEffect(() => { 12 + if (isEqual(uris, lastUris)) { 13 + // no changes 14 + return 15 + } 16 + 17 + async function fetchFeedInfo() { 18 + const models = uris 19 + .slice(0, 25) 20 + .map(uri => new FeedSourceModel(store, uri)) 21 + await Promise.all(models.map(m => m.setup())) 22 + setTabs(['Following'].concat(models.map(f => f.displayName))) 23 + setLastUris(uris) 24 + } 25 + fetchFeedInfo() 26 + }, [store, uris, lastUris, setLastUris, setTabs]) 27 + 28 + return tabs 29 + }
+27
src/lib/icons.tsx
··· 947 947 </Svg> 948 948 ) 949 949 } 950 + 951 + export function ListIcon({ 952 + style, 953 + size, 954 + strokeWidth = 1.5, 955 + }: { 956 + style?: StyleProp<TextStyle> 957 + size?: string | number 958 + strokeWidth?: number 959 + }) { 960 + return ( 961 + <Svg 962 + fill="none" 963 + viewBox="0 0 24 24" 964 + strokeWidth={strokeWidth || 1.5} 965 + stroke="currentColor" 966 + width={size} 967 + height={size} 968 + style={style}> 969 + <Path 970 + strokeLinecap="round" 971 + strokeLinejoin="round" 972 + 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" 973 + /> 974 + </Svg> 975 + ) 976 + }
+12 -3
src/lib/moderation.ts
··· 17 17 } 18 18 } 19 19 if (cause.type === 'blocking') { 20 - return { 21 - name: 'User Blocked', 22 - description: 'You have blocked this user. You cannot view their content.', 20 + if (cause.source.type === 'list') { 21 + return { 22 + name: `User Blocked by "${cause.source.list.name}"`, 23 + description: 24 + 'You have blocked this user. You cannot view their content.', 25 + } 26 + } else { 27 + return { 28 + name: 'User Blocked', 29 + description: 30 + 'You have blocked this user. You cannot view their content.', 31 + } 23 32 } 24 33 } 25 34 if (cause.type === 'blocked-by') {
+12
src/lib/routes/links.ts
··· 13 13 ...segments, 14 14 ].join('/') 15 15 } 16 + 17 + export function makeCustomFeedLink( 18 + did: string, 19 + rkey: string, 20 + ...segments: string[] 21 + ) { 22 + return [`/profile`, did, 'feed', rkey, ...segments].join('/') 23 + } 24 + 25 + export function makeListLink(did: string, rkey: string, ...segments: string[]) { 26 + return [`/profile`, did, 'lists', rkey, ...segments].join('/') 27 + }
+4 -3
src/lib/routes/types.ts
··· 5 5 6 6 export type CommonNavigatorParams = { 7 7 NotFound: undefined 8 + Lists: undefined 8 9 Moderation: undefined 9 - ModerationMuteLists: undefined 10 + ModerationModlists: undefined 10 11 ModerationMutedAccounts: undefined 11 12 ModerationBlockedAccounts: undefined 12 13 Settings: undefined ··· 18 19 PostThread: {name: string; rkey: string} 19 20 PostLikedBy: {name: string; rkey: string} 20 21 PostRepostedBy: {name: string; rkey: string} 21 - CustomFeed: {name: string; rkey: string} 22 - CustomFeedLikedBy: {name: string; rkey: string} 22 + ProfileFeed: {name: string; rkey: string} 23 + ProfileFeedLikedBy: {name: string; rkey: string} 23 24 Debug: undefined 24 25 Log: undefined 25 26 Support: undefined
+4 -3
src/routes.ts
··· 7 7 Notifications: '/notifications', 8 8 Settings: '/settings', 9 9 LanguageSettings: '/settings/language', 10 + Lists: '/lists', 10 11 Moderation: '/moderation', 11 - ModerationMuteLists: '/moderation/mute-lists', 12 + ModerationModlists: '/moderation/modlists', 12 13 ModerationMutedAccounts: '/moderation/muted-accounts', 13 14 ModerationBlockedAccounts: '/moderation/blocked-accounts', 14 15 Profile: '/profile/:name', ··· 18 19 PostThread: '/profile/:name/post/:rkey', 19 20 PostLikedBy: '/profile/:name/post/:rkey/liked-by', 20 21 PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', 21 - CustomFeed: '/profile/:name/feed/:rkey', 22 - CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', 22 + ProfileFeed: '/profile/:name/feed/:rkey', 23 + ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', 23 24 Debug: '/sys/debug', 24 25 Log: '/sys/log', 25 26 AppPasswords: '/settings/app-passwords',
+223
src/state/models/content/feed-source.ts
··· 1 + import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api' 2 + import {makeAutoObservable, runInAction} from 'mobx' 3 + import {RootStoreModel} from 'state/models/root-store' 4 + import {sanitizeDisplayName} from 'lib/strings/display-names' 5 + import {sanitizeHandle} from 'lib/strings/handles' 6 + import {bundleAsync} from 'lib/async/bundle' 7 + import {cleanError} from 'lib/strings/errors' 8 + import {track} from 'lib/analytics/analytics' 9 + 10 + export class FeedSourceModel { 11 + // state 12 + _reactKey: string 13 + hasLoaded = false 14 + error: string | undefined 15 + 16 + // data 17 + uri: string 18 + cid: string = '' 19 + type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported' 20 + avatar: string | undefined = '' 21 + displayName: string = '' 22 + descriptionRT: RichText | null = null 23 + creatorDid: string = '' 24 + creatorHandle: string = '' 25 + likeCount: number | undefined = 0 26 + likeUri: string | undefined = '' 27 + 28 + constructor(public rootStore: RootStoreModel, uri: string) { 29 + this._reactKey = uri 30 + this.uri = uri 31 + 32 + try { 33 + const urip = new AtUri(uri) 34 + if (urip.collection === 'app.bsky.feed.generator') { 35 + this.type = 'feed-generator' 36 + } else if (urip.collection === 'app.bsky.graph.list') { 37 + this.type = 'list' 38 + } 39 + } catch {} 40 + this.displayName = uri.split('/').pop() || '' 41 + 42 + makeAutoObservable( 43 + this, 44 + { 45 + rootStore: false, 46 + }, 47 + {autoBind: true}, 48 + ) 49 + } 50 + 51 + get href() { 52 + const urip = new AtUri(this.uri) 53 + const collection = 54 + urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' 55 + return `/profile/${urip.hostname}/${collection}/${urip.rkey}` 56 + } 57 + 58 + get isSaved() { 59 + return this.rootStore.preferences.savedFeeds.includes(this.uri) 60 + } 61 + 62 + get isPinned() { 63 + return this.rootStore.preferences.isPinnedFeed(this.uri) 64 + } 65 + 66 + get isLiked() { 67 + return !!this.likeUri 68 + } 69 + 70 + get isOwner() { 71 + return this.creatorDid === this.rootStore.me.did 72 + } 73 + 74 + setup = bundleAsync(async () => { 75 + try { 76 + if (this.type === 'feed-generator') { 77 + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ 78 + feed: this.uri, 79 + }) 80 + this.hydrateFeedGenerator(res.data.view) 81 + } else if (this.type === 'list') { 82 + const res = await this.rootStore.agent.app.bsky.graph.getList({ 83 + list: this.uri, 84 + limit: 1, 85 + }) 86 + this.hydrateList(res.data.list) 87 + } 88 + } catch (e) { 89 + runInAction(() => { 90 + this.error = cleanError(e) 91 + }) 92 + } 93 + }) 94 + 95 + hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) { 96 + this.uri = view.uri 97 + this.cid = view.cid 98 + this.avatar = view.avatar 99 + this.displayName = view.displayName 100 + ? sanitizeDisplayName(view.displayName) 101 + : `Feed by ${sanitizeHandle(view.creator.handle, '@')}` 102 + this.descriptionRT = new RichText({ 103 + text: view.description || '', 104 + facets: (view.descriptionFacets || [])?.slice(), 105 + }) 106 + this.creatorDid = view.creator.did 107 + this.creatorHandle = view.creator.handle 108 + this.likeCount = view.likeCount 109 + this.likeUri = view.viewer?.like 110 + this.hasLoaded = true 111 + } 112 + 113 + hydrateList(view: AppBskyGraphDefs.ListView) { 114 + this.uri = view.uri 115 + this.cid = view.cid 116 + this.avatar = view.avatar 117 + this.displayName = view.name 118 + ? sanitizeDisplayName(view.name) 119 + : `User List by ${sanitizeHandle(view.creator.handle, '@')}` 120 + this.descriptionRT = new RichText({ 121 + text: view.description || '', 122 + facets: (view.descriptionFacets || [])?.slice(), 123 + }) 124 + this.creatorDid = view.creator.did 125 + this.creatorHandle = view.creator.handle 126 + this.likeCount = undefined 127 + this.hasLoaded = true 128 + } 129 + 130 + async save() { 131 + if (this.type !== 'feed-generator') { 132 + return 133 + } 134 + try { 135 + await this.rootStore.preferences.addSavedFeed(this.uri) 136 + } catch (error) { 137 + this.rootStore.log.error('Failed to save feed', error) 138 + } finally { 139 + track('CustomFeed:Save') 140 + } 141 + } 142 + 143 + async unsave() { 144 + if (this.type !== 'feed-generator') { 145 + return 146 + } 147 + try { 148 + await this.rootStore.preferences.removeSavedFeed(this.uri) 149 + } catch (error) { 150 + this.rootStore.log.error('Failed to unsave feed', error) 151 + } finally { 152 + track('CustomFeed:Unsave') 153 + } 154 + } 155 + 156 + async pin() { 157 + try { 158 + await this.rootStore.preferences.addPinnedFeed(this.uri) 159 + } catch (error) { 160 + this.rootStore.log.error('Failed to pin feed', error) 161 + } finally { 162 + track('CustomFeed:Pin', { 163 + name: this.displayName, 164 + uri: this.uri, 165 + }) 166 + } 167 + } 168 + 169 + async togglePin() { 170 + if (!this.isPinned) { 171 + track('CustomFeed:Pin', { 172 + name: this.displayName, 173 + uri: this.uri, 174 + }) 175 + return this.rootStore.preferences.addPinnedFeed(this.uri) 176 + } else { 177 + track('CustomFeed:Unpin', { 178 + name: this.displayName, 179 + uri: this.uri, 180 + }) 181 + return this.rootStore.preferences.removePinnedFeed(this.uri) 182 + } 183 + } 184 + 185 + async like() { 186 + if (this.type !== 'feed-generator') { 187 + return 188 + } 189 + try { 190 + this.likeUri = 'pending' 191 + this.likeCount = (this.likeCount || 0) + 1 192 + const res = await this.rootStore.agent.like(this.uri, this.cid) 193 + this.likeUri = res.uri 194 + } catch (e: any) { 195 + this.likeUri = undefined 196 + this.likeCount = (this.likeCount || 1) - 1 197 + this.rootStore.log.error('Failed to like feed', e) 198 + } finally { 199 + track('CustomFeed:Like') 200 + } 201 + } 202 + 203 + async unlike() { 204 + if (this.type !== 'feed-generator') { 205 + return 206 + } 207 + if (!this.likeUri) { 208 + return 209 + } 210 + const uri = this.likeUri 211 + try { 212 + this.likeUri = undefined 213 + this.likeCount = (this.likeCount || 1) - 1 214 + await this.rootStore.agent.deleteLike(uri!) 215 + } catch (e: any) { 216 + this.likeUri = uri 217 + this.likeCount = (this.likeCount || 0) + 1 218 + this.rootStore.log.error('Failed to unlike feed', e) 219 + } finally { 220 + track('CustomFeed:Unlike') 221 + } 222 + } 223 + }
+8 -1
src/state/models/content/list-membership.ts
··· 110 110 }) 111 111 } 112 112 113 - async updateTo(uris: string[]) { 113 + async updateTo( 114 + uris: string[], 115 + ): Promise<{added: string[]; removed: string[]}> { 116 + const added = [] 117 + const removed = [] 114 118 for (const uri of uris) { 115 119 await this.add(uri) 120 + added.push(uri) 116 121 } 117 122 for (const membership of this.memberships) { 118 123 if (!uris.includes(membership.value.list)) { 119 124 await this.remove(membership.value.list) 125 + removed.push(membership.value.list) 120 126 } 121 127 } 128 + return {added, removed} 122 129 } 123 130 }
+206 -24
src/state/models/content/list.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import { 3 3 AtUri, 4 + AppBskyActorDefs, 4 5 AppBskyGraphGetList as GetList, 5 6 AppBskyGraphDefs as GraphDefs, 6 7 AppBskyGraphList, 7 8 AppBskyGraphListitem, 9 + RichText, 8 10 } from '@atproto/api' 9 11 import {Image as RNImage} from 'react-native-image-crop-picker' 10 12 import chunk from 'lodash.chunk' ··· 13 15 import {cleanError} from 'lib/strings/errors' 14 16 import {bundleAsync} from 'lib/async/bundle' 15 17 import {track} from 'lib/analytics/analytics' 18 + import {until} from 'lib/async/until' 16 19 17 20 const PAGE_SIZE = 30 18 21 ··· 37 40 loadMoreCursor?: string 38 41 39 42 // data 40 - list: GraphDefs.ListView | null = null 43 + data: GraphDefs.ListView | null = null 41 44 items: GraphDefs.ListItemView[] = [] 45 + descriptionRT: RichText | null = null 42 46 43 - static async createModList( 47 + static async createList( 44 48 rootStore: RootStoreModel, 45 49 { 50 + purpose, 46 51 name, 47 52 description, 48 53 avatar, 49 - }: {name: string; description: string; avatar: RNImage | null | undefined}, 54 + }: { 55 + purpose: string 56 + name: string 57 + description: string 58 + avatar: RNImage | null | undefined 59 + }, 50 60 ) { 61 + if ( 62 + purpose !== 'app.bsky.graph.defs#curatelist' && 63 + purpose !== 'app.bsky.graph.defs#modlist' 64 + ) { 65 + throw new Error('Invalid list purpose: must be curatelist or modlist') 66 + } 51 67 const record: AppBskyGraphList.Record = { 52 - purpose: 'app.bsky.graph.defs#modlist', 68 + purpose, 53 69 name, 54 70 description, 55 71 avatar: undefined, ··· 69 85 }, 70 86 record, 71 87 ) 72 - await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) 88 + 89 + // wait for the appview to update 90 + await until( 91 + 5, // 5 tries 92 + 1e3, // 1s delay between tries 93 + (v: GetList.Response, _e: any) => { 94 + return typeof v?.data?.list.uri === 'string' 95 + }, 96 + () => 97 + rootStore.agent.app.bsky.graph.getList({ 98 + list: res.uri, 99 + limit: 1, 100 + }), 101 + ) 73 102 return res 74 103 } 75 104 ··· 95 124 return this.hasLoaded && !this.hasContent 96 125 } 97 126 127 + get isCuratelist() { 128 + return this.data?.purpose === 'app.bsky.graph.defs#curatelist' 129 + } 130 + 131 + get isModlist() { 132 + return this.data?.purpose === 'app.bsky.graph.defs#modlist' 133 + } 134 + 98 135 get isOwner() { 99 - return this.list?.creator.did === this.rootStore.me.did 136 + return this.data?.creator.did === this.rootStore.me.did 100 137 } 101 138 102 - get isSubscribed() { 103 - return this.list?.viewer?.muted 139 + get isBlocking() { 140 + return !!this.data?.viewer?.blocked 141 + } 142 + 143 + get isMuting() { 144 + return !!this.data?.viewer?.muted 145 + } 146 + 147 + get isPinned() { 148 + return this.rootStore.preferences.isPinnedFeed(this.uri) 104 149 } 105 150 106 151 get creatorDid() { 107 - return this.list?.creator.did 152 + return this.data?.creator.did 153 + } 154 + 155 + getMembership(did: string) { 156 + return this.items.find(item => item.subject.did === did) 157 + } 158 + 159 + isMember(did: string) { 160 + return !!this.getMembership(did) 108 161 } 109 162 110 163 // public api ··· 137 190 } 138 191 }) 139 192 193 + async loadAll() { 194 + for (let i = 0; i < 1000; i++) { 195 + if (!this.hasMore) { 196 + break 197 + } 198 + await this.loadMore() 199 + } 200 + } 201 + 140 202 async updateMetadata({ 141 203 name, 142 204 description, ··· 146 208 description: string 147 209 avatar: RNImage | null | undefined 148 210 }) { 149 - if (!this.list) { 211 + if (!this.data) { 150 212 return 151 213 } 152 214 if (!this.isOwner) { ··· 183 245 } 184 246 185 247 async delete() { 186 - if (!this.list) { 248 + if (!this.data) { 187 249 return 188 250 } 189 251 await this._resolveUri() ··· 231 293 this.rootStore.emitListDeleted(this.uri) 232 294 } 233 295 234 - async subscribe() { 235 - if (!this.list) { 296 + async addMember(profile: AppBskyActorDefs.ProfileViewBasic) { 297 + if (this.isMember(profile.did)) { 298 + return 299 + } 300 + await this.rootStore.agent.app.bsky.graph.listitem.create( 301 + { 302 + repo: this.rootStore.me.did, 303 + }, 304 + { 305 + subject: profile.did, 306 + list: this.uri, 307 + createdAt: new Date().toISOString(), 308 + }, 309 + ) 310 + runInAction(() => { 311 + this.items = this.items.concat([ 312 + {_reactKey: profile.did, subject: profile}, 313 + ]) 314 + }) 315 + } 316 + 317 + /** 318 + * Just adds to local cache; used to reflect changes affected elsewhere 319 + */ 320 + cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) { 321 + if (!this.isMember(profile.did)) { 322 + this.items = this.items.concat([ 323 + {_reactKey: profile.did, subject: profile}, 324 + ]) 325 + } 326 + } 327 + 328 + /** 329 + * Just removes from local cache; used to reflect changes affected elsewhere 330 + */ 331 + cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) { 332 + if (this.isMember(profile.did)) { 333 + this.items = this.items.filter(item => item.subject.did !== profile.did) 334 + } 335 + } 336 + 337 + async pin() { 338 + try { 339 + await this.rootStore.preferences.addPinnedFeed(this.uri) 340 + } catch (error) { 341 + this.rootStore.log.error('Failed to pin feed', error) 342 + } finally { 343 + track('CustomFeed:Pin', { 344 + name: this.data?.name || '', 345 + uri: this.uri, 346 + }) 347 + } 348 + } 349 + 350 + async togglePin() { 351 + if (!this.isPinned) { 352 + track('CustomFeed:Pin', { 353 + name: this.data?.name || '', 354 + uri: this.uri, 355 + }) 356 + return this.rootStore.preferences.addPinnedFeed(this.uri) 357 + } else { 358 + track('CustomFeed:Unpin', { 359 + name: this.data?.name || '', 360 + uri: this.uri, 361 + }) 362 + // TEMPORARY 363 + // lists are temporarily piggybacking on the saved/pinned feeds preferences 364 + // we'll eventually replace saved feeds with the bookmarks API 365 + // until then, we need to unsave lists instead of just unpin them 366 + // -prf 367 + // return this.rootStore.preferences.removePinnedFeed(this.uri) 368 + return this.rootStore.preferences.removeSavedFeed(this.uri) 369 + } 370 + } 371 + 372 + async mute() { 373 + if (!this.data) { 236 374 return 237 375 } 238 376 await this._resolveUri() 239 - await this.rootStore.agent.app.bsky.graph.muteActorList({ 240 - list: this.list.uri, 377 + await this.rootStore.agent.muteModList(this.data.uri) 378 + track('Lists:Mute') 379 + runInAction(() => { 380 + if (this.data) { 381 + const d = this.data 382 + this.data = {...d, viewer: {...(d.viewer || {}), muted: true}} 383 + } 384 + }) 385 + } 386 + 387 + async unmute() { 388 + if (!this.data) { 389 + return 390 + } 391 + await this._resolveUri() 392 + await this.rootStore.agent.unmuteModList(this.data.uri) 393 + track('Lists:Unmute') 394 + runInAction(() => { 395 + if (this.data) { 396 + const d = this.data 397 + this.data = {...d, viewer: {...(d.viewer || {}), muted: false}} 398 + } 399 + }) 400 + } 401 + 402 + async block() { 403 + if (!this.data) { 404 + return 405 + } 406 + await this._resolveUri() 407 + const res = await this.rootStore.agent.blockModList(this.data.uri) 408 + track('Lists:Block') 409 + runInAction(() => { 410 + if (this.data) { 411 + const d = this.data 412 + this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}} 413 + } 241 414 }) 242 - track('Lists:Subscribe') 243 - await this.refresh() 244 415 } 245 416 246 - async unsubscribe() { 247 - if (!this.list) { 417 + async unblock() { 418 + if (!this.data || !this.data.viewer?.blocked) { 248 419 return 249 420 } 250 421 await this._resolveUri() 251 - await this.rootStore.agent.app.bsky.graph.unmuteActorList({ 252 - list: this.list.uri, 422 + await this.rootStore.agent.unblockModList(this.data.uri) 423 + track('Lists:Unblock') 424 + runInAction(() => { 425 + if (this.data) { 426 + const d = this.data 427 + this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}} 428 + } 253 429 }) 254 - track('Lists:Unsubscribe') 255 - await this.refresh() 256 430 } 257 431 258 432 /** ··· 314 488 _appendAll(res: GetList.Response) { 315 489 this.loadMoreCursor = res.data.cursor 316 490 this.hasMore = !!this.loadMoreCursor 317 - this.list = res.data.list 491 + this.data = res.data.list 318 492 this.items = this.items.concat( 319 493 res.data.items.map(item => ({...item, _reactKey: item.subject.did})), 320 494 ) 495 + if (this.data.description) { 496 + this.descriptionRT = new RichText({ 497 + text: this.data.description, 498 + facets: (this.data.descriptionFacets || [])?.slice(), 499 + }) 500 + } else { 501 + this.descriptionRT = null 502 + } 321 503 } 322 504 }
+2 -1
src/state/models/content/profile.ts
··· 22 22 following?: string 23 23 followedBy?: string 24 24 blockedBy?: boolean 25 - blocking?: string; 25 + blocking?: string 26 + blockingByList?: AppBskyGraphDefs.ListViewBasic; 26 27 [key: string]: unknown 27 28 28 29 constructor() {
+5 -3
src/state/models/discovery/feeds.ts
··· 3 3 import {RootStoreModel} from '../root-store' 4 4 import {bundleAsync} from 'lib/async/bundle' 5 5 import {cleanError} from 'lib/strings/errors' 6 - import {CustomFeedModel} from '../feeds/custom-feed' 6 + import {FeedSourceModel} from '../content/feed-source' 7 7 8 8 const DEFAULT_LIMIT = 50 9 9 ··· 16 16 loadMoreCursor: string | undefined = undefined 17 17 18 18 // data 19 - feeds: CustomFeedModel[] = [] 19 + feeds: FeedSourceModel[] = [] 20 20 21 21 constructor(public rootStore: RootStoreModel) { 22 22 makeAutoObservable( ··· 137 137 _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { 138 138 // 1. push data into feeds array 139 139 for (const f of res.data.feeds) { 140 - this.feeds.push(new CustomFeedModel(this.rootStore, f)) 140 + const model = new FeedSourceModel(this.rootStore, f.uri) 141 + model.hydrateFeedGenerator(f) 142 + this.feeds.push(model) 141 143 } 142 144 // 2. set loadMoreCursor 143 145 this.loadMoreCursor = res.data.cursor
-151
src/state/models/feeds/custom-feed.ts
··· 1 - import {AppBskyFeedDefs} from '@atproto/api' 2 - import {makeAutoObservable, runInAction} from 'mobx' 3 - import {RootStoreModel} from 'state/models/root-store' 4 - import {sanitizeDisplayName} from 'lib/strings/display-names' 5 - import {sanitizeHandle} from 'lib/strings/handles' 6 - import {updateDataOptimistically} from 'lib/async/revertible' 7 - import {track} from 'lib/analytics/analytics' 8 - 9 - export class CustomFeedModel { 10 - // data 11 - _reactKey: string 12 - data: AppBskyFeedDefs.GeneratorView 13 - isOnline: boolean 14 - isValid: boolean 15 - 16 - constructor( 17 - public rootStore: RootStoreModel, 18 - view: AppBskyFeedDefs.GeneratorView, 19 - isOnline?: boolean, 20 - isValid?: boolean, 21 - ) { 22 - this._reactKey = view.uri 23 - this.data = view 24 - this.isOnline = isOnline ?? true 25 - this.isValid = isValid ?? true 26 - makeAutoObservable( 27 - this, 28 - { 29 - rootStore: false, 30 - }, 31 - {autoBind: true}, 32 - ) 33 - } 34 - 35 - // local actions 36 - // = 37 - 38 - get uri() { 39 - return this.data.uri 40 - } 41 - 42 - get displayName() { 43 - if (this.data.displayName) { 44 - return sanitizeDisplayName(this.data.displayName) 45 - } 46 - return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}` 47 - } 48 - 49 - get isSaved() { 50 - return this.rootStore.preferences.savedFeeds.includes(this.uri) 51 - } 52 - 53 - get isLiked() { 54 - return this.data.viewer?.like 55 - } 56 - 57 - // public apis 58 - // = 59 - 60 - async save() { 61 - try { 62 - await this.rootStore.preferences.addSavedFeed(this.uri) 63 - } catch (error) { 64 - this.rootStore.log.error('Failed to save feed', error) 65 - } finally { 66 - track('CustomFeed:Save') 67 - } 68 - } 69 - 70 - async pin() { 71 - try { 72 - await this.rootStore.preferences.addPinnedFeed(this.uri) 73 - } catch (error) { 74 - this.rootStore.log.error('Failed to pin feed', error) 75 - } finally { 76 - track('CustomFeed:Pin', { 77 - name: this.data.displayName, 78 - uri: this.uri, 79 - }) 80 - } 81 - } 82 - 83 - async unsave() { 84 - try { 85 - await this.rootStore.preferences.removeSavedFeed(this.uri) 86 - } catch (error) { 87 - this.rootStore.log.error('Failed to unsave feed', error) 88 - } finally { 89 - track('CustomFeed:Unsave') 90 - } 91 - } 92 - 93 - async like() { 94 - try { 95 - await updateDataOptimistically( 96 - this.data, 97 - () => { 98 - this.data.viewer = this.data.viewer || {} 99 - this.data.viewer.like = 'pending' 100 - this.data.likeCount = (this.data.likeCount || 0) + 1 101 - }, 102 - () => this.rootStore.agent.like(this.data.uri, this.data.cid), 103 - res => { 104 - this.data.viewer = this.data.viewer || {} 105 - this.data.viewer.like = res.uri 106 - }, 107 - ) 108 - } catch (e: any) { 109 - this.rootStore.log.error('Failed to like feed', e) 110 - } finally { 111 - track('CustomFeed:Like') 112 - } 113 - } 114 - 115 - async unlike() { 116 - if (!this.data.viewer?.like) { 117 - return 118 - } 119 - try { 120 - const likeUri = this.data.viewer.like 121 - await updateDataOptimistically( 122 - this.data, 123 - () => { 124 - this.data.viewer = this.data.viewer || {} 125 - this.data.viewer.like = undefined 126 - this.data.likeCount = (this.data.likeCount || 1) - 1 127 - }, 128 - () => this.rootStore.agent.deleteLike(likeUri), 129 - ) 130 - } catch (e: any) { 131 - this.rootStore.log.error('Failed to unlike feed', e) 132 - } finally { 133 - track('CustomFeed:Unlike') 134 - } 135 - } 136 - 137 - async reload() { 138 - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ 139 - feed: this.data.uri, 140 - }) 141 - runInAction(() => { 142 - this.data = res.data.view 143 - this.isOnline = res.data.isOnline 144 - this.isValid = res.data.isValid 145 - }) 146 - } 147 - 148 - serialize() { 149 - return JSON.stringify(this.data) 150 - } 151 - }
+26 -2
src/state/models/feeds/posts.ts
··· 4 4 AppBskyFeedGetAuthorFeed as GetAuthorFeed, 5 5 AppBskyFeedGetFeed as GetCustomFeed, 6 6 AppBskyFeedGetActorLikes as GetActorLikes, 7 + AppBskyFeedGetListFeed as GetListFeed, 7 8 } from '@atproto/api' 8 9 import AwaitLock from 'await-lock' 9 10 import {bundleAsync} from 'lib/async/bundle' ··· 19 20 import {AuthorFeedAPI} from 'lib/api/feed/author' 20 21 import {LikesFeedAPI} from 'lib/api/feed/likes' 21 22 import {CustomFeedAPI} from 'lib/api/feed/custom' 23 + import {ListFeedAPI} from 'lib/api/feed/list' 22 24 import {MergeFeedAPI} from 'lib/api/feed/merge' 23 25 24 26 const PAGE_SIZE = 30 ··· 36 38 | GetAuthorFeed.QueryParams 37 39 | GetActorLikes.QueryParams 38 40 | GetCustomFeed.QueryParams 41 + | GetListFeed.QueryParams 39 42 40 43 export class PostsFeedModel { 41 44 // state ··· 66 69 67 70 constructor( 68 71 public rootStore: RootStoreModel, 69 - public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', 72 + public feedType: 73 + | 'home' 74 + | 'following' 75 + | 'author' 76 + | 'custom' 77 + | 'likes' 78 + | 'list', 70 79 params: QueryParams, 71 80 options?: Options, 72 81 ) { ··· 99 108 rootStore, 100 109 params as GetCustomFeed.QueryParams, 101 110 ) 111 + } else if (feedType === 'list') { 112 + this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams) 102 113 } else { 103 114 this.api = new FollowingFeedAPI(rootStore) 104 115 } 105 116 } 106 117 118 + get reactKey() { 119 + if (this.feedType === 'author') { 120 + return (this.params as GetAuthorFeed.QueryParams).actor 121 + } 122 + if (this.feedType === 'custom') { 123 + return (this.params as GetCustomFeed.QueryParams).feed 124 + } 125 + if (this.feedType === 'list') { 126 + return (this.params as GetListFeed.QueryParams).list 127 + } 128 + return this.feedType 129 + } 130 + 107 131 get hasContent() { 108 132 return this.slices.length !== 0 109 133 } ··· 117 141 } 118 142 119 143 get isLoadingMore() { 120 - return this.isLoading && !this.isRefreshing 144 + return this.isLoading && !this.isRefreshing && this.hasContent 121 145 } 122 146 123 147 setHasNewLatest(v: boolean) {
+5 -3
src/state/models/lists/actor-feeds.ts
··· 3 3 import {RootStoreModel} from '../root-store' 4 4 import {bundleAsync} from 'lib/async/bundle' 5 5 import {cleanError} from 'lib/strings/errors' 6 - import {CustomFeedModel} from '../feeds/custom-feed' 6 + import {FeedSourceModel} from '../content/feed-source' 7 7 8 8 const PAGE_SIZE = 30 9 9 ··· 17 17 loadMoreCursor?: string 18 18 19 19 // data 20 - feeds: CustomFeedModel[] = [] 20 + feeds: FeedSourceModel[] = [] 21 21 22 22 constructor( 23 23 public rootStore: RootStoreModel, ··· 114 114 this.loadMoreCursor = res.data.cursor 115 115 this.hasMore = !!this.loadMoreCursor 116 116 for (const f of res.data.feeds) { 117 - this.feeds.push(new CustomFeedModel(this.rootStore, f)) 117 + const model = new FeedSourceModel(this.rootStore, f.uri) 118 + model.hydrateFeedGenerator(f) 119 + this.feeds.push(model) 118 120 } 119 121 } 120 122 }
+108 -97
src/state/models/lists/lists-list.ts
··· 1 1 import {makeAutoObservable} from 'mobx' 2 - import { 3 - AppBskyGraphGetLists as GetLists, 4 - AppBskyGraphGetListMutes as GetListMutes, 5 - AppBskyGraphDefs as GraphDefs, 6 - } from '@atproto/api' 2 + import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' 7 3 import {RootStoreModel} from '../root-store' 8 4 import {cleanError} from 'lib/strings/errors' 9 5 import {bundleAsync} from 'lib/async/bundle' 6 + import {accumulate} from 'lib/async/accumulate' 10 7 11 8 const PAGE_SIZE = 30 12 9 ··· 25 22 26 23 constructor( 27 24 public rootStore: RootStoreModel, 28 - public source: 'my-modlists' | string, 25 + public source: 'mine' | 'my-curatelists' | 'my-modlists' | string, 29 26 ) { 30 27 makeAutoObservable( 31 28 this, ··· 48 45 return this.hasLoaded && !this.hasContent 49 46 } 50 47 48 + get curatelists() { 49 + return this.lists.filter( 50 + list => list.purpose === 'app.bsky.graph.defs#curatelist', 51 + ) 52 + } 53 + 54 + get isCuratelistsEmpty() { 55 + return this.hasLoaded && this.curatelists.length === 0 56 + } 57 + 58 + get modlists() { 59 + return this.lists.filter( 60 + list => list.purpose === 'app.bsky.graph.defs#modlist', 61 + ) 62 + } 63 + 64 + get isModlistsEmpty() { 65 + return this.hasLoaded && this.modlists.length === 0 66 + } 67 + 51 68 /** 52 69 * Removes posts from the feed upon deletion. 53 70 */ ··· 76 93 } 77 94 this._xLoading(replace) 78 95 try { 79 - let res: GetLists.Response 80 - if (this.source === 'my-modlists') { 81 - res = { 82 - success: true, 83 - headers: {}, 84 - data: { 85 - subject: undefined, 86 - lists: [], 87 - }, 96 + let cursor: string | undefined 97 + let lists: GraphDefs.ListView[] = [] 98 + if ( 99 + this.source === 'mine' || 100 + this.source === 'my-curatelists' || 101 + this.source === 'my-modlists' 102 + ) { 103 + const promises = [ 104 + accumulate(cursor => 105 + this.rootStore.agent.app.bsky.graph 106 + .getLists({ 107 + actor: this.rootStore.me.did, 108 + cursor, 109 + limit: 50, 110 + }) 111 + .then(res => ({cursor: res.data.cursor, items: res.data.lists})), 112 + ), 113 + ] 114 + if (this.source === 'my-modlists') { 115 + promises.push( 116 + accumulate(cursor => 117 + this.rootStore.agent.app.bsky.graph 118 + .getListMutes({ 119 + cursor, 120 + limit: 50, 121 + }) 122 + .then(res => ({ 123 + cursor: res.data.cursor, 124 + items: res.data.lists, 125 + })), 126 + ), 127 + ) 128 + promises.push( 129 + accumulate(cursor => 130 + this.rootStore.agent.app.bsky.graph 131 + .getListBlocks({ 132 + cursor, 133 + limit: 50, 134 + }) 135 + .then(res => ({ 136 + cursor: res.data.cursor, 137 + items: res.data.lists, 138 + })), 139 + ), 140 + ) 88 141 } 89 - const [res1, res2] = await Promise.all([ 90 - fetchAllUserLists(this.rootStore, this.rootStore.me.did), 91 - fetchAllMyMuteLists(this.rootStore), 92 - ]) 93 - for (let list of res1.data.lists) { 94 - if (list.purpose === 'app.bsky.graph.defs#modlist') { 95 - res.data.lists.push(list) 96 - } 97 - } 98 - for (let list of res2.data.lists) { 99 - if ( 100 - list.purpose === 'app.bsky.graph.defs#modlist' && 101 - !res.data.lists.find(l => l.uri === list.uri) 102 - ) { 103 - res.data.lists.push(list) 142 + const resultset = await Promise.all(promises) 143 + for (const res of resultset) { 144 + for (let list of res) { 145 + if ( 146 + this.source === 'my-curatelists' && 147 + list.purpose !== 'app.bsky.graph.defs#curatelist' 148 + ) { 149 + continue 150 + } 151 + if ( 152 + this.source === 'my-modlists' && 153 + list.purpose !== 'app.bsky.graph.defs#modlist' 154 + ) { 155 + continue 156 + } 157 + if (!lists.find(l => l.uri === list.uri)) { 158 + lists.push(list) 159 + } 104 160 } 105 161 } 106 162 } else { 107 - res = await this.rootStore.agent.app.bsky.graph.getLists({ 163 + const res = await this.rootStore.agent.app.bsky.graph.getLists({ 108 164 actor: this.source, 109 165 limit: PAGE_SIZE, 110 166 cursor: replace ? undefined : this.loadMoreCursor, 111 167 }) 168 + lists = res.data.lists 169 + cursor = res.data.cursor 112 170 } 113 171 if (replace) { 114 - this._replaceAll(res) 172 + this._replaceAll({lists, cursor}) 115 173 } else { 116 - this._appendAll(res) 174 + this._appendAll({lists, cursor}) 117 175 } 118 176 this._xIdle() 119 177 } catch (e: any) { ··· 156 214 // helper functions 157 215 // = 158 216 159 - _replaceAll(res: GetLists.Response | GetListMutes.Response) { 217 + _replaceAll({ 218 + lists, 219 + cursor, 220 + }: { 221 + lists: GraphDefs.ListView[] 222 + cursor: string | undefined 223 + }) { 160 224 this.lists = [] 161 - this._appendAll(res) 225 + this._appendAll({lists, cursor}) 162 226 } 163 227 164 - _appendAll(res: GetLists.Response | GetListMutes.Response) { 165 - this.loadMoreCursor = res.data.cursor 228 + _appendAll({ 229 + lists, 230 + cursor, 231 + }: { 232 + lists: GraphDefs.ListView[] 233 + cursor: string | undefined 234 + }) { 235 + this.loadMoreCursor = cursor 166 236 this.hasMore = !!this.loadMoreCursor 167 237 this.lists = this.lists.concat( 168 - res.data.lists.map(list => ({...list, _reactKey: list.uri})), 238 + lists.map(list => ({...list, _reactKey: list.uri})), 169 239 ) 170 240 } 171 241 } 172 - 173 - async function fetchAllUserLists( 174 - store: RootStoreModel, 175 - did: string, 176 - ): Promise<GetLists.Response> { 177 - let acc: GetLists.Response = { 178 - success: true, 179 - headers: {}, 180 - data: { 181 - subject: undefined, 182 - lists: [], 183 - }, 184 - } 185 - 186 - let cursor 187 - for (let i = 0; i < 100; i++) { 188 - const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({ 189 - actor: did, 190 - cursor, 191 - limit: 50, 192 - }) 193 - cursor = res.data.cursor 194 - acc.data.lists = acc.data.lists.concat(res.data.lists) 195 - if (!cursor) { 196 - break 197 - } 198 - } 199 - 200 - return acc 201 - } 202 - 203 - async function fetchAllMyMuteLists( 204 - store: RootStoreModel, 205 - ): Promise<GetListMutes.Response> { 206 - let acc: GetListMutes.Response = { 207 - success: true, 208 - headers: {}, 209 - data: { 210 - subject: undefined, 211 - lists: [], 212 - }, 213 - } 214 - 215 - let cursor 216 - for (let i = 0; i < 100; i++) { 217 - const res: GetListMutes.Response = 218 - await store.agent.app.bsky.graph.getListMutes({ 219 - cursor, 220 - limit: 50, 221 - }) 222 - cursor = res.data.cursor 223 - acc.data.lists = acc.data.lists.concat(res.data.lists) 224 - if (!cursor) { 225 - break 226 - } 227 - } 228 - 229 - return acc 230 - }
-3
src/state/models/me.ts
··· 8 8 import {NotificationsFeedModel} from './feeds/notifications' 9 9 import {MyFollowsCache} from './cache/my-follows' 10 10 import {isObj, hasProp} from 'lib/type-guards' 11 - import {SavedFeedsModel} from './ui/saved-feeds' 12 11 13 12 const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min 14 13 const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec ··· 22 21 followsCount: number | undefined 23 22 followersCount: number | undefined 24 23 mainFeed: PostsFeedModel 25 - savedFeeds: SavedFeedsModel 26 24 notifications: NotificationsFeedModel 27 25 follows: MyFollowsCache 28 26 invites: ComAtprotoServerDefs.InviteCode[] = [] ··· 45 43 }) 46 44 this.notifications = new NotificationsFeedModel(this.rootStore) 47 45 this.follows = new MyFollowsCache(this.rootStore) 48 - this.savedFeeds = new SavedFeedsModel(this.rootStore) 49 46 } 50 47 51 48 clear() {
+22 -8
src/state/models/ui/my-feeds.ts
··· 1 - import {makeAutoObservable} from 'mobx' 1 + import {makeAutoObservable, reaction} from 'mobx' 2 + import {SavedFeedsModel} from './saved-feeds' 2 3 import {FeedsDiscoveryModel} from '../discovery/feeds' 3 - import {CustomFeedModel} from '../feeds/custom-feed' 4 + import {FeedSourceModel} from '../content/feed-source' 4 5 import {RootStoreModel} from '../root-store' 5 6 6 7 export type MyFeedsItem = ··· 29 30 | { 30 31 _reactKey: string 31 32 type: 'saved-feed' 32 - feed: CustomFeedModel 33 + feed: FeedSourceModel 33 34 } 34 35 | { 35 36 _reactKey: string ··· 46 47 | { 47 48 _reactKey: string 48 49 type: 'discover-feed' 49 - feed: CustomFeedModel 50 + feed: FeedSourceModel 50 51 } 51 52 52 53 export class MyFeedsUIModel { 54 + saved: SavedFeedsModel 53 55 discovery: FeedsDiscoveryModel 54 56 55 57 constructor(public rootStore: RootStoreModel) { 56 58 makeAutoObservable(this) 59 + this.saved = new SavedFeedsModel(this.rootStore) 57 60 this.discovery = new FeedsDiscoveryModel(this.rootStore) 58 - } 59 - 60 - get saved() { 61 - return this.rootStore.me.savedFeeds 62 61 } 63 62 64 63 get isRefreshing() { ··· 75 74 } 76 75 if (!this.discovery.hasLoaded) { 77 76 await this.discovery.refresh() 77 + } 78 + } 79 + 80 + registerListeners() { 81 + const dispose1 = reaction( 82 + () => this.rootStore.preferences.savedFeeds, 83 + () => this.saved.refresh(), 84 + ) 85 + const dispose2 = reaction( 86 + () => this.rootStore.preferences.pinnedFeeds, 87 + () => this.saved.refresh(), 88 + ) 89 + return () => { 90 + dispose1() 91 + dispose2() 78 92 } 79 93 } 80 94
+21 -4
src/state/models/ui/preferences.ts
··· 194 194 /** 195 195 * This function fetches preferences and sets defaults for missing items. 196 196 */ 197 - async sync({clearCache}: {clearCache?: boolean} = {}) { 197 + async sync() { 198 198 await this.lock.acquireAsync() 199 199 try { 200 200 // fetch preferences ··· 252 252 } finally { 253 253 this.lock.release() 254 254 } 255 - 256 - await this.rootStore.me.savedFeeds.updateCache(clearCache) 257 255 } 258 256 259 257 async syncLegacyPreferences() { ··· 285 283 this.lock.release() 286 284 } 287 285 } 286 + 287 + // languages 288 + // = 288 289 289 290 hasContentLanguage(code2: string) { 290 291 return this.contentLanguages.includes(code2) ··· 358 359 return all.join(', ') 359 360 } 360 361 362 + // moderation 363 + // = 364 + 361 365 async setContentLabelPref( 362 366 key: keyof LabelPreferencesModel, 363 367 value: LabelPreference, ··· 409 413 } 410 414 } 411 415 416 + // feeds 417 + // = 418 + 419 + isPinnedFeed(uri: string) { 420 + return this.pinnedFeeds.includes(uri) 421 + } 422 + 412 423 async _optimisticUpdateSavedFeeds( 413 424 saved: string[], 414 425 pinned: string[], ··· 473 484 () => this.rootStore.agent.removePinnedFeed(v), 474 485 ) 475 486 } 487 + 488 + // other 489 + // = 476 490 477 491 async setBirthDate(birthDate: Date) { 478 492 this.birthDate = birthDate ··· 602 616 } 603 617 604 618 getFeedTuners( 605 - feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', 619 + feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', 606 620 ) { 607 621 if (feedType === 'custom') { 608 622 return [ 609 623 FeedTuner.dedupReposts, 610 624 FeedTuner.preferredLangOnly(this.contentLanguages), 611 625 ] 626 + } 627 + if (feedType === 'list') { 628 + return [FeedTuner.dedupReposts] 612 629 } 613 630 if (feedType === 'home' || feedType === 'following') { 614 631 const feedTuners = []
+39 -113
src/state/models/ui/saved-feeds.ts
··· 2 2 import {RootStoreModel} from '../root-store' 3 3 import {bundleAsync} from 'lib/async/bundle' 4 4 import {cleanError} from 'lib/strings/errors' 5 - import {CustomFeedModel} from '../feeds/custom-feed' 5 + import {FeedSourceModel} from '../content/feed-source' 6 6 import {track} from 'lib/analytics/analytics' 7 7 8 8 export class SavedFeedsModel { ··· 13 13 error = '' 14 14 15 15 // data 16 - _feedModelCache: Record<string, CustomFeedModel> = {} 16 + all: FeedSourceModel[] = [] 17 17 18 18 constructor(public rootStore: RootStoreModel) { 19 19 makeAutoObservable( ··· 38 38 } 39 39 40 40 get pinned() { 41 - return this.rootStore.preferences.pinnedFeeds 42 - .map(uri => this._feedModelCache[uri] as CustomFeedModel) 43 - .filter(Boolean) 41 + return this.all.filter(feed => feed.isPinned) 44 42 } 45 43 46 44 get unpinned() { 47 - return this.rootStore.preferences.savedFeeds 48 - .filter(uri => !this.isPinned(uri)) 49 - .map(uri => this._feedModelCache[uri] as CustomFeedModel) 50 - .filter(Boolean) 51 - } 52 - 53 - get all() { 54 - return [...this.pinned, ...this.unpinned] 45 + return this.all.filter(feed => !feed.isPinned) 55 46 } 56 47 57 48 get pinnedFeedNames() { ··· 62 53 // = 63 54 64 55 /** 65 - * Syncs the cached models against the current state 66 - * - Should only be called by the preferences model after syncing state 67 - */ 68 - updateCache = bundleAsync(async (clearCache?: boolean) => { 69 - let newFeedModels: Record<string, CustomFeedModel> = {} 70 - if (!clearCache) { 71 - newFeedModels = {...this._feedModelCache} 72 - } 73 - 74 - // collect the feed URIs that havent been synced yet 75 - const neededFeedUris = [] 76 - for (const feedUri of this.rootStore.preferences.savedFeeds) { 77 - if (!(feedUri in newFeedModels)) { 78 - neededFeedUris.push(feedUri) 79 - } 80 - } 81 - 82 - // early exit if no feeds need to be fetched 83 - if (!neededFeedUris.length || neededFeedUris.length === 0) { 84 - return 85 - } 86 - 87 - // fetch the missing models 88 - try { 89 - for (let i = 0; i < neededFeedUris.length; i += 25) { 90 - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ 91 - feeds: neededFeedUris.slice(i, 25), 92 - }) 93 - for (const feedInfo of res.data.feeds) { 94 - newFeedModels[feedInfo.uri] = new CustomFeedModel( 95 - this.rootStore, 96 - feedInfo, 97 - ) 98 - } 99 - } 100 - } catch (error) { 101 - console.error('Failed to fetch feed models', error) 102 - this.rootStore.log.error('Failed to fetch feed models', error) 103 - } 104 - 105 - // merge into the cache 106 - runInAction(() => { 107 - this._feedModelCache = newFeedModels 108 - }) 109 - }) 110 - 111 - /** 112 56 * Refresh the preferences then reload all feed infos 113 57 */ 114 58 refresh = bundleAsync(async () => { 115 59 this._xLoading(true) 116 60 try { 117 - await this.rootStore.preferences.sync({clearCache: true}) 61 + await this.rootStore.preferences.sync() 62 + const uris = dedup( 63 + this.rootStore.preferences.pinnedFeeds.concat( 64 + this.rootStore.preferences.savedFeeds, 65 + ), 66 + ) 67 + const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri)) 68 + await Promise.all(feeds.map(f => f.setup())) 69 + runInAction(() => { 70 + this.all = feeds 71 + this._updatePinSortOrder() 72 + }) 118 73 this._xIdle() 119 74 } catch (e: any) { 120 75 this._xIdle(e) 121 76 } 122 77 }) 123 78 124 - async save(feed: CustomFeedModel) { 125 - try { 126 - await feed.save() 127 - await this.updateCache() 128 - } catch (e: any) { 129 - this.rootStore.log.error('Failed to save feed', e) 130 - } 131 - } 132 - 133 - async unsave(feed: CustomFeedModel) { 134 - const uri = feed.uri 135 - try { 136 - if (this.isPinned(feed)) { 137 - await this.rootStore.preferences.removePinnedFeed(uri) 138 - } 139 - await feed.unsave() 140 - } catch (e: any) { 141 - this.rootStore.log.error('Failed to unsave feed', e) 142 - } 143 - } 144 - 145 - async togglePinnedFeed(feed: CustomFeedModel) { 146 - if (!this.isPinned(feed)) { 147 - track('CustomFeed:Pin', { 148 - name: feed.data.displayName, 149 - uri: feed.uri, 150 - }) 151 - return this.rootStore.preferences.addPinnedFeed(feed.uri) 152 - } else { 153 - track('CustomFeed:Unpin', { 154 - name: feed.data.displayName, 155 - uri: feed.uri, 156 - }) 157 - return this.rootStore.preferences.removePinnedFeed(feed.uri) 158 - } 159 - } 160 - 161 - async reorderPinnedFeeds(feeds: CustomFeedModel[]) { 162 - return this.rootStore.preferences.setSavedFeeds( 79 + async reorderPinnedFeeds(feeds: FeedSourceModel[]) { 80 + this._updatePinSortOrder(feeds.map(f => f.uri)) 81 + await this.rootStore.preferences.setSavedFeeds( 163 82 this.rootStore.preferences.savedFeeds, 164 - feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), 83 + feeds.filter(feed => feed.isPinned).map(feed => feed.uri), 165 84 ) 166 85 } 167 86 168 - isPinned(feedOrUri: CustomFeedModel | string) { 169 - let uri: string 170 - if (typeof feedOrUri === 'string') { 171 - uri = feedOrUri 172 - } else { 173 - uri = feedOrUri.uri 174 - } 175 - return this.rootStore.preferences.pinnedFeeds.includes(uri) 176 - } 177 - 178 - async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') { 87 + async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { 179 88 const pinned = this.rootStore.preferences.pinnedFeeds.slice() 180 89 const index = pinned.indexOf(item.uri) 181 90 if (index === -1) { ··· 194 103 this.rootStore.preferences.savedFeeds, 195 104 pinned, 196 105 ) 106 + this._updatePinSortOrder() 197 107 track('CustomFeed:Reorder', { 198 - name: item.data.displayName, 108 + name: item.displayName, 199 109 uri: item.uri, 200 110 index: pinned.indexOf(item.uri), 201 111 }) ··· 219 129 this.rootStore.log.error('Failed to fetch user feeds', err) 220 130 } 221 131 } 132 + 133 + // helpers 134 + // = 135 + 136 + _updatePinSortOrder(order?: string[]) { 137 + order ??= this.rootStore.preferences.pinnedFeeds.concat( 138 + this.rootStore.preferences.savedFeeds, 139 + ) 140 + this.all.sort((a, b) => { 141 + return order!.indexOf(a.uri) - order!.indexOf(b.uri) 142 + }) 143 + } 144 + } 145 + 146 + function dedup(strings: string[]): string[] { 147 + return Array.from(new Set(strings)) 222 148 }
+19 -8
src/state/models/ui/shell.ts
··· 1 - import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' 1 + import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' 2 2 import {RootStoreModel} from '../root-store' 3 3 import {makeAutoObservable, runInAction} from 'mobx' 4 4 import {ProfileModel} from '../content/profile' ··· 60 60 | {did: string} 61 61 ) 62 62 63 - export interface CreateOrEditMuteListModal { 64 - name: 'create-or-edit-mute-list' 63 + export interface CreateOrEditListModal { 64 + name: 'create-or-edit-list' 65 + purpose?: string 65 66 list?: ListModel 66 67 onSave?: (uri: string) => void 67 68 } 68 69 69 - export interface ListAddRemoveUserModal { 70 - name: 'list-add-remove-user' 70 + export interface UserAddRemoveListsModal { 71 + name: 'user-add-remove-lists' 71 72 subject: string 72 73 displayName: string 73 - onUpdate?: () => void 74 + onAdd?: (listUri: string) => void 75 + onRemove?: (listUri: string) => void 76 + } 77 + 78 + export interface ListAddUserModal { 79 + name: 'list-add-user' 80 + list: ListModel 81 + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void 74 82 } 75 83 76 84 export interface EditImageModal { ··· 180 188 // Moderation 181 189 | ModerationDetailsModal 182 190 | ReportModal 183 - | CreateOrEditMuteListModal 184 - | ListAddRemoveUserModal 191 + 192 + // Lists 193 + | CreateOrEditListModal 194 + | UserAddRemoveListsModal 195 + | ListAddUserModal 185 196 186 197 // Posts 187 198 | AltTextImageModal
+4 -2
src/view/com/auth/onboarding/RecommendedFeeds.tsx
··· 12 12 import {usePalette} from 'lib/hooks/usePalette' 13 13 import {useQuery} from '@tanstack/react-query' 14 14 import {useStores} from 'state/index' 15 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 15 + import {FeedSourceModel} from 'state/models/content/feed-source' 16 16 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 17 17 18 18 type Props = { ··· 39 39 } 40 40 41 41 return (feeds.length ? feeds : []).map(feed => { 42 - return new CustomFeedModel(store, feed) 42 + const model = new FeedSourceModel(store, feed.uri) 43 + model.hydrateFeedGenerator(feed) 44 + return model 43 45 }) 44 46 } catch (e) { 45 47 return []
+11 -10
src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
··· 3 3 import {observer} from 'mobx-react-lite' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {Text} from 'view/com/util/text/Text' 6 + import {RichText} from 'view/com/util/text/RichText' 6 7 import {Button} from 'view/com/util/forms/Button' 7 8 import {UserAvatar} from 'view/com/util/UserAvatar' 8 9 import * as Toast from 'view/com/util/Toast' ··· 10 11 import {usePalette} from 'lib/hooks/usePalette' 11 12 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 12 13 import {sanitizeHandle} from 'lib/strings/handles' 13 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 14 + import {FeedSourceModel} from 'state/models/content/feed-source' 14 15 15 16 export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ 16 17 item, 17 18 }: { 18 - item: CustomFeedModel 19 + item: FeedSourceModel 19 20 }) { 20 21 const {isMobile} = useWebMediaQueries() 21 22 const pal = usePalette('default') ··· 54 55 }, 55 56 ]}> 56 57 <View style={{marginTop: 2}}> 57 - <UserAvatar type="algo" size={42} avatar={item.data.avatar} /> 58 + <UserAvatar type="algo" size={42} avatar={item.avatar} /> 58 59 </View> 59 60 <View style={{flex: isMobile ? 1 : undefined}}> 60 61 <Text ··· 65 66 </Text> 66 67 67 68 <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> 68 - by {sanitizeHandle(item.data.creator.handle, '@')} 69 + by {sanitizeHandle(item.creatorHandle, '@')} 69 70 </Text> 70 71 71 - {item.data.description ? ( 72 - <Text 72 + {item.descriptionRT ? ( 73 + <RichText 73 74 type="xl" 74 75 style={[ 75 76 pal.text, ··· 79 80 marginBottom: 18, 80 81 }, 81 82 ]} 82 - numberOfLines={6}> 83 - {item.data.description} 84 - </Text> 83 + richText={item.descriptionRT} 84 + numberOfLines={6} 85 + /> 85 86 ) : null} 86 87 87 88 <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> ··· 129 130 style={[pal.textLight, {position: 'relative', top: 2}]} 130 131 /> 131 132 <Text type="lg-medium" style={[pal.text, pal.textLight]}> 132 - {item.data.likeCount || 0} 133 + {item.likeCount || 0} 133 134 </Text> 134 135 </View> 135 136 </View>
+28 -18
src/view/com/feeds/CustomFeed.tsx src/view/com/feeds/FeedSourceCard.tsx
··· 2 2 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 4 import {Text} from '../util/text/Text' 5 + import {RichText} from '../util/text/RichText' 5 6 import {usePalette} from 'lib/hooks/usePalette' 6 7 import {s} from 'lib/styles' 7 8 import {UserAvatar} from '../util/UserAvatar' 8 9 import {observer} from 'mobx-react-lite' 9 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 10 + import {FeedSourceModel} from 'state/models/content/feed-source' 10 11 import {useNavigation} from '@react-navigation/native' 11 12 import {NavigationProp} from 'lib/routes/types' 12 13 import {useStores} from 'state/index' ··· 15 16 import * as Toast from 'view/com/util/Toast' 16 17 import {sanitizeHandle} from 'lib/strings/handles' 17 18 18 - export const CustomFeed = observer(function CustomFeedImpl({ 19 + export const FeedSourceCard = observer(function FeedSourceCardImpl({ 19 20 item, 20 21 style, 21 22 showSaveBtn = false, 22 23 showDescription = false, 23 24 showLikes = false, 24 25 }: { 25 - item: CustomFeedModel 26 + item: FeedSourceModel 26 27 style?: StyleProp<ViewStyle> 27 28 showSaveBtn?: boolean 28 29 showDescription?: boolean ··· 40 41 message: `Remove ${item.displayName} from my feeds?`, 41 42 onPressConfirm: async () => { 42 43 try { 43 - await store.me.savedFeeds.unsave(item) 44 + await item.unsave() 44 45 Toast.show('Removed from my feeds') 45 46 } catch (e) { 46 47 Toast.show('There was an issue contacting your server') ··· 50 51 }) 51 52 } else { 52 53 try { 53 - await store.me.savedFeeds.save(item) 54 + await item.save() 54 55 Toast.show('Added to my feeds') 55 56 } catch (e) { 56 57 Toast.show('There was an issue contacting your server') ··· 65 66 accessibilityRole="button" 66 67 style={[styles.container, pal.border, style]} 67 68 onPress={() => { 68 - navigation.push('CustomFeed', { 69 - name: item.data.creator.did, 70 - rkey: new AtUri(item.data.uri).rkey, 71 - }) 69 + if (item.type === 'feed-generator') { 70 + navigation.push('ProfileFeed', { 71 + name: item.creatorDid, 72 + rkey: new AtUri(item.uri).rkey, 73 + }) 74 + } else if (item.type === 'list') { 75 + navigation.push('ProfileList', { 76 + name: item.creatorDid, 77 + rkey: new AtUri(item.uri).rkey, 78 + }) 79 + } 72 80 }} 73 - key={item.data.uri}> 81 + key={item.uri}> 74 82 <View style={[styles.headerContainer]}> 75 83 <View style={[s.mr10]}> 76 - <UserAvatar type="algo" size={36} avatar={item.data.avatar} /> 84 + <UserAvatar type="algo" size={36} avatar={item.avatar} /> 77 85 </View> 78 86 <View style={[styles.headerTextContainer]}> 79 87 <Text style={[pal.text, s.bold]} numberOfLines={3}> 80 88 {item.displayName} 81 89 </Text> 82 90 <Text style={[pal.textLight]} numberOfLines={3}> 83 - by {sanitizeHandle(item.data.creator.handle, '@')} 91 + by {sanitizeHandle(item.creatorHandle, '@')} 84 92 </Text> 85 93 </View> 86 94 {showSaveBtn && ( ··· 112 120 )} 113 121 </View> 114 122 115 - {showDescription && item.data.description ? ( 116 - <Text style={[pal.textLight, styles.description]} numberOfLines={3}> 117 - {item.data.description} 118 - </Text> 123 + {showDescription && item.descriptionRT ? ( 124 + <RichText 125 + style={[pal.textLight, styles.description]} 126 + richText={item.descriptionRT} 127 + numberOfLines={3} 128 + /> 119 129 ) : null} 120 130 121 131 {showLikes ? ( 122 132 <Text type="sm-medium" style={[pal.text, pal.textLight]}> 123 - Liked by {item.data.likeCount || 0}{' '} 124 - {pluralize(item.data.likeCount || 0, 'user')} 133 + Liked by {item.likeCount || 0}{' '} 134 + {pluralize(item.likeCount || 0, 'user')} 125 135 </Text> 126 136 ) : null} 127 137 </Pressable>
-6
src/view/com/feeds/FeedPage.tsx
··· 111 111 store.shell.openComposer({}) 112 112 }, [store, track]) 113 113 114 - const onPressTryAgain = React.useCallback(() => { 115 - feed.refresh() 116 - }, [feed]) 117 - 118 114 const onPressLoadLatest = React.useCallback(() => { 119 115 scrollToTop() 120 116 feed.refresh() ··· 179 175 <View testID={testID} style={s.h100pct}> 180 176 <Feed 181 177 testID={testID ? `${testID}-feed` : undefined} 182 - key="default" 183 178 feed={feed} 184 179 scrollElRef={scrollElRef} 185 - onPressTryAgain={onPressTryAgain} 186 180 onScroll={onMainScroll} 187 181 scrollEventThrottle={100} 188 182 renderEmptyState={renderEmptyState}
-98
src/view/com/lists/ListActions.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {Button} from '../util/forms/Button' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - 7 - export const ListActions = ({ 8 - muted, 9 - onToggleSubscribed, 10 - onPressEditList, 11 - isOwner, 12 - onPressDeleteList, 13 - onPressShareList, 14 - onPressReportList, 15 - reversed = false, // Default value of reversed is false 16 - }: { 17 - isOwner: boolean 18 - muted?: boolean 19 - onToggleSubscribed?: () => void 20 - onPressEditList?: () => void 21 - onPressDeleteList?: () => void 22 - onPressShareList?: () => void 23 - onPressReportList?: () => void 24 - reversed?: boolean // New optional prop 25 - }) => { 26 - const pal = usePalette('default') 27 - 28 - let buttons = [ 29 - <Button 30 - key="subscribeListBtn" 31 - testID={muted ? 'unsubscribeListBtn' : 'subscribeListBtn'} 32 - type={muted ? 'inverted' : 'primary'} 33 - label={muted ? 'Unsubscribe' : 'Subscribe & Mute'} 34 - accessibilityLabel={muted ? 'Unsubscribe' : 'Subscribe and mute'} 35 - accessibilityHint="" 36 - onPress={onToggleSubscribed} 37 - />, 38 - isOwner && ( 39 - <Button 40 - key="editListBtn" 41 - testID="editListBtn" 42 - type="default" 43 - label="Edit List" 44 - accessibilityLabel="Edit list" 45 - accessibilityHint="" 46 - onPress={onPressEditList} 47 - /> 48 - ), 49 - isOwner && ( 50 - <Button 51 - key="deleteListBtn" 52 - testID="deleteListBtn" 53 - type="default" 54 - accessibilityLabel="Delete list" 55 - accessibilityHint="" 56 - onPress={onPressDeleteList}> 57 - <FontAwesomeIcon icon={['far', 'trash-can']} style={[pal.text]} /> 58 - </Button> 59 - ), 60 - <Button 61 - key="shareListBtn" 62 - testID="shareListBtn" 63 - type="default" 64 - accessibilityLabel="Share list" 65 - accessibilityHint="" 66 - onPress={onPressShareList}> 67 - <FontAwesomeIcon icon={'share'} style={[pal.text]} /> 68 - </Button>, 69 - !isOwner && ( 70 - <Button 71 - key="reportListBtn" 72 - testID="reportListBtn" 73 - type="default" 74 - accessibilityLabel="Report list" 75 - accessibilityHint="" 76 - onPress={onPressReportList}> 77 - <FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} /> 78 - </Button> 79 - ), 80 - ] 81 - 82 - // If reversed is true, reverse the array to reverse the order of the buttons 83 - if (reversed) { 84 - buttons = buttons.filter(Boolean).reverse() // filterting out any falsey values and reversing the array 85 - } else { 86 - buttons = buttons.filter(Boolean) // filterting out any falsey values 87 - } 88 - 89 - return <View style={styles.headerBtns}>{buttons}</View> 90 - } 91 - 92 - const styles = StyleSheet.create({ 93 - headerBtns: { 94 - flexDirection: 'row', 95 - gap: 8, 96 - marginTop: 12, 97 - }, 98 - })
+4 -1
src/view/com/lists/ListCard.tsx
··· 76 76 {sanitizeDisplayName(list.name)} 77 77 </Text> 78 78 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 79 - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} 79 + {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} 80 + {list.purpose === 'app.bsky.graph.defs#modlist' && 81 + 'Moderation list '} 82 + by{' '} 80 83 {list.creator.did === store.me.did 81 84 ? 'you' 82 85 : sanitizeHandle(list.creator.handle, '@')}
+63 -198
src/view/com/lists/ListItems.tsx
··· 3 3 ActivityIndicator, 4 4 RefreshControl, 5 5 StyleProp, 6 - StyleSheet, 7 6 View, 8 7 ViewStyle, 9 - FlatList, 10 8 } from 'react-native' 11 - import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' 9 + import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' 10 + import {FlatList} from '../util/Views' 12 11 import {observer} from 'mobx-react-lite' 13 12 import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 14 13 import {ErrorMessage} from '../util/error/ErrorMessage' 15 14 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 16 15 import {ProfileCard} from '../profile/ProfileCard' 17 16 import {Button} from '../util/forms/Button' 18 - import {Text} from '../util/text/Text' 19 - import {RichText as RichTextCom} from '../util/text/RichText' 20 - import {UserAvatar} from '../util/UserAvatar' 21 - import {TextLink} from '../util/Link' 22 17 import {ListModel} from 'state/models/content/list' 23 18 import {useAnalytics} from 'lib/analytics/analytics' 24 19 import {usePalette} from 'lib/hooks/usePalette' 25 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 26 20 import {useStores} from 'state/index' 21 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 27 22 import {s} from 'lib/styles' 28 - import {ListActions} from './ListActions' 29 - import {makeProfileLink} from 'lib/routes/links' 30 - import {sanitizeHandle} from 'lib/strings/handles' 23 + import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 31 24 32 25 const LOADING_ITEM = {_reactKey: '__loading__'} 33 - const HEADER_ITEM = {_reactKey: '__header__'} 34 26 const EMPTY_ITEM = {_reactKey: '__empty__'} 35 27 const ERROR_ITEM = {_reactKey: '__error__'} 36 28 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} ··· 39 31 list, 40 32 style, 41 33 scrollElRef, 34 + onScroll, 42 35 onPressTryAgain, 43 - onToggleSubscribed, 44 - onPressEditList, 45 - onPressDeleteList, 46 - onPressShareList, 47 - onPressReportList, 36 + renderHeader, 48 37 renderEmptyState, 49 38 testID, 39 + scrollEventThrottle, 50 40 headerOffset = 0, 41 + desktopFixedHeightOffset, 51 42 }: { 52 43 list: ListModel 53 44 style?: StyleProp<ViewStyle> 54 45 scrollElRef?: MutableRefObject<FlatList<any> | null> 46 + onScroll?: OnScrollCb 55 47 onPressTryAgain?: () => void 56 - onToggleSubscribed: () => void 57 - onPressEditList: () => void 58 - onPressDeleteList: () => void 59 - onPressShareList: () => void 60 - onPressReportList: () => void 61 - renderEmptyState?: () => JSX.Element 48 + renderHeader: () => JSX.Element 49 + renderEmptyState: () => JSX.Element 62 50 testID?: string 51 + scrollEventThrottle?: number 63 52 headerOffset?: number 53 + desktopFixedHeightOffset?: number 64 54 }) { 65 55 const pal = usePalette('default') 66 56 const store = useStores() 67 57 const {track} = useAnalytics() 68 58 const [isRefreshing, setIsRefreshing] = React.useState(false) 59 + const {isMobile} = useWebMediaQueries() 69 60 70 61 const data = React.useMemo(() => { 71 - let items: any[] = [HEADER_ITEM] 62 + let items: any[] = [] 72 63 if (list.hasLoaded) { 73 64 if (list.hasError) { 74 65 items = items.concat([ERROR_ITEM]) ··· 124 115 const onPressEditMembership = React.useCallback( 125 116 (profile: AppBskyActorDefs.ProfileViewBasic) => { 126 117 store.shell.openModal({ 127 - name: 'list-add-remove-user', 118 + name: 'user-add-remove-lists', 128 119 subject: profile.did, 129 120 displayName: profile.displayName || profile.handle, 130 - onUpdate() { 131 - list.refresh() 121 + onAdd(listUri: string) { 122 + if (listUri === list.uri) { 123 + list.cacheAddMember(profile) 124 + } 125 + }, 126 + onRemove(listUri: string) { 127 + if (listUri === list.uri) { 128 + list.cacheRemoveMember(profile) 129 + } 132 130 }, 133 131 }) 134 132 }, ··· 145 143 } 146 144 return ( 147 145 <Button 146 + testID={`user-${profile.handle}-editBtn`} 148 147 type="default" 149 148 label="Edit" 150 149 onPress={() => onPressEditMembership(profile)} ··· 157 156 const renderItem = React.useCallback( 158 157 ({item}: {item: any}) => { 159 158 if (item === EMPTY_ITEM) { 160 - if (renderEmptyState) { 161 - return renderEmptyState() 162 - } 163 - return <View /> 164 - } else if (item === HEADER_ITEM) { 165 - return list.list ? ( 166 - <ListHeader 167 - list={list.list} 168 - isOwner={list.isOwner} 169 - onToggleSubscribed={onToggleSubscribed} 170 - onPressEditList={onPressEditList} 171 - onPressDeleteList={onPressDeleteList} 172 - onPressShareList={onPressShareList} 173 - onPressReportList={onPressReportList} 174 - /> 175 - ) : null 159 + return renderEmptyState() 176 160 } else if (item === ERROR_ITEM) { 177 161 return ( 178 162 <ErrorMessage ··· 197 181 }`} 198 182 profile={(item as AppBskyGraphDefs.ListItemView).subject} 199 183 renderButton={renderMemberButton} 184 + style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}} 200 185 /> 201 186 ) 202 187 }, 203 188 [ 204 189 renderMemberButton, 205 190 renderEmptyState, 206 - list.list, 207 - list.isOwner, 208 191 list.error, 209 - onToggleSubscribed, 210 - onPressEditList, 211 - onPressDeleteList, 212 - onPressShareList, 213 - onPressReportList, 214 192 onPressTryAgain, 215 193 onPressRetryLoadMore, 194 + isMobile, 216 195 ], 217 196 ) 218 197 219 198 const Footer = React.useCallback( 220 - () => 221 - list.isLoading ? ( 222 - <View style={styles.feedFooter}> 223 - <ActivityIndicator /> 224 - </View> 225 - ) : ( 226 - <View /> 227 - ), 228 - [list], 199 + () => ( 200 + <View style={{paddingTop: 20, paddingBottom: 200}}> 201 + {list.isLoading && <ActivityIndicator />} 202 + </View> 203 + ), 204 + [list.isLoading], 229 205 ) 230 206 231 207 return ( 232 208 <View testID={testID} style={style}> 233 - {data.length > 0 && ( 234 - <FlatList 235 - testID={testID ? `${testID}-flatlist` : undefined} 236 - ref={scrollElRef} 237 - data={data} 238 - keyExtractor={item => item._reactKey} 239 - renderItem={renderItem} 240 - ListFooterComponent={Footer} 241 - refreshControl={ 242 - <RefreshControl 243 - refreshing={isRefreshing} 244 - onRefresh={onRefresh} 245 - tintColor={pal.colors.text} 246 - titleColor={pal.colors.text} 247 - progressViewOffset={headerOffset} 248 - /> 249 - } 250 - contentContainerStyle={s.contentContainer} 251 - style={{paddingTop: headerOffset}} 252 - onEndReached={onEndReached} 253 - onEndReachedThreshold={0.6} 254 - removeClippedSubviews={true} 255 - contentOffset={{x: 0, y: headerOffset * -1}} 256 - // @ts-ignore our .web version only -prf 257 - desktopFixedHeight 258 - /> 259 - )} 209 + <FlatList 210 + testID={testID ? `${testID}-flatlist` : undefined} 211 + ref={scrollElRef} 212 + data={data} 213 + keyExtractor={(item: any) => item._reactKey} 214 + renderItem={renderItem} 215 + ListHeaderComponent={renderHeader} 216 + ListFooterComponent={Footer} 217 + refreshControl={ 218 + <RefreshControl 219 + refreshing={isRefreshing} 220 + onRefresh={onRefresh} 221 + tintColor={pal.colors.text} 222 + titleColor={pal.colors.text} 223 + progressViewOffset={headerOffset} 224 + /> 225 + } 226 + contentContainerStyle={s.contentContainer} 227 + style={{paddingTop: headerOffset}} 228 + onScroll={onScroll} 229 + onEndReached={onEndReached} 230 + onEndReachedThreshold={0.6} 231 + scrollEventThrottle={scrollEventThrottle} 232 + removeClippedSubviews={true} 233 + contentOffset={{x: 0, y: headerOffset * -1}} 234 + // @ts-ignore our .web version only -prf 235 + desktopFixedHeight={desktopFixedHeightOffset || true} 236 + /> 260 237 </View> 261 238 ) 262 239 }) 263 - 264 - const ListHeader = observer(function ListHeaderImpl({ 265 - list, 266 - isOwner, 267 - onToggleSubscribed, 268 - onPressEditList, 269 - onPressDeleteList, 270 - onPressShareList, 271 - onPressReportList, 272 - }: { 273 - list: AppBskyGraphDefs.ListView 274 - isOwner: boolean 275 - onToggleSubscribed: () => void 276 - onPressEditList: () => void 277 - onPressDeleteList: () => void 278 - onPressShareList: () => void 279 - onPressReportList: () => void 280 - }) { 281 - const pal = usePalette('default') 282 - const store = useStores() 283 - const {isDesktop} = useWebMediaQueries() 284 - const descriptionRT = React.useMemo( 285 - () => 286 - list?.description && 287 - new RichText({ 288 - text: list.description, 289 - facets: (list.descriptionFacets || [])?.slice(), 290 - }), 291 - [list], 292 - ) 293 - return ( 294 - <> 295 - <View style={[styles.header, pal.border]}> 296 - <View style={s.flex1}> 297 - <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> 298 - {list.name} 299 - </Text> 300 - {list && ( 301 - <Text type="md" style={[pal.textLight]} numberOfLines={1}> 302 - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} 303 - by{' '} 304 - {list.creator.did === store.me.did ? ( 305 - 'you' 306 - ) : ( 307 - <TextLink 308 - text={sanitizeHandle(list.creator.handle, '@')} 309 - href={makeProfileLink(list.creator)} 310 - style={pal.textLight} 311 - /> 312 - )} 313 - </Text> 314 - )} 315 - {descriptionRT && ( 316 - <RichTextCom 317 - testID="listDescription" 318 - style={[pal.text, styles.headerDescription]} 319 - richText={descriptionRT} 320 - /> 321 - )} 322 - {isDesktop && ( 323 - <ListActions 324 - isOwner={isOwner} 325 - muted={list.viewer?.muted} 326 - onPressDeleteList={onPressDeleteList} 327 - onPressEditList={onPressEditList} 328 - onToggleSubscribed={onToggleSubscribed} 329 - onPressShareList={onPressShareList} 330 - onPressReportList={onPressReportList} 331 - /> 332 - )} 333 - </View> 334 - <View> 335 - <UserAvatar type="list" avatar={list.avatar} size={64} /> 336 - </View> 337 - </View> 338 - <View 339 - style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}> 340 - <View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> 341 - <Text type="md-medium" style={[pal.text]}> 342 - Muted users 343 - </Text> 344 - </View> 345 - </View> 346 - </> 347 - ) 348 - }) 349 - 350 - const styles = StyleSheet.create({ 351 - header: { 352 - flexDirection: 'row', 353 - gap: 12, 354 - paddingHorizontal: 16, 355 - paddingTop: 12, 356 - paddingBottom: 16, 357 - borderTopWidth: 1, 358 - }, 359 - headerDescription: { 360 - flex: 1, 361 - marginTop: 8, 362 - }, 363 - headerBtns: { 364 - flexDirection: 'row', 365 - gap: 8, 366 - marginTop: 12, 367 - }, 368 - fakeSelectorItem: { 369 - paddingHorizontal: 12, 370 - paddingBottom: 8, 371 - borderBottomWidth: 3, 372 - }, 373 - feedFooter: {paddingTop: 20}, 374 - })
+43 -94
src/view/com/lists/ListsList.tsx
··· 1 - import React, {MutableRefObject} from 'react' 1 + import React from 'react' 2 2 import { 3 + ActivityIndicator, 4 + FlatList as RNFlatList, 3 5 RefreshControl, 4 6 StyleProp, 5 7 StyleSheet, 6 8 View, 7 9 ViewStyle, 8 - FlatList, 9 10 } from 'react-native' 10 11 import {observer} from 'mobx-react-lite' 11 - import { 12 - FontAwesomeIcon, 13 - FontAwesomeIconStyle, 14 - } from '@fortawesome/react-native-fontawesome' 15 12 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' 16 13 import {ListCard} from './ListCard' 17 - import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 18 14 import {ErrorMessage} from '../util/error/ErrorMessage' 19 15 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 20 - import {Button} from '../util/forms/Button' 21 16 import {Text} from '../util/text/Text' 22 17 import {ListsListModel} from 'state/models/lists/lists-list' 23 18 import {useAnalytics} from 'lib/analytics/analytics' 24 19 import {usePalette} from 'lib/hooks/usePalette' 20 + import {FlatList} from '../util/Views.web' 25 21 import {s} from 'lib/styles' 26 22 27 - const LOADING_ITEM = {_reactKey: '__loading__'} 28 - const CREATENEW_ITEM = {_reactKey: '__loading__'} 29 - const EMPTY_ITEM = {_reactKey: '__empty__'} 23 + const LOADING = {_reactKey: '__loading__'} 24 + const EMPTY = {_reactKey: '__empty__'} 30 25 const ERROR_ITEM = {_reactKey: '__error__'} 31 26 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 32 27 33 28 export const ListsList = observer(function ListsListImpl({ 34 29 listsList, 35 - showAddBtns, 30 + inline, 36 31 style, 37 - scrollElRef, 38 32 onPressTryAgain, 39 - onPressCreateNew, 40 33 renderItem, 41 - renderEmptyState, 42 34 testID, 43 - headerOffset = 0, 44 35 }: { 45 36 listsList: ListsListModel 46 - showAddBtns?: boolean 37 + inline?: boolean 47 38 style?: StyleProp<ViewStyle> 48 - scrollElRef?: MutableRefObject<FlatList<any> | null> 49 - onPressCreateNew: () => void 50 39 onPressTryAgain?: () => void 51 - renderItem?: (list: GraphDefs.ListView) => JSX.Element 52 - renderEmptyState?: () => JSX.Element 40 + renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element 53 41 testID?: string 54 - headerOffset?: number 55 42 }) { 56 43 const pal = usePalette('default') 57 44 const {track} = useAnalytics() ··· 59 46 60 47 const data = React.useMemo(() => { 61 48 let items: any[] = [] 62 - if (listsList.hasLoaded) { 63 - if (listsList.hasError) { 64 - items = items.concat([ERROR_ITEM]) 65 - } 66 - if (listsList.isEmpty) { 67 - items = items.concat([EMPTY_ITEM]) 68 - } else { 69 - if (showAddBtns) { 70 - items = items.concat([CREATENEW_ITEM]) 71 - } 72 - items = items.concat(listsList.lists) 73 - } 74 - if (listsList.loadMoreError) { 75 - items = items.concat([LOAD_MORE_ERROR_ITEM]) 76 - } 77 - } else if (listsList.isLoading) { 78 - items = items.concat([LOADING_ITEM]) 49 + if (listsList.hasError) { 50 + items = items.concat([ERROR_ITEM]) 51 + } 52 + if (!listsList.hasLoaded && listsList.isLoading) { 53 + items = items.concat([LOADING]) 54 + } else if (listsList.isEmpty) { 55 + items = items.concat([EMPTY]) 56 + } else { 57 + items = items.concat(listsList.lists) 58 + } 59 + if (listsList.loadMoreError) { 60 + items = items.concat([LOAD_MORE_ERROR_ITEM]) 79 61 } 80 62 return items 81 63 }, [ 82 64 listsList.hasError, 83 65 listsList.hasLoaded, 84 66 listsList.isLoading, 85 - listsList.isEmpty, 86 67 listsList.lists, 68 + listsList.isEmpty, 87 69 listsList.loadMoreError, 88 - showAddBtns, 89 70 ]) 90 71 91 72 // events ··· 119 100 // = 120 101 121 102 const renderItemInner = React.useCallback( 122 - ({item}: {item: any}) => { 123 - if (item === EMPTY_ITEM) { 124 - if (renderEmptyState) { 125 - return renderEmptyState() 126 - } 127 - return <View /> 128 - } else if (item === CREATENEW_ITEM) { 129 - return <CreateNewItem onPress={onPressCreateNew} /> 103 + ({item, index}: {item: any; index: number}) => { 104 + if (item === EMPTY) { 105 + return ( 106 + <View 107 + testID="listsEmpty" 108 + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> 109 + <Text style={pal.textLight}>You have no lists.</Text> 110 + </View> 111 + ) 130 112 } else if (item === ERROR_ITEM) { 131 113 return ( 132 114 <ErrorMessage ··· 141 123 onPress={onPressRetryLoadMore} 142 124 /> 143 125 ) 144 - } else if (item === LOADING_ITEM) { 145 - return <ProfileCardFeedLoadingPlaceholder /> 126 + } else if (item === LOADING) { 127 + return ( 128 + <View style={{padding: 20}}> 129 + <ActivityIndicator /> 130 + </View> 131 + ) 146 132 } 147 133 return renderItem ? ( 148 - renderItem(item) 134 + renderItem(item, index) 149 135 ) : ( 150 136 <ListCard 151 137 list={item} ··· 154 140 /> 155 141 ) 156 142 }, 157 - [ 158 - listsList, 159 - onPressTryAgain, 160 - onPressRetryLoadMore, 161 - onPressCreateNew, 162 - renderItem, 163 - renderEmptyState, 164 - ], 143 + [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], 165 144 ) 166 145 146 + const FlatListCom = inline ? RNFlatList : FlatList 167 147 return ( 168 148 <View testID={testID} style={style}> 169 149 {data.length > 0 && ( 170 - <FlatList 150 + <FlatListCom 171 151 testID={testID ? `${testID}-flatlist` : undefined} 172 - ref={scrollElRef} 173 152 data={data} 174 - keyExtractor={item => item._reactKey} 153 + keyExtractor={(item: any) => item._reactKey} 175 154 renderItem={renderItemInner} 176 155 refreshControl={ 177 156 <RefreshControl ··· 179 158 onRefresh={onRefresh} 180 159 tintColor={pal.colors.text} 181 160 titleColor={pal.colors.text} 182 - progressViewOffset={headerOffset} 183 161 /> 184 162 } 185 163 contentContainerStyle={[s.contentContainer]} 186 - style={{paddingTop: headerOffset}} 187 164 onEndReached={onEndReached} 188 165 onEndReachedThreshold={0.6} 189 166 removeClippedSubviews={true} 190 - contentOffset={{x: 0, y: headerOffset * -1}} 191 167 // @ts-ignore our .web version only -prf 192 168 desktopFixedHeight 193 169 /> ··· 196 172 ) 197 173 }) 198 174 199 - function CreateNewItem({onPress}: {onPress: () => void}) { 200 - const pal = usePalette('default') 201 - 202 - return ( 203 - <View style={[styles.createNewContainer]}> 204 - <Button type="default" onPress={onPress} style={styles.createNewButton}> 205 - <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} /> 206 - <Text type="button" style={pal.text}> 207 - New Mute List 208 - </Text> 209 - </Button> 210 - </View> 211 - ) 212 - } 213 - 214 175 const styles = StyleSheet.create({ 215 - createNewContainer: { 216 - flexDirection: 'row', 217 - alignItems: 'center', 218 - paddingHorizontal: 18, 219 - paddingTop: 18, 220 - paddingBottom: 16, 221 - }, 222 - createNewButton: { 223 - flexDirection: 'row', 224 - alignItems: 'center', 225 - gap: 8, 226 - }, 227 - feedFooter: {paddingTop: 20}, 228 176 item: { 229 177 paddingHorizontal: 18, 178 + paddingVertical: 4, 230 179 }, 231 180 })
+43 -15
src/view/com/modals/CreateOrEditMuteList.tsx src/view/com/modals/CreateOrEditList.tsx
··· 1 - import React, {useState, useCallback} from 'react' 1 + import React, {useState, useCallback, useMemo} from 'react' 2 2 import * as Toast from '../util/Toast' 3 3 import { 4 4 ActivityIndicator, ··· 31 31 export const snapPoints = ['fullscreen'] 32 32 33 33 export function Component({ 34 + purpose, 34 35 onSave, 35 36 list, 36 37 }: { 38 + purpose?: string 37 39 onSave?: (uri: string) => void 38 40 list?: ListModel 39 41 }) { ··· 44 46 const theme = useTheme() 45 47 const {track} = useAnalytics() 46 48 49 + const activePurpose = useMemo(() => { 50 + if (list?.data?.purpose) { 51 + return list.data.purpose 52 + } 53 + if (purpose) { 54 + return purpose 55 + } 56 + return 'app.bsky.graph.defs#curatelist' 57 + }, [list, purpose]) 58 + const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' 59 + const purposeLabel = isCurateList ? 'User' : 'Moderation' 60 + 47 61 const [isProcessing, setProcessing] = useState<boolean>(false) 48 - const [name, setName] = useState<string>(list?.list?.name || '') 62 + const [name, setName] = useState<string>(list?.data?.name || '') 49 63 const [description, setDescription] = useState<string>( 50 - list?.list?.description || '', 64 + list?.data?.description || '', 51 65 ) 52 - const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar) 66 + const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) 53 67 const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() 54 68 55 69 const onPressCancel = useCallback(() => { ··· 63 77 setAvatar(undefined) 64 78 return 65 79 } 66 - track('CreateMuteList:AvatarSelected') 80 + track('CreateList:AvatarSelected') 67 81 try { 68 82 const finalImg = await compressIfNeeded(img, 1000000) 69 83 setNewAvatar(finalImg) ··· 76 90 ) 77 91 78 92 const onPressSave = useCallback(async () => { 79 - track('CreateMuteList:Save') 93 + if (isCurateList) { 94 + track('CreateList:SaveCurateList') 95 + } else { 96 + track('CreateList:SaveModList') 97 + } 80 98 const nameTrimmed = name.trim() 81 99 if (!nameTrimmed) { 82 100 setError('Name is required') ··· 93 111 description: description.trim(), 94 112 avatar: newAvatar, 95 113 }) 96 - Toast.show('Mute list updated') 114 + Toast.show(`${purposeLabel} list updated`) 97 115 onSave?.(list.uri) 98 116 } else { 99 - const res = await ListModel.createModList(store, { 117 + const res = await ListModel.createList(store, { 118 + purpose: activePurpose, 100 119 name, 101 120 description, 102 121 avatar: newAvatar, 103 122 }) 104 - Toast.show('Mute list created') 123 + Toast.show(`${purposeLabel} list created`) 105 124 onSave?.(res.uri) 106 125 } 107 126 store.shell.closeModal() 108 127 } catch (e: any) { 109 128 if (isNetworkError(e)) { 110 129 setError( 111 - 'Failed to create the mute list. Check your internet connection and try again.', 130 + 'Failed to create the list. Check your internet connection and try again.', 112 131 ) 113 132 } else { 114 133 setError(cleanError(e)) ··· 122 141 error, 123 142 onSave, 124 143 store, 144 + activePurpose, 145 + isCurateList, 146 + purposeLabel, 125 147 name, 126 148 description, 127 149 newAvatar, ··· 137 159 paddingHorizontal: isMobile ? 16 : 0, 138 160 }, 139 161 ]} 140 - testID="createOrEditMuteListModal"> 162 + testID="createOrEditListModal"> 141 163 <Text style={[styles.title, pal.text]}> 142 - {list ? 'Edit Mute List' : 'New Mute List'} 164 + {list ? 'Edit' : 'New'} {purposeLabel} List 143 165 </Text> 144 166 {error !== '' && ( 145 167 <View style={styles.errorContainer}> ··· 163 185 <TextInput 164 186 testID="editNameInput" 165 187 style={[styles.textInput, pal.border, pal.text]} 166 - placeholder="e.g. spammers" 188 + placeholder={ 189 + isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers' 190 + } 167 191 placeholderTextColor={colors.gray4} 168 192 value={name} 169 193 onChangeText={v => setName(enforceLen(v, MAX_NAME))} ··· 180 204 <TextInput 181 205 testID="editDescriptionInput" 182 206 style={[styles.textArea, pal.border, pal.text]} 183 - placeholder="e.g. users that repeatedly reply with ads." 207 + placeholder={ 208 + isCurateList 209 + ? 'e.g. The posters who never miss.' 210 + : 'e.g. Users that repeatedly reply with ads.' 211 + } 184 212 placeholderTextColor={colors.gray4} 185 213 keyboardAppearance={theme.colorScheme} 186 214 multiline ··· 203 231 onPress={onPressSave} 204 232 accessibilityRole="button" 205 233 accessibilityLabel="Save" 206 - accessibilityHint="Creates the mute list"> 234 + accessibilityHint=""> 207 235 <LinearGradient 208 236 colors={[gradients.blueLight.start, gradients.blueLight.end]} 209 237 start={{x: 0, y: 0}}
+30 -37
src/view/com/modals/ListAddRemoveUser.tsx src/view/com/modals/UserAddRemoveLists.tsx
··· 1 1 import React, {useCallback} from 'react' 2 2 import {observer} from 'mobx-react-lite' 3 - import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' 3 + import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' 4 4 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' 5 5 import { 6 6 FontAwesomeIcon, ··· 11 11 import {ListsList} from '../lists/ListsList' 12 12 import {ListsListModel} from 'state/models/lists/lists-list' 13 13 import {ListMembershipModel} from 'state/models/content/list-membership' 14 - import {EmptyStateWithButton} from '../util/EmptyStateWithButton' 15 14 import {Button} from '../util/forms/Button' 16 15 import * as Toast from '../util/Toast' 17 16 import {useStores} from 'state/index' ··· 24 23 25 24 export const snapPoints = ['fullscreen'] 26 25 27 - export const Component = observer(function ListAddRemoveUserImpl({ 26 + export const Component = observer(function UserAddRemoveListsImpl({ 28 27 subject, 29 28 displayName, 30 - onUpdate, 29 + onAdd, 30 + onRemove, 31 31 }: { 32 32 subject: string 33 33 displayName: string 34 - onUpdate?: () => void 34 + onAdd?: (listUri: string) => void 35 + onRemove?: (listUri: string) => void 35 36 }) { 36 37 const store = useStores() 37 38 const pal = usePalette('default') ··· 71 72 }, [store]) 72 73 73 74 const onPressSave = useCallback(async () => { 75 + let changes 74 76 try { 75 - await memberships.updateTo(selected) 77 + changes = await memberships.updateTo(selected) 76 78 } catch (err) { 77 79 store.log.error('Failed to update memberships', {err}) 78 80 return 79 81 } 80 82 Toast.show('Lists updated') 81 - onUpdate?.() 83 + for (const uri of changes.added) { 84 + onAdd?.(uri) 85 + } 86 + for (const uri of changes.removed) { 87 + onRemove?.(uri) 88 + } 82 89 store.shell.closeModal() 83 - }, [store, selected, memberships, onUpdate]) 84 - 85 - const onPressNewMuteList = useCallback(() => { 86 - store.shell.openModal({ 87 - name: 'create-or-edit-mute-list', 88 - onSave: (_uri: string) => { 89 - listsList.refresh() 90 - }, 91 - }) 92 - }, [store, listsList]) 90 + }, [store, selected, memberships, onAdd, onRemove]) 93 91 94 92 const onToggleSelected = useCallback( 95 93 (uri: string) => { ··· 103 101 ) 104 102 105 103 const renderItem = useCallback( 106 - (list: GraphDefs.ListView) => { 104 + (list: GraphDefs.ListView, index: number) => { 107 105 const isSelected = selected.includes(list.uri) 108 106 return ( 109 107 <Pressable ··· 111 109 style={[ 112 110 styles.listItem, 113 111 pal.border, 114 - {opacity: membershipsLoaded ? 1 : 0.5}, 112 + { 113 + opacity: membershipsLoaded ? 1 : 0.5, 114 + borderTopWidth: index === 0 ? 0 : 1, 115 + }, 115 116 ]} 116 117 accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ 117 118 list.name ··· 131 132 {sanitizeDisplayName(list.name)} 132 133 </Text> 133 134 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 134 - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} 135 + {list.purpose === 'app.bsky.graph.defs#curatelist' && 136 + 'User list '} 137 + {list.purpose === 'app.bsky.graph.defs#modlist' && 138 + 'Moderation list '} 139 + by{' '} 135 140 {list.creator.did === store.me.did 136 141 ? 'you' 137 142 : sanitizeHandle(list.creator.handle, '@')} ··· 166 171 ], 167 172 ) 168 173 169 - const renderEmptyState = React.useCallback(() => { 170 - return ( 171 - <EmptyStateWithButton 172 - icon="users-slash" 173 - 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." 174 - buttonLabel="New Mute List" 175 - onPress={onPressNewMuteList} 176 - /> 177 - ) 178 - }, [onPressNewMuteList]) 179 - 180 174 // Only show changes button if there are some items on the list to choose from AND user has made changes in selection 181 175 const canSaveChanges = 182 176 !listsList.isEmpty && !isEqual(selected, originalSelections) 183 177 184 178 return ( 185 - <View testID="listAddRemoveUserModal" style={s.hContentRegion}> 186 - <Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> 179 + <View testID="userAddRemoveListsModal" style={s.hContentRegion}> 180 + <Text style={[styles.title, pal.text]}> 181 + Update {displayName} in Lists 182 + </Text> 187 183 <ListsList 188 184 listsList={listsList} 189 - showAddBtns 190 - onPressCreateNew={onPressNewMuteList} 185 + inline 191 186 renderItem={renderItem} 192 - renderEmptyState={renderEmptyState} 193 187 style={[styles.list, pal.border]} 194 188 /> 195 189 <View style={[styles.btns, pal.border]}> ··· 258 252 listItem: { 259 253 flexDirection: 'row', 260 254 alignItems: 'center', 261 - borderTopWidth: 1, 262 255 paddingHorizontal: 14, 263 256 paddingVertical: 10, 264 257 },
+281
src/view/com/modals/ListAddUser.tsx
··· 1 + import React, {useEffect, useCallback, useState, useMemo} from 'react' 2 + import { 3 + ActivityIndicator, 4 + Pressable, 5 + SafeAreaView, 6 + StyleSheet, 7 + View, 8 + } from 'react-native' 9 + import {AppBskyActorDefs} from '@atproto/api' 10 + import {ScrollView, TextInput} from './util' 11 + import {observer} from 'mobx-react-lite' 12 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 + import {Text} from '../util/text/Text' 14 + import {Button} from '../util/forms/Button' 15 + import {UserAvatar} from '../util/UserAvatar' 16 + import * as Toast from '../util/Toast' 17 + import {useStores} from 'state/index' 18 + import {ListModel} from 'state/models/content/list' 19 + import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' 20 + import {s, colors} from 'lib/styles' 21 + import {usePalette} from 'lib/hooks/usePalette' 22 + import {isWeb} from 'platform/detection' 23 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 24 + import {cleanError} from 'lib/strings/errors' 25 + import {sanitizeDisplayName} from 'lib/strings/display-names' 26 + import {sanitizeHandle} from 'lib/strings/handles' 27 + 28 + export const snapPoints = ['90%'] 29 + 30 + export const Component = observer(function Component({ 31 + list, 32 + onAdd, 33 + }: { 34 + list: ListModel 35 + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void 36 + }) { 37 + const pal = usePalette('default') 38 + const store = useStores() 39 + const {isMobile} = useWebMediaQueries() 40 + const [query, setQuery] = useState('') 41 + const autocompleteView = useMemo<UserAutocompleteModel>( 42 + () => new UserAutocompleteModel(store), 43 + [store], 44 + ) 45 + 46 + // initial setup 47 + useEffect(() => { 48 + autocompleteView.setup().then(() => { 49 + autocompleteView.setPrefix('') 50 + }) 51 + autocompleteView.setActive(true) 52 + list.loadAll() 53 + }, [autocompleteView, list]) 54 + 55 + const onChangeQuery = useCallback( 56 + (text: string) => { 57 + setQuery(text) 58 + autocompleteView.setPrefix(text) 59 + }, 60 + [setQuery, autocompleteView], 61 + ) 62 + 63 + const onPressCancelSearch = useCallback( 64 + () => onChangeQuery(''), 65 + [onChangeQuery], 66 + ) 67 + 68 + return ( 69 + <SafeAreaView 70 + testID="listAddUserModal" 71 + style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}> 72 + <View 73 + style={[ 74 + s.flex1, 75 + isMobile && {paddingHorizontal: 18, paddingBottom: 40}, 76 + ]}> 77 + <View style={styles.titleSection}> 78 + <Text type="title-lg" style={[pal.text, styles.title]}> 79 + Add User to List 80 + </Text> 81 + </View> 82 + <View style={[styles.searchContainer, pal.border]}> 83 + <FontAwesomeIcon icon="search" size={16} /> 84 + <TextInput 85 + testID="searchInput" 86 + style={[styles.searchInput, pal.border, pal.text]} 87 + placeholder="Search for users" 88 + placeholderTextColor={pal.colors.textLight} 89 + value={query} 90 + onChangeText={onChangeQuery} 91 + accessible={true} 92 + accessibilityLabel="Search" 93 + accessibilityHint="" 94 + autoCapitalize="none" 95 + autoComplete="off" 96 + autoCorrect={false} 97 + /> 98 + {query ? ( 99 + <Pressable 100 + onPress={onPressCancelSearch} 101 + accessibilityRole="button" 102 + accessibilityLabel="Cancel search" 103 + accessibilityHint="Exits inputting search query" 104 + onAccessibilityEscape={onPressCancelSearch}> 105 + <FontAwesomeIcon 106 + icon="xmark" 107 + size={16} 108 + color={pal.colors.textLight} 109 + /> 110 + </Pressable> 111 + ) : undefined} 112 + </View> 113 + <ScrollView style={[s.flex1]}> 114 + {autocompleteView.suggestions.length ? ( 115 + <> 116 + {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( 117 + <UserResult 118 + key={item.did} 119 + list={list} 120 + profile={item} 121 + noBorder={i === 0} 122 + onAdd={onAdd} 123 + /> 124 + ))} 125 + </> 126 + ) : ( 127 + <Text 128 + type="xl" 129 + style={[ 130 + pal.textLight, 131 + {paddingHorizontal: 12, paddingVertical: 16}, 132 + ]}> 133 + No results found for {autocompleteView.prefix} 134 + </Text> 135 + )} 136 + </ScrollView> 137 + <View style={[styles.btnContainer]}> 138 + <Button 139 + testID="doneBtn" 140 + type="primary" 141 + onPress={() => store.shell.closeModal()} 142 + accessibilityLabel="Done" 143 + accessibilityHint="" 144 + label="Done" 145 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 146 + labelStyle={[s.f18]} 147 + /> 148 + </View> 149 + </View> 150 + </SafeAreaView> 151 + ) 152 + }) 153 + 154 + function UserResult({ 155 + profile, 156 + list, 157 + noBorder, 158 + onAdd, 159 + }: { 160 + profile: AppBskyActorDefs.ProfileViewBasic 161 + list: ListModel 162 + noBorder: boolean 163 + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined 164 + }) { 165 + const pal = usePalette('default') 166 + const [isProcessing, setIsProcessing] = useState(false) 167 + const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) 168 + 169 + const onPressAdd = useCallback(async () => { 170 + setIsProcessing(true) 171 + try { 172 + await list.addMember(profile) 173 + Toast.show('Added to list') 174 + setIsAdded(true) 175 + onAdd?.(profile) 176 + } catch (e) { 177 + Toast.show(cleanError(e)) 178 + } finally { 179 + setIsProcessing(false) 180 + } 181 + }, [list, profile, setIsProcessing, setIsAdded, onAdd]) 182 + 183 + return ( 184 + <View 185 + style={[ 186 + pal.border, 187 + { 188 + flexDirection: 'row', 189 + alignItems: 'center', 190 + borderTopWidth: noBorder ? 0 : 1, 191 + paddingVertical: 8, 192 + paddingHorizontal: 8, 193 + }, 194 + ]}> 195 + <View 196 + style={{ 197 + alignSelf: 'baseline', 198 + width: 54, 199 + paddingLeft: 4, 200 + paddingTop: 10, 201 + }}> 202 + <UserAvatar size={40} avatar={profile.avatar} /> 203 + </View> 204 + <View 205 + style={{ 206 + flex: 1, 207 + paddingRight: 10, 208 + paddingTop: 10, 209 + paddingBottom: 10, 210 + }}> 211 + <Text 212 + type="lg" 213 + style={[s.bold, pal.text]} 214 + numberOfLines={1} 215 + lineHeight={1.2}> 216 + {sanitizeDisplayName( 217 + profile.displayName || sanitizeHandle(profile.handle), 218 + )} 219 + </Text> 220 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 221 + {sanitizeHandle(profile.handle, '@')} 222 + </Text> 223 + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} 224 + </View> 225 + <View> 226 + {isAdded ? ( 227 + <FontAwesomeIcon icon="check" /> 228 + ) : isProcessing ? ( 229 + <ActivityIndicator /> 230 + ) : ( 231 + <Button 232 + testID={`user-${profile.handle}-addBtn`} 233 + type="default" 234 + label="Add" 235 + onPress={onPressAdd} 236 + /> 237 + )} 238 + </View> 239 + </View> 240 + ) 241 + } 242 + 243 + const styles = StyleSheet.create({ 244 + fixedHeight: { 245 + // @ts-ignore web only -prf 246 + height: '80vh', 247 + }, 248 + titleSection: { 249 + paddingTop: isWeb ? 0 : 4, 250 + paddingBottom: isWeb ? 14 : 10, 251 + }, 252 + title: { 253 + textAlign: 'center', 254 + fontWeight: '600', 255 + marginBottom: 5, 256 + }, 257 + searchContainer: { 258 + flexDirection: 'row', 259 + alignItems: 'center', 260 + gap: 8, 261 + borderWidth: 1, 262 + borderRadius: 24, 263 + paddingHorizontal: 16, 264 + paddingVertical: 10, 265 + }, 266 + searchInput: { 267 + fontSize: 16, 268 + flex: 1, 269 + }, 270 + btn: { 271 + flexDirection: 'row', 272 + alignItems: 'center', 273 + justifyContent: 'center', 274 + borderRadius: 32, 275 + padding: 14, 276 + backgroundColor: colors.blue3, 277 + }, 278 + btnContainer: { 279 + paddingTop: 20, 280 + }, 281 + })
+12 -8
src/view/com/modals/Modal.tsx
··· 16 16 import * as ServerInputModal from './ServerInput' 17 17 import * as RepostModal from './Repost' 18 18 import * as SelfLabelModal from './SelfLabel' 19 - import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' 20 - import * as ListAddRemoveUserModal from './ListAddRemoveUser' 19 + import * as CreateOrEditListModal from './CreateOrEditList' 20 + import * as UserAddRemoveListsModal from './UserAddRemoveLists' 21 + import * as ListAddUserModal from './ListAddUser' 21 22 import * as AltImageModal from './AltImage' 22 23 import * as EditImageModal from './AltImage' 23 24 import * as ReportModal from './report/Modal' ··· 101 102 } else if (activeModal?.name === 'report') { 102 103 snapPoints = ReportModal.snapPoints 103 104 element = <ReportModal.Component {...activeModal} /> 104 - } else if (activeModal?.name === 'create-or-edit-mute-list') { 105 - snapPoints = CreateOrEditMuteListModal.snapPoints 106 - element = <CreateOrEditMuteListModal.Component {...activeModal} /> 107 - } else if (activeModal?.name === 'list-add-remove-user') { 108 - snapPoints = ListAddRemoveUserModal.snapPoints 109 - element = <ListAddRemoveUserModal.Component {...activeModal} /> 105 + } else if (activeModal?.name === 'create-or-edit-list') { 106 + snapPoints = CreateOrEditListModal.snapPoints 107 + element = <CreateOrEditListModal.Component {...activeModal} /> 108 + } else if (activeModal?.name === 'user-add-remove-lists') { 109 + snapPoints = UserAddRemoveListsModal.snapPoints 110 + element = <UserAddRemoveListsModal.Component {...activeModal} /> 111 + } else if (activeModal?.name === 'list-add-user') { 112 + snapPoints = ListAddUserModal.snapPoints 113 + element = <ListAddUserModal.Component {...activeModal} /> 110 114 } else if (activeModal?.name === 'delete-account') { 111 115 snapPoints = DeleteAccountModal.snapPoints 112 116 element = <DeleteAccountModal.Component />
+9 -6
src/view/com/modals/Modal.web.tsx
··· 11 11 import * as ProfilePreviewModal from './ProfilePreview' 12 12 import * as ServerInputModal from './ServerInput' 13 13 import * as ReportModal from './report/Modal' 14 - import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' 15 - import * as ListAddRemoveUserModal from './ListAddRemoveUser' 14 + import * as CreateOrEditListModal from './CreateOrEditList' 15 + import * as UserAddRemoveLists from './UserAddRemoveLists' 16 + import * as ListAddUserModal from './ListAddUser' 16 17 import * as DeleteAccountModal from './DeleteAccount' 17 18 import * as RepostModal from './Repost' 18 19 import * as SelfLabelModal from './SelfLabel' ··· 79 80 element = <ServerInputModal.Component {...modal} /> 80 81 } else if (modal.name === 'report') { 81 82 element = <ReportModal.Component {...modal} /> 82 - } else if (modal.name === 'create-or-edit-mute-list') { 83 - element = <CreateOrEditMuteListModal.Component {...modal} /> 84 - } else if (modal.name === 'list-add-remove-user') { 85 - element = <ListAddRemoveUserModal.Component {...modal} /> 83 + } else if (modal.name === 'create-or-edit-list') { 84 + element = <CreateOrEditListModal.Component {...modal} /> 85 + } else if (modal.name === 'user-add-remove-lists') { 86 + element = <UserAddRemoveLists.Component {...modal} /> 87 + } else if (modal.name === 'list-add-user') { 88 + element = <ListAddUserModal.Component {...modal} /> 86 89 } else if (modal.name === 'crop-image') { 87 90 element = <CropImageModal.Component {...modal} /> 88 91 } else if (modal.name === 'delete-account') {
+19 -2
src/view/com/modals/ModerationDetails.tsx
··· 31 31 description = 32 32 'Moderator has chosen to set a general warning on the content.' 33 33 } else if (moderation.cause.type === 'blocking') { 34 - name = 'User Blocked' 35 - description = 'You have blocked this user. You cannot view their content.' 34 + if (moderation.cause.source.type === 'list') { 35 + const list = moderation.cause.source.list 36 + name = 'User Blocked by List' 37 + description = ( 38 + <> 39 + This user is included in the{' '} 40 + <TextLink 41 + type="2xl" 42 + href={listUriToHref(list.uri)} 43 + text={list.name} 44 + style={pal.link} 45 + />{' '} 46 + list which you have blocked. 47 + </> 48 + ) 49 + } else { 50 + name = 'User Blocked' 51 + description = 'You have blocked this user. You cannot view their content.' 52 + } 36 53 } else if (moderation.cause.type === 'blocked-by') { 37 54 name = 'User Blocks You' 38 55 description = 'This user has blocked you. You cannot view their content.'
+3 -5
src/view/com/pager/FeedsTabBar.web.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import React from 'react' 2 2 import {StyleSheet} from 'react-native' 3 3 import Animated from 'react-native-reanimated' 4 4 import {observer} from 'mobx-react-lite' 5 5 import {TabBar} from 'view/com/pager/TabBar' 6 6 import {RenderTabBarFnProps} from 'view/com/pager/Pager' 7 7 import {useStores} from 'state/index' 8 + import {useHomeTabs} from 'lib/hooks/useHomeTabs' 8 9 import {usePalette} from 'lib/hooks/usePalette' 9 10 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 10 11 import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' ··· 27 28 props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 28 29 ) { 29 30 const store = useStores() 30 - const items = useMemo( 31 - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], 32 - [store.me.savedFeeds.pinnedFeedNames], 33 - ) 31 + const items = useHomeTabs(store.preferences.pinnedFeeds) 34 32 const pal = usePalette('default') 35 33 const {headerMinimalShellTransform} = useMinimalShellMode() 36 34
+5 -13
src/view/com/pager/FeedsTabBarMobile.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import React from 'react' 2 2 import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import {TabBar} from 'view/com/pager/TabBar' 5 5 import {RenderTabBarFnProps} from 'view/com/pager/Pager' 6 6 import {useStores} from 'state/index' 7 + import {useHomeTabs} from 'lib/hooks/useHomeTabs' 7 8 import {usePalette} from 'lib/hooks/usePalette' 8 9 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 9 10 import {Link} from '../util/Link' ··· 18 19 export const FeedsTabBar = observer(function FeedsTabBarImpl( 19 20 props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 20 21 ) { 21 - const store = useStores() 22 22 const pal = usePalette('default') 23 - 23 + const store = useStores() 24 + const items = useHomeTabs(store.preferences.pinnedFeeds) 24 25 const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) 25 26 const {headerMinimalShellTransform} = useMinimalShellMode() 26 27 27 28 const onPressAvi = React.useCallback(() => { 28 29 store.shell.openDrawer() 29 30 }, [store]) 30 - 31 - const items = useMemo( 32 - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], 33 - [store.me.savedFeeds.pinnedFeedNames], 34 - ) 35 - 36 - const tabBarKey = useMemo(() => { 37 - return items.join(',') 38 - }, [items]) 39 31 40 32 return ( 41 33 <Animated.View ··· 81 73 </View> 82 74 </View> 83 75 <TabBar 84 - key={tabBarKey} 76 + key={items.join(',')} 85 77 onPressSelected={props.onPressSelected} 86 78 selectedPage={props.selectedPage} 87 79 onSelect={props.onSelect}
+62 -5
src/view/com/pager/Pager.tsx
··· 1 1 import React, {forwardRef} from 'react' 2 2 import {Animated, View} from 'react-native' 3 - import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view' 3 + import PagerView, { 4 + PagerViewOnPageSelectedEvent, 5 + PagerViewOnPageScrollEvent, 6 + PageScrollStateChangedNativeEvent, 7 + } from 'react-native-pager-view' 4 8 import {s} from 'lib/styles' 5 9 6 10 export type PageSelectedEvent = PagerViewOnPageSelectedEvent ··· 21 25 initialPage?: number 22 26 renderTabBar: RenderTabBarFn 23 27 onPageSelected?: (index: number) => void 28 + onPageSelecting?: (index: number) => void 24 29 testID?: string 25 30 } 26 31 export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( ··· 31 36 initialPage = 0, 32 37 renderTabBar, 33 38 onPageSelected, 39 + onPageSelecting, 34 40 testID, 35 41 }: React.PropsWithChildren<Props>, 36 42 ref, 37 43 ) { 38 44 const [selectedPage, setSelectedPage] = React.useState(0) 45 + const lastOffset = React.useRef(0) 46 + const lastDirection = React.useRef(0) 47 + const scrollState = React.useRef('') 39 48 const pagerView = React.useRef<PagerView>(null) 40 49 41 50 React.useImperativeHandle(ref, () => ({ ··· 50 59 [setSelectedPage, onPageSelected], 51 60 ) 52 61 62 + const onPageScroll = React.useCallback( 63 + (e: PagerViewOnPageScrollEvent) => { 64 + const {position, offset} = e.nativeEvent 65 + if (offset === 0) { 66 + // offset hits 0 in some awkward spots so we ignore it 67 + return 68 + } 69 + // NOTE 70 + // we want to call `onPageSelecting` as soon as the scroll-gesture 71 + // enters the "settling" phase, which means the user has released it 72 + // we can't infer directionality from the scroll information, so we 73 + // track the offset changes. if the offset delta is consistent with 74 + // the existing direction during the settling phase, we can say for 75 + // certain where it's going and can fire 76 + // -prf 77 + if (scrollState.current === 'settling') { 78 + if (lastDirection.current === -1 && offset < lastOffset.current) { 79 + onPageSelecting?.(position) 80 + lastDirection.current = 0 81 + } else if ( 82 + lastDirection.current === 1 && 83 + offset > lastOffset.current 84 + ) { 85 + onPageSelecting?.(position + 1) 86 + lastDirection.current = 0 87 + } 88 + } else { 89 + if (offset < lastOffset.current) { 90 + lastDirection.current = -1 91 + } else if (offset > lastOffset.current) { 92 + lastDirection.current = 1 93 + } 94 + } 95 + lastOffset.current = offset 96 + }, 97 + [lastOffset, lastDirection, onPageSelecting], 98 + ) 99 + 100 + const onPageScrollStateChanged = React.useCallback( 101 + (e: PageScrollStateChangedNativeEvent) => { 102 + scrollState.current = e.nativeEvent.pageScrollState 103 + }, 104 + [scrollState], 105 + ) 106 + 53 107 const onTabBarSelect = React.useCallback( 54 108 (index: number) => { 55 109 pagerView.current?.setPage(index) 110 + onPageSelecting?.(index) 56 111 }, 57 - [pagerView], 112 + [pagerView, onPageSelecting], 58 113 ) 59 114 60 115 return ( 61 - <View testID={testID}> 116 + <View testID={testID} style={s.flex1}> 62 117 {tabBarPosition === 'top' && 63 118 renderTabBar({ 64 119 selectedPage, ··· 66 121 })} 67 122 <AnimatedPagerView 68 123 ref={pagerView} 69 - style={s.h100pct} 124 + style={s.flex1} 70 125 initialPage={initialPage} 71 - onPageSelected={onPageSelectedInner}> 126 + onPageScrollStateChanged={onPageScrollStateChanged} 127 + onPageSelected={onPageSelectedInner} 128 + onPageScroll={onPageScroll}> 72 129 {children} 73 130 </AnimatedPagerView> 74 131 {tabBarPosition === 'bottom' &&
+6 -5
src/view/com/pager/Pager.web.tsx
··· 13 13 initialPage?: number 14 14 renderTabBar: RenderTabBarFn 15 15 onPageSelected?: (index: number) => void 16 + onPageSelecting?: (index: number) => void 16 17 } 17 18 export const Pager = React.forwardRef(function PagerImpl( 18 19 { ··· 21 22 initialPage = 0, 22 23 renderTabBar, 23 24 onPageSelected, 25 + onPageSelecting, 24 26 }: React.PropsWithChildren<Props>, 25 27 ref, 26 28 ) { ··· 34 36 (index: number) => { 35 37 setSelectedPage(index) 36 38 onPageSelected?.(index) 39 + onPageSelecting?.(index) 37 40 }, 38 - [setSelectedPage, onPageSelected], 41 + [setSelectedPage, onPageSelected, onPageSelecting], 39 42 ) 40 43 41 44 return ( 42 - <View> 45 + <View style={s.hContentRegion}> 43 46 {tabBarPosition === 'top' && 44 47 renderTabBar({ 45 48 selectedPage, 46 49 onSelect: onTabBarSelect, 47 50 })} 48 51 {React.Children.map(children, (child, i) => ( 49 - <View 50 - style={selectedPage === i ? undefined : s.hidden} 51 - key={`page-${i}`}> 52 + <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> 52 53 {child} 53 54 </View> 54 55 ))}
+212
src/view/com/pager/PagerWithHeader.tsx
··· 1 + import * as React from 'react' 2 + import {LayoutChangeEvent, StyleSheet} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + useAnimatedReaction, 6 + useAnimatedScrollHandler, 7 + useAnimatedStyle, 8 + useSharedValue, 9 + withTiming, 10 + runOnJS, 11 + } from 'react-native-reanimated' 12 + import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 13 + import {TabBar} from './TabBar' 14 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 15 + import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 16 + 17 + const SCROLLED_DOWN_LIMIT = 200 18 + 19 + interface PagerWithHeaderChildParams { 20 + headerHeight: number 21 + onScroll: OnScrollCb 22 + isScrolledDown: boolean 23 + } 24 + 25 + export interface PagerWithHeaderProps { 26 + testID?: string 27 + children: 28 + | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] 29 + | ((props: PagerWithHeaderChildParams) => JSX.Element) 30 + items: string[] 31 + renderHeader?: () => JSX.Element 32 + initialPage?: number 33 + onPageSelected?: (index: number) => void 34 + onCurrentPageSelected?: (index: number) => void 35 + } 36 + export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( 37 + function PageWithHeaderImpl( 38 + { 39 + children, 40 + testID, 41 + items, 42 + renderHeader, 43 + initialPage, 44 + onPageSelected, 45 + onCurrentPageSelected, 46 + }: PagerWithHeaderProps, 47 + ref, 48 + ) { 49 + const {isMobile} = useWebMediaQueries() 50 + const [currentPage, setCurrentPage] = React.useState(0) 51 + const scrollYs = React.useRef<Record<number, number>>({}) 52 + const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) 53 + const [tabBarHeight, setTabBarHeight] = React.useState(0) 54 + const [headerHeight, setHeaderHeight] = React.useState(0) 55 + const [isScrolledDown, setIsScrolledDown] = React.useState( 56 + scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, 57 + ) 58 + 59 + // react to scroll updates 60 + function onScrollUpdate(v: number) { 61 + // track each page's current scroll position 62 + scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight) 63 + // update the 'is scrolled down' value 64 + setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) 65 + } 66 + useAnimatedReaction( 67 + () => scrollY.value, 68 + v => runOnJS(onScrollUpdate)(v), 69 + ) 70 + 71 + // capture the header bar sizing 72 + const onTabBarLayout = React.useCallback( 73 + (evt: LayoutChangeEvent) => { 74 + setTabBarHeight(evt.nativeEvent.layout.height) 75 + }, 76 + [setTabBarHeight], 77 + ) 78 + const onHeaderLayout = React.useCallback( 79 + (evt: LayoutChangeEvent) => { 80 + setHeaderHeight(evt.nativeEvent.layout.height) 81 + }, 82 + [setHeaderHeight], 83 + ) 84 + 85 + // render the the header and tab bar 86 + const headerTransform = useAnimatedStyle( 87 + () => ({ 88 + transform: [ 89 + { 90 + translateY: Math.min( 91 + Math.min(scrollY.value, headerHeight - tabBarHeight) * -1, 92 + 0, 93 + ), 94 + }, 95 + ], 96 + }), 97 + [scrollY, headerHeight, tabBarHeight], 98 + ) 99 + const renderTabBar = React.useCallback( 100 + (props: RenderTabBarFnProps) => { 101 + return ( 102 + <Animated.View 103 + onLayout={onHeaderLayout} 104 + style={[ 105 + isMobile ? styles.tabBarMobile : styles.tabBarDesktop, 106 + headerTransform, 107 + ]}> 108 + {renderHeader?.()} 109 + <TabBar 110 + items={items} 111 + selectedPage={currentPage} 112 + onSelect={props.onSelect} 113 + onPressSelected={onCurrentPageSelected} 114 + onLayout={onTabBarLayout} 115 + /> 116 + </Animated.View> 117 + ) 118 + }, 119 + [ 120 + items, 121 + renderHeader, 122 + headerTransform, 123 + currentPage, 124 + onCurrentPageSelected, 125 + isMobile, 126 + onTabBarLayout, 127 + onHeaderLayout, 128 + ], 129 + ) 130 + 131 + // props to pass into children render functions 132 + const onScroll = useAnimatedScrollHandler({ 133 + onScroll(e) { 134 + scrollY.value = e.contentOffset.y 135 + }, 136 + }) 137 + const childProps = React.useMemo<PagerWithHeaderChildParams>(() => { 138 + return { 139 + headerHeight, 140 + onScroll, 141 + isScrolledDown, 142 + } 143 + }, [headerHeight, onScroll, isScrolledDown]) 144 + 145 + const onPageSelectedInner = React.useCallback( 146 + (index: number) => { 147 + setCurrentPage(index) 148 + onPageSelected?.(index) 149 + }, 150 + [onPageSelected, setCurrentPage], 151 + ) 152 + 153 + const onPageSelecting = React.useCallback( 154 + (index: number) => { 155 + setCurrentPage(index) 156 + if (scrollY.value > headerHeight) { 157 + scrollY.value = headerHeight 158 + } 159 + scrollY.value = withTiming(scrollYs.current[index] || 0, { 160 + duration: 170, 161 + easing: Easing.inOut(Easing.quad), 162 + }) 163 + }, 164 + [scrollY, setCurrentPage, scrollYs, headerHeight], 165 + ) 166 + 167 + return ( 168 + <Pager 169 + ref={ref} 170 + testID={testID} 171 + initialPage={initialPage} 172 + onPageSelected={onPageSelectedInner} 173 + onPageSelecting={onPageSelecting} 174 + renderTabBar={renderTabBar} 175 + tabBarPosition="top"> 176 + {toArray(children) 177 + .filter(Boolean) 178 + .map(child => { 179 + if (child) { 180 + return child(childProps) 181 + } 182 + return null 183 + })} 184 + </Pager> 185 + ) 186 + }, 187 + ) 188 + 189 + const styles = StyleSheet.create({ 190 + tabBarMobile: { 191 + position: 'absolute', 192 + zIndex: 1, 193 + top: 0, 194 + left: 0, 195 + width: '100%', 196 + }, 197 + tabBarDesktop: { 198 + position: 'absolute', 199 + zIndex: 1, 200 + top: 0, 201 + // @ts-ignore Web only -prf 202 + left: 'calc(50% - 299px)', 203 + width: 598, 204 + }, 205 + }) 206 + 207 + function toArray<T>(v: T | T[]): T[] { 208 + if (Array.isArray(v)) { 209 + return v 210 + } 211 + return [v] 212 + }
+5 -6
src/view/com/pager/TabBar.tsx
··· 13 13 items: string[] 14 14 indicatorColor?: string 15 15 onSelect?: (index: number) => void 16 - onPressSelected?: () => void 16 + onPressSelected?: (index: number) => void 17 + onLayout?: (evt: LayoutChangeEvent) => void 17 18 } 18 19 19 20 export function TabBar({ ··· 23 24 indicatorColor, 24 25 onSelect, 25 26 onPressSelected, 27 + onLayout, 26 28 }: TabBarProps) { 27 29 const pal = usePalette('default') 28 30 const scrollElRef = useRef<ScrollView>(null) ··· 44 46 (index: number) => { 45 47 onSelect?.(index) 46 48 if (index === selectedPage) { 47 - onPressSelected?.() 49 + onPressSelected?.(index) 48 50 } 49 51 }, 50 52 [onSelect, selectedPage, onPressSelected], ··· 66 68 const styles = isDesktop || isTablet ? desktopStyles : mobileStyles 67 69 68 70 return ( 69 - <View testID={testID} style={[pal.view, styles.outer]}> 71 + <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> 70 72 <DraggableScrollView 71 73 horizontal={true} 72 74 showsHorizontalScrollIndicator={false} ··· 118 120 119 121 const mobileStyles = StyleSheet.create({ 120 122 outer: { 121 - flex: 1, 122 123 flexDirection: 'row', 123 - backgroundColor: 'transparent', 124 - maxWidth: '100%', 125 124 }, 126 125 contentContainer: { 127 126 columnGap: isWeb ? 0 : 20,
+12 -4
src/view/com/posts/Feed.tsx
··· 29 29 feed, 30 30 style, 31 31 scrollElRef, 32 - onPressTryAgain, 33 32 onScroll, 34 33 scrollEventThrottle, 35 34 renderEmptyState, 36 35 renderEndOfFeed, 37 36 testID, 38 37 headerOffset = 0, 38 + desktopFixedHeightOffset, 39 39 ListHeaderComponent, 40 40 extraData, 41 41 }: { 42 42 feed: PostsFeedModel 43 43 style?: StyleProp<ViewStyle> 44 44 scrollElRef?: MutableRefObject<FlatList<any> | null> 45 - onPressTryAgain?: () => void 46 45 onScroll?: OnScrollCb 47 46 scrollEventThrottle?: number 48 47 renderEmptyState: () => JSX.Element 49 48 renderEndOfFeed?: () => JSX.Element 50 49 testID?: string 51 50 headerOffset?: number 51 + desktopFixedHeightOffset?: number 52 52 ListHeaderComponent?: () => JSX.Element 53 53 extraData?: any 54 54 }) { ··· 71 71 if (feed.loadMoreError) { 72 72 feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) 73 73 } 74 + } else { 75 + feedItems.push(LOADING_ITEM) 74 76 } 75 77 return feedItems 76 78 }, [ ··· 106 108 } 107 109 }, [feed, track]) 108 110 111 + const onPressTryAgain = React.useCallback(() => { 112 + feed.refresh() 113 + }, [feed]) 114 + 109 115 const onPressRetryLoadMore = React.useCallback(() => { 110 116 feed.retryLoadMore() 111 117 }, [feed]) ··· 158 164 <FlatList 159 165 testID={testID ? `${testID}-flatlist` : undefined} 160 166 ref={scrollElRef} 161 - data={!feed.hasLoaded ? [LOADING_ITEM] : data} 167 + data={data} 162 168 keyExtractor={item => item._reactKey} 163 169 renderItem={renderItem} 164 170 ListFooterComponent={FeedFooter} ··· 183 189 contentOffset={{x: 0, y: headerOffset * -1}} 184 190 extraData={extraData} 185 191 // @ts-ignore our .web version only -prf 186 - desktopFixedHeight 192 + desktopFixedHeight={ 193 + desktopFixedHeightOffset ? desktopFixedHeightOffset : true 194 + } 187 195 /> 188 196 </View> 189 197 )
+7 -4
src/view/com/profile/ProfileCard.tsx
··· 1 1 import * as React from 'react' 2 - import {StyleSheet, View} from 'react-native' 2 + import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import { 5 5 AppBskyActorDefs, ··· 29 29 noBorder, 30 30 followers, 31 31 renderButton, 32 + style, 32 33 }: { 33 34 testID?: string 34 35 profile: AppBskyActorDefs.ProfileViewBasic ··· 36 37 noBorder?: boolean 37 38 followers?: AppBskyActorDefs.ProfileView[] | undefined 38 39 renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode 40 + style?: StyleProp<ViewStyle> 39 41 }) { 40 42 const store = useStores() 41 43 const pal = usePalette('default') ··· 50 52 pal.border, 51 53 noBorder && styles.outerNoBorder, 52 54 !noBg && pal.view, 55 + style, 53 56 ]} 54 57 href={makeProfileLink(profile)} 55 58 title={profile.handle} ··· 93 96 {profile.description as string} 94 97 </Text> 95 98 </View> 96 - ) : undefined} 99 + ) : null} 97 100 <FollowersList followers={followers} /> 98 101 </Link> 99 102 ) ··· 220 223 alignItems: 'center', 221 224 }, 222 225 layoutAvi: { 226 + alignSelf: 'baseline', 223 227 width: 54, 224 228 paddingLeft: 4, 225 - paddingTop: 8, 226 - paddingBottom: 10, 229 + paddingTop: 10, 227 230 }, 228 231 avi: { 229 232 width: 40,
+43 -39
src/view/com/profile/ProfileHeader.tsx
··· 181 181 const onPressAddRemoveLists = React.useCallback(() => { 182 182 track('ProfileHeader:AddToListsButtonClicked') 183 183 store.shell.openModal({ 184 - name: 'list-add-remove-user', 184 + name: 'user-add-remove-lists', 185 185 subject: view.did, 186 186 displayName: view.displayName || view.handle, 187 187 }) ··· 276 276 }, 277 277 }, 278 278 ] 279 + items.push({label: 'separator'}) 280 + items.push({ 281 + testID: 'profileHeaderDropdownListAddRemoveBtn', 282 + label: 'Add to Lists', 283 + onPress: onPressAddRemoveLists, 284 + icon: { 285 + ios: { 286 + name: 'list.bullet', 287 + }, 288 + android: 'ic_menu_add', 289 + web: 'list', 290 + }, 291 + }) 279 292 if (!isMe) { 280 - items.push({label: 'separator'}) 281 - // Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self! 282 - items.push({ 283 - testID: 'profileHeaderDropdownListAddRemoveBtn', 284 - label: 'Add to Lists', 285 - onPress: onPressAddRemoveLists, 286 - icon: { 287 - ios: { 288 - name: 'list.bullet', 289 - }, 290 - android: 'ic_menu_add', 291 - web: 'list', 292 - }, 293 - }) 294 293 if (!view.viewer.blocking) { 295 294 items.push({ 296 295 testID: 'profileHeaderDropdownMuteBtn', ··· 307 306 }, 308 307 }) 309 308 } 310 - items.push({ 311 - testID: 'profileHeaderDropdownBlockBtn', 312 - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', 313 - onPress: view.viewer.blocking 314 - ? onPressUnblockAccount 315 - : onPressBlockAccount, 316 - icon: { 317 - ios: { 318 - name: 'person.fill.xmark', 309 + if (!view.viewer.blockingByList) { 310 + items.push({ 311 + testID: 'profileHeaderDropdownBlockBtn', 312 + label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', 313 + onPress: view.viewer.blocking 314 + ? onPressUnblockAccount 315 + : onPressBlockAccount, 316 + icon: { 317 + ios: { 318 + name: 'person.fill.xmark', 319 + }, 320 + android: 'ic_menu_close_clear_cancel', 321 + web: 'user-slash', 319 322 }, 320 - android: 'ic_menu_close_clear_cancel', 321 - web: 'user-slash', 322 - }, 323 - }) 323 + }) 324 + } 324 325 items.push({ 325 326 testID: 'profileHeaderDropdownReportBtn', 326 327 label: 'Report Account', ··· 339 340 isMe, 340 341 view.viewer.muted, 341 342 view.viewer.blocking, 343 + view.viewer.blockingByList, 342 344 onPressShare, 343 345 onPressUnmuteAccount, 344 346 onPressMuteAccount, ··· 371 373 </Text> 372 374 </TouchableOpacity> 373 375 ) : view.viewer.blocking ? ( 374 - <TouchableOpacity 375 - testID="unblockBtn" 376 - onPress={onPressUnblockAccount} 377 - style={[styles.btn, styles.mainBtn, pal.btn]} 378 - accessibilityRole="button" 379 - accessibilityLabel="Unblock" 380 - accessibilityHint=""> 381 - <Text type="button" style={[pal.text, s.bold]}> 382 - Unblock 383 - </Text> 384 - </TouchableOpacity> 376 + view.viewer.blockingByList ? null : ( 377 + <TouchableOpacity 378 + testID="unblockBtn" 379 + onPress={onPressUnblockAccount} 380 + style={[styles.btn, styles.mainBtn, pal.btn]} 381 + accessibilityRole="button" 382 + accessibilityLabel="Unblock" 383 + accessibilityHint=""> 384 + <Text type="button" style={[pal.text, s.bold]}> 385 + Unblock 386 + </Text> 387 + </TouchableOpacity> 388 + ) 385 389 ) : !view.viewer.blockedBy ? ( 386 390 <> 387 391 {!isProfilePreview && (
+194
src/view/com/profile/ProfileSubpageHeader.tsx
··· 1 + import React from 'react' 2 + import {Pressable, StyleSheet, View} from 'react-native' 3 + import {observer} from 'mobx-react-lite' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {useNavigation} from '@react-navigation/native' 6 + import {usePalette} from 'lib/hooks/usePalette' 7 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 8 + import {Text} from '../util/text/Text' 9 + import {TextLink} from '../util/Link' 10 + import {UserAvatar, UserAvatarType} from '../util/UserAvatar' 11 + import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 12 + import {CenteredView} from '../util/Views' 13 + import {sanitizeHandle} from 'lib/strings/handles' 14 + import {makeProfileLink} from 'lib/routes/links' 15 + import {useStores} from 'state/index' 16 + import {NavigationProp} from 'lib/routes/types' 17 + import {BACK_HITSLOP} from 'lib/constants' 18 + import {isNative} from 'platform/detection' 19 + import {ImagesLightbox} from 'state/models/ui/shell' 20 + 21 + export const ProfileSubpageHeader = observer(function HeaderImpl({ 22 + isLoading, 23 + href, 24 + title, 25 + avatar, 26 + isOwner, 27 + creator, 28 + avatarType, 29 + children, 30 + }: React.PropsWithChildren<{ 31 + isLoading?: boolean 32 + href: string 33 + title: string | undefined 34 + avatar: string | undefined 35 + isOwner: boolean | undefined 36 + creator: 37 + | { 38 + did: string 39 + handle: string 40 + } 41 + | undefined 42 + avatarType: UserAvatarType 43 + }>) { 44 + const store = useStores() 45 + const navigation = useNavigation<NavigationProp>() 46 + const {isMobile} = useWebMediaQueries() 47 + const pal = usePalette('default') 48 + const canGoBack = navigation.canGoBack() 49 + 50 + const onPressBack = React.useCallback(() => { 51 + if (navigation.canGoBack()) { 52 + navigation.goBack() 53 + } else { 54 + navigation.navigate('Home') 55 + } 56 + }, [navigation]) 57 + 58 + const onPressMenu = React.useCallback(() => { 59 + store.shell.openDrawer() 60 + }, [store]) 61 + 62 + const onPressAvi = React.useCallback(() => { 63 + if ( 64 + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 65 + ) { 66 + store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0)) 67 + } 68 + }, [store, avatar]) 69 + 70 + return ( 71 + <CenteredView style={pal.view}> 72 + {isMobile && ( 73 + <View 74 + style={[ 75 + { 76 + flexDirection: 'row', 77 + alignItems: 'center', 78 + borderBottomWidth: 1, 79 + paddingTop: isNative ? 0 : 8, 80 + paddingBottom: 8, 81 + paddingHorizontal: isMobile ? 12 : 14, 82 + }, 83 + pal.border, 84 + ]}> 85 + <Pressable 86 + testID="headerDrawerBtn" 87 + onPress={canGoBack ? onPressBack : onPressMenu} 88 + hitSlop={BACK_HITSLOP} 89 + style={canGoBack ? styles.backBtn : styles.backBtnWide} 90 + accessibilityRole="button" 91 + accessibilityLabel={canGoBack ? 'Back' : 'Menu'} 92 + accessibilityHint=""> 93 + {canGoBack ? ( 94 + <FontAwesomeIcon 95 + size={18} 96 + icon="angle-left" 97 + style={[styles.backIcon, pal.text]} 98 + /> 99 + ) : ( 100 + <FontAwesomeIcon 101 + size={18} 102 + icon="bars" 103 + style={[styles.backIcon, pal.textLight]} 104 + /> 105 + )} 106 + </Pressable> 107 + <View style={{flex: 1}} /> 108 + {children} 109 + </View> 110 + )} 111 + <View 112 + style={{ 113 + flexDirection: 'row', 114 + alignItems: 'flex-start', 115 + gap: 10, 116 + paddingTop: 14, 117 + paddingBottom: 6, 118 + paddingHorizontal: isMobile ? 12 : 14, 119 + }}> 120 + <Pressable 121 + testID="headerAviButton" 122 + onPress={onPressAvi} 123 + accessibilityRole="image" 124 + accessibilityLabel="View the avatar" 125 + accessibilityHint="" 126 + style={{width: 58}}> 127 + <UserAvatar type={avatarType} size={58} avatar={avatar} /> 128 + </Pressable> 129 + <View style={{flex: 1}}> 130 + {isLoading ? ( 131 + <LoadingPlaceholder 132 + width={200} 133 + height={32} 134 + style={{marginVertical: 6}} 135 + /> 136 + ) : ( 137 + <TextLink 138 + testID="headerTitle" 139 + type="title-xl" 140 + href={href} 141 + style={[pal.text, {fontWeight: 'bold'}]} 142 + text={title || ''} 143 + onPress={() => store.emitScreenSoftReset()} 144 + numberOfLines={4} 145 + /> 146 + )} 147 + 148 + {isLoading ? ( 149 + <LoadingPlaceholder width={50} height={8} /> 150 + ) : ( 151 + <Text type="xl" style={[pal.textLight]} numberOfLines={1}> 152 + by{' '} 153 + {!creator ? ( 154 + '—' 155 + ) : isOwner ? ( 156 + 'you' 157 + ) : ( 158 + <TextLink 159 + text={sanitizeHandle(creator.handle, '@')} 160 + href={makeProfileLink(creator)} 161 + style={pal.textLight} 162 + /> 163 + )} 164 + </Text> 165 + )} 166 + </View> 167 + {!isMobile && ( 168 + <View 169 + style={{ 170 + flexDirection: 'row', 171 + alignItems: 'center', 172 + }}> 173 + {children} 174 + </View> 175 + )} 176 + </View> 177 + </CenteredView> 178 + ) 179 + }) 180 + 181 + const styles = StyleSheet.create({ 182 + backBtn: { 183 + width: 20, 184 + height: 30, 185 + }, 186 + backBtnWide: { 187 + width: 20, 188 + height: 30, 189 + paddingHorizontal: 6, 190 + }, 191 + backIcon: { 192 + marginTop: 6, 193 + }, 194 + })
+6
src/view/com/testing/TestCtrls.e2e.tsx
··· 66 66 style={BTN} 67 67 /> 68 68 <Pressable 69 + testID="e2eGotoLists" 70 + onPress={() => navigate('Lists')} 71 + accessibilityRole="button" 72 + style={BTN} 73 + /> 74 + <Pressable 69 75 testID="e2eToggleMergefeed" 70 76 onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} 71 77 accessibilityRole="button"
+1 -1
src/view/com/util/AccountDropdownBtn.tsx
··· 25 25 name: 'trash', 26 26 }, 27 27 android: 'ic_delete', 28 - web: 'trash', 28 + web: ['far', 'trash-can'], 29 29 }, 30 30 }, 31 31 ]
+2 -7
src/view/com/util/LoadingPlaceholder.tsx
··· 83 83 84 84 export function PostFeedLoadingPlaceholder() { 85 85 return ( 86 - <> 87 - <PostLoadingPlaceholder /> 88 - <PostLoadingPlaceholder /> 86 + <View> 89 87 <PostLoadingPlaceholder /> 90 88 <PostLoadingPlaceholder /> 91 89 <PostLoadingPlaceholder /> 92 90 <PostLoadingPlaceholder /> 93 91 <PostLoadingPlaceholder /> 94 92 <PostLoadingPlaceholder /> 95 - <PostLoadingPlaceholder /> 96 - <PostLoadingPlaceholder /> 97 - <PostLoadingPlaceholder /> 98 - </> 93 + </View> 99 94 ) 100 95 } 101 96
+4 -4
src/view/com/util/UserAvatar.tsx
··· 17 17 import {UserPreviewLink} from './UserPreviewLink' 18 18 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' 19 19 20 - type Type = 'user' | 'algo' | 'list' 20 + export type UserAvatarType = 'user' | 'algo' | 'list' 21 21 22 22 interface BaseUserAvatarProps { 23 - type?: Type 23 + type?: UserAvatarType 24 24 size: number 25 25 avatar?: string | null 26 26 } ··· 41 41 42 42 const BLUR_AMOUNT = isWeb ? 5 : 100 43 43 44 - function DefaultAvatar({type, size}: {type: Type; size: number}) { 44 + function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) { 45 45 if (type === 'algo') { 46 46 // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. 47 47 return ( ··· 261 261 name: 'trash', 262 262 }, 263 263 android: 'ic_delete', 264 - web: 'trash', 264 + web: ['far', 'trash-can'], 265 265 }, 266 266 onPress: async () => { 267 267 onSelectNewAvatar(null)
+1 -1
src/view/com/util/UserBanner.tsx
··· 91 91 name: 'trash', 92 92 }, 93 93 android: 'ic_delete', 94 - web: 'trash', 94 + web: ['far', 'trash-can'], 95 95 }, 96 96 onPress: () => { 97 97 onSelectNewBanner?.(null)
+3 -7
src/view/com/util/ViewHeader.tsx
··· 124 124 <CenteredView 125 125 style={[ 126 126 styles.header, 127 - styles.headerFixed, 128 127 styles.desktopHeader, 129 128 pal.border, 130 129 { ··· 158 157 <View 159 158 style={[ 160 159 styles.header, 161 - styles.headerFixed, 162 160 pal.view, 163 161 pal.border, 164 162 showBorder && styles.border, ··· 190 188 paddingVertical: 6, 191 189 width: '100%', 192 190 }, 193 - headerFixed: { 194 - maxWidth: 600, 195 - marginLeft: 'auto', 196 - marginRight: 'auto', 197 - }, 198 191 headerFloating: { 199 192 position: 'absolute', 200 193 top: 0, ··· 202 195 }, 203 196 desktopHeader: { 204 197 paddingVertical: 12, 198 + maxWidth: 600, 199 + marginLeft: 'auto', 200 + marginRight: 'auto', 205 201 }, 206 202 border: { 207 203 borderBottomWidth: 1,
+1
src/view/com/util/Views.d.ts
··· 1 + export {FlatList, ScrollView, View as CenteredView} from 'react-native'
+9
src/view/com/util/Views.jsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import Animated from 'react-native-reanimated' 4 + 5 + export const FlatList = Animated.FlatList 6 + export const ScrollView = Animated.ScrollView 7 + export function CenteredView(props) { 8 + return <View {...props} /> 9 + }
-1
src/view/com/util/Views.tsx
··· 1 - export {View as CenteredView, FlatList, ScrollView} from 'react-native'
+42 -18
src/view/com/util/Views.web.tsx
··· 14 14 15 15 import React from 'react' 16 16 import { 17 - FlatList as RNFlatList, 18 17 FlatListProps, 19 - ScrollView as RNScrollView, 20 18 ScrollViewProps, 21 19 StyleSheet, 22 20 View, ··· 25 23 import {addStyle} from 'lib/styles' 26 24 import {usePalette} from 'lib/hooks/usePalette' 27 25 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 26 + import Animated from 'react-native-reanimated' 28 27 29 28 interface AddedProps { 30 - desktopFixedHeight?: boolean 29 + desktopFixedHeight?: boolean | number 31 30 } 32 31 33 32 export function CenteredView({ 34 33 style, 34 + sideBorders, 35 35 ...props 36 - }: React.PropsWithChildren<ViewProps>) { 37 - style = addStyle(style, styles.container) 36 + }: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) { 37 + const pal = usePalette('default') 38 + const {isMobile} = useWebMediaQueries() 39 + if (!isMobile) { 40 + style = addStyle(style, styles.container) 41 + } 42 + if (sideBorders) { 43 + style = addStyle(style, { 44 + borderLeftWidth: 1, 45 + borderRightWidth: 1, 46 + }) 47 + style = addStyle(style, pal.border) 48 + } 38 49 return <View style={style} {...props} /> 39 50 } 40 51 ··· 46 57 desktopFixedHeight, 47 58 ...props 48 59 }: React.PropsWithChildren<FlatListProps<ItemT> & AddedProps>, 49 - ref: React.Ref<RNFlatList>, 60 + ref: React.Ref<Animated.FlatList<ItemT>>, 50 61 ) { 51 62 const pal = usePalette('default') 52 63 const {isMobile} = useWebMediaQueries() 53 - contentContainerStyle = addStyle( 54 - contentContainerStyle, 55 - styles.containerScroll, 56 - ) 64 + if (!isMobile) { 65 + contentContainerStyle = addStyle( 66 + contentContainerStyle, 67 + styles.containerScroll, 68 + ) 69 + } 57 70 if (contentOffset && contentOffset?.y !== 0) { 58 71 // NOTE 59 72 // we use paddingTop & contentOffset to space around the floating header ··· 68 81 }) 69 82 } 70 83 if (desktopFixedHeight) { 71 - style = addStyle(style, styles.fixedHeight) 84 + if (typeof desktopFixedHeight === 'number') { 85 + // @ts-ignore Web only -prf 86 + style = addStyle(style, { 87 + height: `calc(100vh - ${desktopFixedHeight}px)`, 88 + }) 89 + } else { 90 + style = addStyle(style, styles.fixedHeight) 91 + } 72 92 if (!isMobile) { 73 93 // NOTE 74 94 // react native web produces *three* wrapping divs ··· 85 105 } 86 106 } 87 107 return ( 88 - <RNFlatList 108 + <Animated.FlatList 89 109 ref={ref} 90 110 contentContainerStyle={[ 91 111 contentContainerStyle, ··· 101 121 102 122 export const ScrollView = React.forwardRef(function ScrollViewImpl( 103 123 {contentContainerStyle, ...props}: React.PropsWithChildren<ScrollViewProps>, 104 - ref: React.Ref<RNScrollView>, 124 + ref: React.Ref<Animated.ScrollView>, 105 125 ) { 106 126 const pal = usePalette('default') 107 127 108 - contentContainerStyle = addStyle( 109 - contentContainerStyle, 110 - styles.containerScroll, 111 - ) 128 + const {isMobile} = useWebMediaQueries() 129 + if (!isMobile) { 130 + contentContainerStyle = addStyle( 131 + contentContainerStyle, 132 + styles.containerScroll, 133 + ) 134 + } 112 135 return ( 113 - <RNScrollView 136 + <Animated.ScrollView 114 137 contentContainerStyle={[ 115 138 contentContainerStyle, 116 139 pal.border, 117 140 styles.contentContainer, 118 141 ]} 142 + // @ts-ignore something is wrong with the reanimated types -prf 119 143 ref={ref} 120 144 {...props} 121 145 />
+3 -1
src/view/com/util/load-latest/LoadLatestBtn.tsx
··· 10 10 import Animated from 'react-native-reanimated' 11 11 const AnimatedTouchableOpacity = 12 12 Animated.createAnimatedComponent(TouchableOpacity) 13 + import {isWeb} from 'platform/detection' 13 14 14 15 export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ 15 16 onPress, ··· 47 48 48 49 const styles = StyleSheet.create({ 49 50 loadLatest: { 50 - position: 'absolute', 51 + // @ts-ignore 'fixed' is web only -prf 52 + position: isWeb ? 'fixed' : 'absolute', 51 53 left: 18, 52 54 bottom: 44, 53 55 borderWidth: 1,
+1 -1
src/view/com/util/moderation/PostHider.tsx
··· 74 74 accessibilityHint=""> 75 75 <ShieldExclamation size={18} style={pal.text} /> 76 76 </Pressable> 77 - <Text type="lg" style={pal.text}> 77 + <Text type="lg" style={[{flex: 1}, pal.text]} numberOfLines={1}> 78 78 {desc.name} 79 79 </Text> 80 80 {!moderation.noOverride && (
+1 -1
src/view/com/util/moderation/ProfileHeaderAlerts.tsx
··· 45 45 accessibilityHint="" 46 46 style={[styles.container, pal.viewLight, style]}> 47 47 <ShieldExclamation style={pal.text} size={24} /> 48 - <Text type="lg" style={pal.text}> 48 + <Text type="lg" style={[{flex: 1}, pal.text]}> 49 49 {desc.name} 50 50 </Text> 51 51 <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+8 -7
src/view/com/util/post-embeds/CustomFeedEmbed.tsx
··· 3 3 import {usePalette} from 'lib/hooks/usePalette' 4 4 import {StyleSheet} from 'react-native' 5 5 import {useStores} from 'state/index' 6 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 7 - import {CustomFeed} from 'view/com/feeds/CustomFeed' 6 + import {FeedSourceModel} from 'state/models/content/feed-source' 7 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 8 8 9 9 export function CustomFeedEmbed({ 10 10 record, ··· 13 13 }) { 14 14 const pal = usePalette('default') 15 15 const store = useStores() 16 - const item = useMemo( 17 - () => new CustomFeedModel(store, record), 18 - [store, record], 19 - ) 16 + const item = useMemo(() => { 17 + const model = new FeedSourceModel(store, record.uri) 18 + model.hydrateFeedGenerator(record) 19 + return model 20 + }, [store, record]) 20 21 return ( 21 - <CustomFeed 22 + <FeedSourceCard 22 23 item={item} 23 24 style={[pal.view, pal.border, styles.customFeedOuter]} 24 25 showLikes
+1 -1
src/view/com/util/post-embeds/index.tsx
··· 75 75 return <CustomFeedEmbed record={embed.record} /> 76 76 } 77 77 78 - // list embed (e.g. mute lists; i.e. ListView) 78 + // list embed 79 79 if (AppBskyGraphDefs.isListView(embed.record)) { 80 80 return <ListEmbed item={embed.record} /> 81 81 }
-495
src/view/screens/CustomFeed.tsx
··· 1 - import React, {useMemo, useRef} from 'react' 2 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {useNavigation, useIsFocused} from '@react-navigation/native' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {HeartIcon, HeartIconSolid} from 'lib/icons' 7 - import {CommonNavigatorParams} from 'lib/routes/types' 8 - import {makeRecordUri} from 'lib/strings/url-helpers' 9 - import {colors, s} from 'lib/styles' 10 - import {observer} from 'mobx-react-lite' 11 - import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' 12 - import {useStores} from 'state/index' 13 - import {PostsFeedModel} from 'state/models/feeds/posts' 14 - import {useCustomFeed} from 'lib/hooks/useCustomFeed' 15 - import {withAuthRequired} from 'view/com/auth/withAuthRequired' 16 - import {Feed} from 'view/com/posts/Feed' 17 - import {TextLink} from 'view/com/util/Link' 18 - import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 19 - import {Button} from 'view/com/util/forms/Button' 20 - import {Text} from 'view/com/util/text/Text' 21 - import * as Toast from 'view/com/util/Toast' 22 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 23 - import {useSetTitle} from 'lib/hooks/useSetTitle' 24 - import {shareUrl} from 'lib/sharing' 25 - import {toShareUrl} from 'lib/strings/url-helpers' 26 - import {Haptics} from 'lib/haptics' 27 - import {ComposeIcon2} from 'lib/icons' 28 - import {FAB} from '../com/util/fab/FAB' 29 - import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 30 - import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 31 - import {EmptyState} from 'view/com/util/EmptyState' 32 - import {useAnalytics} from 'lib/analytics/analytics' 33 - import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' 34 - import {resolveName} from 'lib/api' 35 - import {CenteredView} from 'view/com/util/Views' 36 - import {NavigationProp} from 'lib/routes/types' 37 - 38 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> 39 - 40 - export const CustomFeedScreen = withAuthRequired( 41 - observer(function CustomFeedScreenImpl(props: Props) { 42 - const pal = usePalette('default') 43 - const store = useStores() 44 - const navigation = useNavigation<NavigationProp>() 45 - 46 - const {name: handleOrDid} = props.route.params 47 - 48 - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() 49 - const [error, setError] = React.useState<string | undefined>() 50 - 51 - const onPressBack = React.useCallback(() => { 52 - if (navigation.canGoBack()) { 53 - navigation.goBack() 54 - } else { 55 - navigation.navigate('Home') 56 - } 57 - }, [navigation]) 58 - 59 - React.useEffect(() => { 60 - /* 61 - * We must resolve the DID of the feed owner before we can fetch the feed. 62 - */ 63 - async function fetchDid() { 64 - try { 65 - const did = await resolveName(store, handleOrDid) 66 - setFeedOwnerDid(did) 67 - } catch (e) { 68 - setError( 69 - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, 70 - ) 71 - } 72 - } 73 - 74 - fetchDid() 75 - }, [store, handleOrDid, setFeedOwnerDid]) 76 - 77 - if (error) { 78 - return ( 79 - <CenteredView> 80 - <View style={[pal.view, pal.border, styles.notFoundContainer]}> 81 - <Text type="title-lg" style={[pal.text, s.mb10]}> 82 - Could not load feed 83 - </Text> 84 - <Text type="md" style={[pal.text, s.mb20]}> 85 - {error} 86 - </Text> 87 - 88 - <View style={{flexDirection: 'row'}}> 89 - <Button 90 - type="default" 91 - accessibilityLabel="Go Back" 92 - accessibilityHint="Return to previous page" 93 - onPress={onPressBack} 94 - style={{flexShrink: 1}}> 95 - <Text type="button" style={pal.text}> 96 - Go Back 97 - </Text> 98 - </Button> 99 - </View> 100 - </View> 101 - </CenteredView> 102 - ) 103 - } 104 - 105 - return feedOwnerDid ? ( 106 - <CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> 107 - ) : ( 108 - <CenteredView> 109 - <View style={s.p20}> 110 - <ActivityIndicator size="large" /> 111 - </View> 112 - </CenteredView> 113 - ) 114 - }), 115 - ) 116 - 117 - export const CustomFeedScreenInner = observer( 118 - function CustomFeedScreenInnerImpl({ 119 - route, 120 - feedOwnerDid, 121 - }: Props & {feedOwnerDid: string}) { 122 - const store = useStores() 123 - const pal = usePalette('default') 124 - const palInverted = usePalette('inverted') 125 - const navigation = useNavigation<NavigationProp>() 126 - const isScreenFocused = useIsFocused() 127 - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() 128 - const {track} = useAnalytics() 129 - const {rkey, name: handleOrDid} = route.params 130 - const uri = useMemo( 131 - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), 132 - [rkey, feedOwnerDid], 133 - ) 134 - const scrollElRef = useRef<FlatList>(null) 135 - const currentFeed = useCustomFeed(uri) 136 - const algoFeed: PostsFeedModel = useMemo(() => { 137 - const feed = new PostsFeedModel(store, 'custom', { 138 - feed: uri, 139 - }) 140 - feed.setup() 141 - return feed 142 - }, [store, uri]) 143 - const isPinned = store.me.savedFeeds.isPinned(uri) 144 - const [onMainScroll, isScrolledDown, resetMainScroll] = 145 - useOnMainScroll(store) 146 - useSetTitle(currentFeed?.displayName) 147 - 148 - const onToggleSaved = React.useCallback(async () => { 149 - try { 150 - Haptics.default() 151 - if (currentFeed?.isSaved) { 152 - await currentFeed?.unsave() 153 - } else { 154 - await currentFeed?.save() 155 - } 156 - } catch (err) { 157 - Toast.show( 158 - 'There was an an issue updating your feeds, please check your internet connection and try again.', 159 - ) 160 - store.log.error('Failed up update feeds', {err}) 161 - } 162 - }, [store, currentFeed]) 163 - 164 - const onToggleLiked = React.useCallback(async () => { 165 - Haptics.default() 166 - try { 167 - if (currentFeed?.isLiked) { 168 - await currentFeed?.unlike() 169 - } else { 170 - await currentFeed?.like() 171 - } 172 - } catch (err) { 173 - Toast.show( 174 - 'There was an an issue contacting the server, please check your internet connection and try again.', 175 - ) 176 - store.log.error('Failed up toggle like', {err}) 177 - } 178 - }, [store, currentFeed]) 179 - 180 - const onTogglePinned = React.useCallback(async () => { 181 - Haptics.default() 182 - store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { 183 - Toast.show('There was an issue contacting the server') 184 - store.log.error('Failed to toggle pinned feed', {e}) 185 - }) 186 - }, [store, currentFeed]) 187 - 188 - const onPressAbout = React.useCallback(() => { 189 - store.shell.openModal({ 190 - name: 'confirm', 191 - title: currentFeed?.displayName || '', 192 - message: 193 - currentFeed?.data.description || 'This feed has no description.', 194 - confirmBtnText: 'Close', 195 - onPressConfirm() {}, 196 - }) 197 - }, [store, currentFeed]) 198 - 199 - const onPressViewAuthor = React.useCallback(() => { 200 - navigation.navigate('Profile', {name: handleOrDid}) 201 - }, [handleOrDid, navigation]) 202 - 203 - const onPressShare = React.useCallback(() => { 204 - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) 205 - shareUrl(url) 206 - track('CustomFeed:Share') 207 - }, [handleOrDid, rkey, track]) 208 - 209 - const onPressReport = React.useCallback(() => { 210 - if (!currentFeed) return 211 - store.shell.openModal({ 212 - name: 'report', 213 - uri: currentFeed.uri, 214 - cid: currentFeed.data.cid, 215 - }) 216 - }, [store, currentFeed]) 217 - 218 - const onScrollToTop = React.useCallback(() => { 219 - scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) 220 - resetMainScroll() 221 - }, [scrollElRef, resetMainScroll]) 222 - 223 - const onPressCompose = React.useCallback(() => { 224 - store.shell.openComposer({}) 225 - }, [store]) 226 - 227 - const onSoftReset = React.useCallback(() => { 228 - if (isScreenFocused) { 229 - onScrollToTop() 230 - algoFeed.refresh() 231 - } 232 - }, [isScreenFocused, onScrollToTop, algoFeed]) 233 - 234 - // fires when page within screen is activated/deactivated 235 - React.useEffect(() => { 236 - if (!isScreenFocused) { 237 - return 238 - } 239 - 240 - const softResetSub = store.onScreenSoftReset(onSoftReset) 241 - return () => { 242 - softResetSub.remove() 243 - } 244 - }, [store, onSoftReset, isScreenFocused]) 245 - 246 - const dropdownItems: DropdownItem[] = React.useMemo(() => { 247 - return [ 248 - currentFeed 249 - ? { 250 - testID: 'feedHeaderDropdownAboutBtn', 251 - label: 'About this feed', 252 - onPress: onPressAbout, 253 - icon: { 254 - ios: { 255 - name: 'info.circle', 256 - }, 257 - android: '', 258 - web: 'info', 259 - }, 260 - } 261 - : undefined, 262 - { 263 - testID: 'feedHeaderDropdownViewAuthorBtn', 264 - label: 'View author', 265 - onPress: onPressViewAuthor, 266 - icon: { 267 - ios: { 268 - name: 'person', 269 - }, 270 - android: '', 271 - web: ['far', 'user'], 272 - }, 273 - }, 274 - { 275 - testID: 'feedHeaderDropdownToggleSavedBtn', 276 - label: currentFeed?.isSaved 277 - ? 'Remove from my feeds' 278 - : 'Add to my feeds', 279 - onPress: onToggleSaved, 280 - icon: currentFeed?.isSaved 281 - ? { 282 - ios: { 283 - name: 'trash', 284 - }, 285 - android: 'ic_delete', 286 - web: 'trash', 287 - } 288 - : { 289 - ios: { 290 - name: 'plus', 291 - }, 292 - android: '', 293 - web: 'plus', 294 - }, 295 - }, 296 - { 297 - testID: 'feedHeaderDropdownReportBtn', 298 - label: 'Report feed', 299 - onPress: onPressReport, 300 - icon: { 301 - ios: { 302 - name: 'exclamationmark.triangle', 303 - }, 304 - android: 'ic_menu_report_image', 305 - web: 'circle-exclamation', 306 - }, 307 - }, 308 - { 309 - testID: 'feedHeaderDropdownShareBtn', 310 - label: 'Share link', 311 - onPress: onPressShare, 312 - icon: { 313 - ios: { 314 - name: 'square.and.arrow.up', 315 - }, 316 - android: 'ic_menu_share', 317 - web: 'share', 318 - }, 319 - }, 320 - ].filter(Boolean) as DropdownItem[] 321 - }, [ 322 - currentFeed, 323 - onPressAbout, 324 - onToggleSaved, 325 - onPressReport, 326 - onPressShare, 327 - onPressViewAuthor, 328 - ]) 329 - 330 - const renderEmptyState = React.useCallback(() => { 331 - return ( 332 - <View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}> 333 - <EmptyState icon="feed" message="This feed is empty!" /> 334 - </View> 335 - ) 336 - }, [pal.border]) 337 - 338 - return ( 339 - <View style={s.hContentRegion}> 340 - <SimpleViewHeader 341 - showBackButton={isMobile} 342 - style={ 343 - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] 344 - }> 345 - <Text type="title-lg" style={styles.headerText} numberOfLines={1}> 346 - {currentFeed ? ( 347 - <TextLink 348 - type="title-lg" 349 - href="/" 350 - style={[pal.text, {fontWeight: 'bold'}]} 351 - text={currentFeed?.displayName || ''} 352 - onPress={() => store.emitScreenSoftReset()} 353 - /> 354 - ) : ( 355 - 'Loading...' 356 - )} 357 - </Text> 358 - {currentFeed ? ( 359 - <> 360 - <Button 361 - type="default-light" 362 - testID="toggleLikeBtn" 363 - accessibilityLabel="Like this feed" 364 - accessibilityHint="" 365 - onPress={onToggleLiked} 366 - style={styles.headerBtn}> 367 - {currentFeed?.isLiked ? ( 368 - <HeartIconSolid size={19} style={styles.liked} /> 369 - ) : ( 370 - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> 371 - )} 372 - </Button> 373 - {currentFeed?.isSaved ? ( 374 - <Button 375 - type="default-light" 376 - accessibilityLabel={ 377 - isPinned ? 'Unpin this feed' : 'Pin this feed' 378 - } 379 - accessibilityHint="" 380 - onPress={onTogglePinned} 381 - style={styles.headerBtn}> 382 - <FontAwesomeIcon 383 - icon="thumb-tack" 384 - size={17} 385 - color={isPinned ? colors.blue3 : pal.colors.textLight} 386 - style={styles.top1} 387 - /> 388 - </Button> 389 - ) : ( 390 - <Button 391 - type="inverted" 392 - onPress={onToggleSaved} 393 - accessibilityLabel="Add to my feeds" 394 - accessibilityHint="" 395 - style={styles.headerAddBtn}> 396 - <FontAwesomeIcon 397 - icon="plus" 398 - color={palInverted.colors.text} 399 - size={19} 400 - /> 401 - <Text type="button" style={palInverted.text}> 402 - Add{!isMobile && ' to My Feeds'} 403 - </Text> 404 - </Button> 405 - )} 406 - </> 407 - ) : null} 408 - <NativeDropdown 409 - testID="feedHeaderDropdownBtn" 410 - items={dropdownItems} 411 - accessibilityLabel="More options" 412 - accessibilityHint=""> 413 - <View 414 - style={{ 415 - paddingLeft: 12, 416 - paddingRight: isMobile ? 12 : 0, 417 - }}> 418 - <FontAwesomeIcon 419 - icon="ellipsis" 420 - size={20} 421 - color={pal.colors.textLight} 422 - /> 423 - </View> 424 - </NativeDropdown> 425 - </SimpleViewHeader> 426 - <Feed 427 - scrollElRef={scrollElRef} 428 - feed={algoFeed} 429 - onScroll={onMainScroll} 430 - scrollEventThrottle={100} 431 - renderEmptyState={renderEmptyState} 432 - extraData={[uri, isPinned]} 433 - style={!isTabletOrDesktop ? {flex: 1} : undefined} 434 - /> 435 - {isScrolledDown ? ( 436 - <LoadLatestBtn 437 - onPress={onSoftReset} 438 - label="Scroll to top" 439 - showIndicator={false} 440 - /> 441 - ) : null} 442 - <FAB 443 - testID="composeFAB" 444 - onPress={onPressCompose} 445 - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 446 - accessibilityRole="button" 447 - accessibilityLabel="New post" 448 - accessibilityHint="" 449 - /> 450 - </View> 451 - ) 452 - }, 453 - ) 454 - 455 - const styles = StyleSheet.create({ 456 - header: { 457 - flexDirection: 'row', 458 - gap: 12, 459 - paddingHorizontal: 16, 460 - paddingTop: 12, 461 - paddingBottom: 16, 462 - borderTopWidth: 1, 463 - }, 464 - headerText: { 465 - flex: 1, 466 - fontWeight: 'bold', 467 - }, 468 - headerBtn: { 469 - paddingVertical: 0, 470 - }, 471 - headerAddBtn: { 472 - flexDirection: 'row', 473 - alignItems: 'center', 474 - gap: 4, 475 - paddingVertical: 4, 476 - paddingLeft: 10, 477 - }, 478 - liked: { 479 - color: colors.red3, 480 - }, 481 - top1: { 482 - position: 'relative', 483 - top: 1, 484 - }, 485 - top2: { 486 - position: 'relative', 487 - top: 2, 488 - }, 489 - notFoundContainer: { 490 - margin: 10, 491 - paddingHorizontal: 18, 492 - paddingVertical: 14, 493 - borderRadius: 6, 494 - }, 495 - })
+2 -2
src/view/screens/CustomFeedLikedBy.tsx src/view/screens/ProfileFeedLikedBy.tsx
··· 8 8 import {useStores} from 'state/index' 9 9 import {makeRecordUri} from 'lib/strings/url-helpers' 10 10 11 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'> 12 - export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { 11 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> 12 + export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { 13 13 const store = useStores() 14 14 const {name, rkey} = route.params 15 15 const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
+42 -28
src/view/screens/Feeds.tsx
··· 2 2 import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 4 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 5 - import {AtUri} from '@atproto/api' 6 5 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 7 6 import {ViewHeader} from 'view/com/util/ViewHeader' 8 7 import {FAB} from 'view/com/util/fab/FAB' ··· 24 23 import debounce from 'lodash.debounce' 25 24 import {Text} from 'view/com/util/text/Text' 26 25 import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' 26 + import {FeedSourceModel} from 'state/models/content/feed-source' 27 27 import {FlatList} from 'view/com/util/Views' 28 28 import {useFocusEffect} from '@react-navigation/native' 29 - import {CustomFeed} from 'view/com/feeds/CustomFeed' 29 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 30 30 31 31 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> 32 32 export const FeedsScreen = withAuthRequired( ··· 52 52 } 53 53 }, [store, myFeeds]), 54 54 ) 55 + React.useEffect(() => { 56 + // watch for changes to saved/pinned feeds 57 + return myFeeds.registerListeners() 58 + }, [myFeeds]) 55 59 56 60 const onPressCompose = React.useCallback(() => { 57 61 store.shell.openComposer({}) ··· 139 143 </> 140 144 ) 141 145 } else if (item.type === 'saved-feed') { 142 - return ( 143 - <SavedFeed 144 - uri={item.feed.uri} 145 - avatar={item.feed.data.avatar} 146 - displayName={item.feed.displayName} 147 - /> 148 - ) 146 + return <SavedFeed feed={item.feed} /> 149 147 } else if (item.type === 'discover-feeds-header') { 150 148 return ( 151 149 <> ··· 187 185 ) 188 186 } else if (item.type === 'discover-feed') { 189 187 return ( 190 - <CustomFeed 188 + <FeedSourceCard 191 189 item={item.feed} 192 190 showSaveBtn 193 191 showDescription ··· 257 255 }), 258 256 ) 259 257 260 - function SavedFeed({ 261 - uri, 262 - avatar, 263 - displayName, 264 - }: { 265 - uri: string 266 - avatar: string | undefined 267 - displayName: string 268 - }) { 258 + function SavedFeed({feed}: {feed: FeedSourceModel}) { 269 259 const pal = usePalette('default') 270 - const urip = new AtUri(uri) 271 - const href = `/profile/${urip.hostname}/feed/${urip.rkey}` 272 260 const {isMobile} = useWebMediaQueries() 273 261 return ( 274 262 <Link 275 - testID={`saved-feed-${displayName}`} 276 - href={href} 263 + testID={`saved-feed-${feed.displayName}`} 264 + href={feed.href} 277 265 style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} 278 266 hoverStyle={pal.viewLight} 279 - accessibilityLabel={displayName} 267 + accessibilityLabel={feed.displayName} 280 268 accessibilityHint="" 281 269 asAnchor 282 270 anchorNoUnderline> 283 - <UserAvatar type="algo" size={28} avatar={avatar} /> 284 - <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}> 285 - {displayName} 286 - </Text> 271 + {feed.error ? ( 272 + <View 273 + style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> 274 + <FontAwesomeIcon 275 + icon="exclamation-circle" 276 + color={pal.colors.textLight} 277 + /> 278 + </View> 279 + ) : ( 280 + <UserAvatar type="algo" size={28} avatar={feed.avatar} /> 281 + )} 282 + <View 283 + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 284 + <Text type="lg-medium" style={pal.text} numberOfLines={1}> 285 + {feed.displayName} 286 + </Text> 287 + {feed.error && ( 288 + <View style={[styles.offlineSlug, pal.borderDark]}> 289 + <Text type="xs" style={pal.textLight}> 290 + Feed offline 291 + </Text> 292 + </View> 293 + )} 294 + </View> 287 295 {isMobile && ( 288 296 <FontAwesomeIcon 289 297 icon="chevron-right" ··· 341 349 }, 342 350 savedFeedMobile: { 343 351 paddingVertical: 10, 352 + }, 353 + offlineSlug: { 354 + borderWidth: 1, 355 + borderRadius: 4, 356 + paddingHorizontal: 4, 357 + paddingVertical: 2, 344 358 }, 345 359 })
+13 -14
src/view/screens/Home.tsx
··· 1 1 import React from 'react' 2 2 import {useWindowDimensions} from 'react-native' 3 3 import {useFocusEffect} from '@react-navigation/native' 4 - import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' 5 4 import {observer} from 'mobx-react-lite' 6 5 import isEqual from 'lodash.isequal' 7 6 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' ··· 30 29 >([]) 31 30 32 31 React.useEffect(() => { 33 - const {pinned} = store.me.savedFeeds 32 + const pinned = store.preferences.pinnedFeeds 34 33 35 - if ( 36 - isEqual( 37 - pinned.map(p => p.uri), 38 - requestedCustomFeeds, 39 - ) 40 - ) { 34 + if (isEqual(pinned, requestedCustomFeeds)) { 41 35 // no changes 42 36 return 43 37 } 44 38 45 39 const feeds = [] 46 - for (const feed of pinned) { 47 - const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) 48 - feeds.push(model) 40 + for (const uri of pinned) { 41 + if (uri.includes('app.bsky.feed.generator')) { 42 + const model = new PostsFeedModel(store, 'custom', {feed: uri}) 43 + feeds.push(model) 44 + } else if (uri.includes('app.bsky.graph.list')) { 45 + const model = new PostsFeedModel(store, 'list', {list: uri}) 46 + feeds.push(model) 47 + } 49 48 } 50 49 pagerRef.current?.setPage(0) 51 50 setCustomFeeds(feeds) 52 - setRequestedCustomFeeds(pinned.map(p => p.uri)) 51 + setRequestedCustomFeeds(pinned) 53 52 }, [ 54 53 store, 55 - store.me.savedFeeds.pinned, 54 + store.preferences.pinnedFeeds, 56 55 customFeeds, 57 56 setCustomFeeds, 58 57 pagerRef, ··· 124 123 {customFeeds.map((f, index) => { 125 124 return ( 126 125 <FeedPage 127 - key={(f.params as GetCustomFeed.QueryParams).feed} 126 + key={f.reactKey} 128 127 testID="customFeedPage" 129 128 isPageFocused={selectedPage === 1 + index} 130 129 feed={f}
+92
src/view/screens/Lists.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {AtUri} from '@atproto/api' 6 + import {observer} from 'mobx-react-lite' 7 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 8 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 9 + import {useStores} from 'state/index' 10 + import {ListsListModel} from 'state/models/lists/lists-list' 11 + import {ListsList} from 'view/com/lists/ListsList' 12 + import {Text} from 'view/com/util/text/Text' 13 + import {Button} from 'view/com/util/forms/Button' 14 + import {NavigationProp} from 'lib/routes/types' 15 + import {usePalette} from 'lib/hooks/usePalette' 16 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 17 + import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 18 + import {s} from 'lib/styles' 19 + 20 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> 21 + export const ListsScreen = withAuthRequired( 22 + observer(function ListsScreenImpl({}: Props) { 23 + const pal = usePalette('default') 24 + const store = useStores() 25 + const {isMobile} = useWebMediaQueries() 26 + const navigation = useNavigation<NavigationProp>() 27 + 28 + const listsLists: ListsListModel = React.useMemo( 29 + () => new ListsListModel(store, 'my-curatelists'), 30 + [store], 31 + ) 32 + 33 + useFocusEffect( 34 + React.useCallback(() => { 35 + store.shell.setMinimalShellMode(false) 36 + listsLists.refresh() 37 + }, [store, listsLists]), 38 + ) 39 + 40 + const onPressNewList = React.useCallback(() => { 41 + store.shell.openModal({ 42 + name: 'create-or-edit-list', 43 + purpose: 'app.bsky.graph.defs#curatelist', 44 + onSave: (uri: string) => { 45 + try { 46 + const urip = new AtUri(uri) 47 + navigation.navigate('ProfileList', { 48 + name: urip.hostname, 49 + rkey: urip.rkey, 50 + }) 51 + } catch {} 52 + }, 53 + }) 54 + }, [store, navigation]) 55 + 56 + return ( 57 + <View style={s.hContentRegion} testID="listsScreen"> 58 + <SimpleViewHeader 59 + showBackButton={isMobile} 60 + style={ 61 + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] 62 + }> 63 + <View style={{flex: 1}}> 64 + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> 65 + User Lists 66 + </Text> 67 + <Text style={pal.textLight}> 68 + Public, shareable lists which can drive feeds. 69 + </Text> 70 + </View> 71 + <View> 72 + <Button 73 + testID="newUserListBtn" 74 + type="default" 75 + onPress={onPressNewList} 76 + style={{ 77 + flexDirection: 'row', 78 + alignItems: 'center', 79 + gap: 8, 80 + }}> 81 + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> 82 + <Text type="button" style={pal.text}> 83 + New 84 + </Text> 85 + </Button> 86 + </View> 87 + </SimpleViewHeader> 88 + <ListsList listsList={listsLists} /> 89 + </View> 90 + ) 91 + }), 92 + )
+3 -3
src/view/screens/Moderation.tsx
··· 66 66 </Text> 67 67 </TouchableOpacity> 68 68 <Link 69 - testID="mutelistsBtn" 69 + testID="moderationlistsBtn" 70 70 style={[styles.linkCard, pal.view]} 71 - href="/moderation/mute-lists"> 71 + href="/moderation/modlists"> 72 72 <View style={[styles.iconContainer, pal.btn]}> 73 73 <FontAwesomeIcon 74 74 icon="users-slash" ··· 76 76 /> 77 77 </View> 78 78 <Text type="lg" style={pal.text}> 79 - Mute lists 79 + Moderation lists 80 80 </Text> 81 81 </Link> 82 82 <Link
+92
src/view/screens/ModerationModlists.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {AtUri} from '@atproto/api' 6 + import {observer} from 'mobx-react-lite' 7 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 8 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 9 + import {useStores} from 'state/index' 10 + import {ListsListModel} from 'state/models/lists/lists-list' 11 + import {ListsList} from 'view/com/lists/ListsList' 12 + import {Text} from 'view/com/util/text/Text' 13 + import {Button} from 'view/com/util/forms/Button' 14 + import {NavigationProp} from 'lib/routes/types' 15 + import {usePalette} from 'lib/hooks/usePalette' 16 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 17 + import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 18 + import {s} from 'lib/styles' 19 + 20 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> 21 + export const ModerationModlistsScreen = withAuthRequired( 22 + observer(function ModerationModlistsScreenImpl({}: Props) { 23 + const pal = usePalette('default') 24 + const store = useStores() 25 + const {isMobile} = useWebMediaQueries() 26 + const navigation = useNavigation<NavigationProp>() 27 + 28 + const mutelists: ListsListModel = React.useMemo( 29 + () => new ListsListModel(store, 'my-modlists'), 30 + [store], 31 + ) 32 + 33 + useFocusEffect( 34 + React.useCallback(() => { 35 + store.shell.setMinimalShellMode(false) 36 + mutelists.refresh() 37 + }, [store, mutelists]), 38 + ) 39 + 40 + const onPressNewList = React.useCallback(() => { 41 + store.shell.openModal({ 42 + name: 'create-or-edit-list', 43 + purpose: 'app.bsky.graph.defs#modlist', 44 + onSave: (uri: string) => { 45 + try { 46 + const urip = new AtUri(uri) 47 + navigation.navigate('ProfileList', { 48 + name: urip.hostname, 49 + rkey: urip.rkey, 50 + }) 51 + } catch {} 52 + }, 53 + }) 54 + }, [store, navigation]) 55 + 56 + return ( 57 + <View style={s.hContentRegion} testID="moderationModlistsScreen"> 58 + <SimpleViewHeader 59 + showBackButton={isMobile} 60 + style={ 61 + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] 62 + }> 63 + <View style={{flex: 1}}> 64 + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> 65 + Moderation Lists 66 + </Text> 67 + <Text style={pal.textLight}> 68 + Public, shareable lists of users to mute or block in bulk. 69 + </Text> 70 + </View> 71 + <View> 72 + <Button 73 + testID="newModListBtn" 74 + type="default" 75 + onPress={onPressNewList} 76 + style={{ 77 + flexDirection: 'row', 78 + alignItems: 'center', 79 + gap: 8, 80 + }}> 81 + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> 82 + <Text type="button" style={pal.text}> 83 + New 84 + </Text> 85 + </Button> 86 + </View> 87 + </SimpleViewHeader> 88 + <ListsList listsList={mutelists} /> 89 + </View> 90 + ) 91 + }), 92 + )
-124
src/view/screens/ModerationMuteLists.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet} from 'react-native' 3 - import {useFocusEffect, useNavigation} from '@react-navigation/native' 4 - import { 5 - FontAwesomeIcon, 6 - FontAwesomeIconStyle, 7 - } from '@fortawesome/react-native-fontawesome' 8 - import {AtUri} from '@atproto/api' 9 - import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 10 - import {withAuthRequired} from 'view/com/auth/withAuthRequired' 11 - import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' 12 - import {useStores} from 'state/index' 13 - import {ListsListModel} from 'state/models/lists/lists-list' 14 - import {ListsList} from 'view/com/lists/ListsList' 15 - import {Button} from 'view/com/util/forms/Button' 16 - import {NavigationProp} from 'lib/routes/types' 17 - import {usePalette} from 'lib/hooks/usePalette' 18 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 19 - import {CenteredView} from 'view/com/util/Views' 20 - import {ViewHeader} from 'view/com/util/ViewHeader' 21 - 22 - type Props = NativeStackScreenProps< 23 - CommonNavigatorParams, 24 - 'ModerationMuteLists' 25 - > 26 - export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { 27 - const pal = usePalette('default') 28 - const store = useStores() 29 - const {isTabletOrDesktop} = useWebMediaQueries() 30 - const navigation = useNavigation<NavigationProp>() 31 - 32 - const mutelists: ListsListModel = React.useMemo( 33 - () => new ListsListModel(store, 'my-modlists'), 34 - [store], 35 - ) 36 - 37 - useFocusEffect( 38 - React.useCallback(() => { 39 - store.shell.setMinimalShellMode(false) 40 - mutelists.refresh() 41 - }, [store, mutelists]), 42 - ) 43 - 44 - const onPressNewMuteList = React.useCallback(() => { 45 - store.shell.openModal({ 46 - name: 'create-or-edit-mute-list', 47 - onSave: (uri: string) => { 48 - try { 49 - const urip = new AtUri(uri) 50 - navigation.navigate('ProfileList', { 51 - name: urip.hostname, 52 - rkey: urip.rkey, 53 - }) 54 - } catch {} 55 - }, 56 - }) 57 - }, [store, navigation]) 58 - 59 - const renderEmptyState = React.useCallback(() => { 60 - return ( 61 - <EmptyStateWithButton 62 - testID="emptyMuteLists" 63 - icon="users-slash" 64 - 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." 65 - buttonLabel="New Mute List" 66 - onPress={onPressNewMuteList} 67 - /> 68 - ) 69 - }, [onPressNewMuteList]) 70 - 71 - const renderHeaderButton = React.useCallback( 72 - () => ( 73 - <Button 74 - type="primary-light" 75 - onPress={onPressNewMuteList} 76 - style={styles.createBtn}> 77 - <FontAwesomeIcon 78 - icon="plus" 79 - style={pal.link as FontAwesomeIconStyle} 80 - size={18} 81 - /> 82 - </Button> 83 - ), 84 - [onPressNewMuteList, pal], 85 - ) 86 - 87 - return ( 88 - <CenteredView 89 - style={[ 90 - styles.container, 91 - pal.view, 92 - pal.border, 93 - isTabletOrDesktop && styles.containerDesktop, 94 - ]} 95 - testID="moderationMutelistsScreen"> 96 - <ViewHeader 97 - title="Mute Lists" 98 - showOnDesktop 99 - renderButton={renderHeaderButton} 100 - /> 101 - <ListsList 102 - listsList={mutelists} 103 - showAddBtns={isTabletOrDesktop} 104 - renderEmptyState={renderEmptyState} 105 - onPressCreateNew={onPressNewMuteList} 106 - /> 107 - </CenteredView> 108 - ) 109 - }) 110 - 111 - const styles = StyleSheet.create({ 112 - container: { 113 - flex: 1, 114 - paddingBottom: 100, 115 - }, 116 - containerDesktop: { 117 - borderLeftWidth: 1, 118 - borderRightWidth: 1, 119 - paddingBottom: 0, 120 - }, 121 - createBtn: { 122 - width: 40, 123 - }, 124 - })
+9 -4
src/view/screens/Profile.tsx
··· 25 25 import {s, colors} from 'lib/styles' 26 26 import {useAnalytics} from 'lib/analytics/analytics' 27 27 import {ComposeIcon2} from 'lib/icons' 28 - import {CustomFeed} from 'view/com/feeds/CustomFeed' 29 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 28 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 29 + import {FeedSourceModel} from 'state/models/content/feed-source' 30 30 import {useSetTitle} from 'lib/hooks/useSetTitle' 31 31 import {combinedDisplayName} from 'lib/strings/display-names' 32 32 ··· 189 189 style={styles.emptyState} 190 190 /> 191 191 ) 192 - } else if (item instanceof CustomFeedModel) { 192 + } else if (item instanceof FeedSourceModel) { 193 193 return ( 194 - <CustomFeed item={item} showSaveBtn showLikes showDescription /> 194 + <FeedSourceCard 195 + item={item} 196 + showSaveBtn 197 + showLikes 198 + showDescription 199 + /> 195 200 ) 196 201 } 197 202 // if section is posts or posts & replies
+535
src/view/screens/ProfileFeed.tsx
··· 1 + import React, {useMemo, useCallback} from 'react' 2 + import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' 3 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 4 + import {useNavigation} from '@react-navigation/native' 5 + import {usePalette} from 'lib/hooks/usePalette' 6 + import {HeartIcon, HeartIconSolid} from 'lib/icons' 7 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 8 + import {CommonNavigatorParams} from 'lib/routes/types' 9 + import {makeRecordUri} from 'lib/strings/url-helpers' 10 + import {colors, s} from 'lib/styles' 11 + import {observer} from 'mobx-react-lite' 12 + import {useStores} from 'state/index' 13 + import {FeedSourceModel} from 'state/models/content/feed-source' 14 + import {PostsFeedModel} from 'state/models/feeds/posts' 15 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 16 + import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 17 + import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' 18 + import {Feed} from 'view/com/posts/Feed' 19 + import {TextLink} from 'view/com/util/Link' 20 + import {Button} from 'view/com/util/forms/Button' 21 + import {Text} from 'view/com/util/text/Text' 22 + import {RichText} from 'view/com/util/text/RichText' 23 + import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 24 + import {FAB} from 'view/com/util/fab/FAB' 25 + import {EmptyState} from 'view/com/util/EmptyState' 26 + import * as Toast from 'view/com/util/Toast' 27 + import {useSetTitle} from 'lib/hooks/useSetTitle' 28 + import {useCustomFeed} from 'lib/hooks/useCustomFeed' 29 + import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 30 + import {shareUrl} from 'lib/sharing' 31 + import {toShareUrl} from 'lib/strings/url-helpers' 32 + import {Haptics} from 'lib/haptics' 33 + import {useAnalytics} from 'lib/analytics/analytics' 34 + import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' 35 + import {resolveName} from 'lib/api' 36 + import {makeCustomFeedLink} from 'lib/routes/links' 37 + import {pluralize} from 'lib/strings/helpers' 38 + import {CenteredView, ScrollView} from 'view/com/util/Views' 39 + import {NavigationProp} from 'lib/routes/types' 40 + import {sanitizeHandle} from 'lib/strings/handles' 41 + import {makeProfileLink} from 'lib/routes/links' 42 + import {ComposeIcon2} from 'lib/icons' 43 + 44 + const SECTION_TITLES = ['Posts', 'About'] 45 + 46 + interface SectionRef { 47 + scrollToTop: () => void 48 + } 49 + 50 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> 51 + export const ProfileFeedScreen = withAuthRequired( 52 + observer(function ProfileFeedScreenImpl(props: Props) { 53 + const pal = usePalette('default') 54 + const store = useStores() 55 + const navigation = useNavigation<NavigationProp>() 56 + 57 + const {name: handleOrDid} = props.route.params 58 + 59 + const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() 60 + const [error, setError] = React.useState<string | undefined>() 61 + 62 + const onPressBack = React.useCallback(() => { 63 + if (navigation.canGoBack()) { 64 + navigation.goBack() 65 + } else { 66 + navigation.navigate('Home') 67 + } 68 + }, [navigation]) 69 + 70 + React.useEffect(() => { 71 + /* 72 + * We must resolve the DID of the feed owner before we can fetch the feed. 73 + */ 74 + async function fetchDid() { 75 + try { 76 + const did = await resolveName(store, handleOrDid) 77 + setFeedOwnerDid(did) 78 + } catch (e) { 79 + setError( 80 + `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, 81 + ) 82 + } 83 + } 84 + 85 + fetchDid() 86 + }, [store, handleOrDid, setFeedOwnerDid]) 87 + 88 + if (error) { 89 + return ( 90 + <CenteredView> 91 + <View style={[pal.view, pal.border, styles.notFoundContainer]}> 92 + <Text type="title-lg" style={[pal.text, s.mb10]}> 93 + Could not load feed 94 + </Text> 95 + <Text type="md" style={[pal.text, s.mb20]}> 96 + {error} 97 + </Text> 98 + 99 + <View style={{flexDirection: 'row'}}> 100 + <Button 101 + type="default" 102 + accessibilityLabel="Go Back" 103 + accessibilityHint="Return to previous page" 104 + onPress={onPressBack} 105 + style={{flexShrink: 1}}> 106 + <Text type="button" style={pal.text}> 107 + Go Back 108 + </Text> 109 + </Button> 110 + </View> 111 + </View> 112 + </CenteredView> 113 + ) 114 + } 115 + 116 + return feedOwnerDid ? ( 117 + <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> 118 + ) : ( 119 + <CenteredView> 120 + <View style={s.p20}> 121 + <ActivityIndicator size="large" /> 122 + </View> 123 + </CenteredView> 124 + ) 125 + }), 126 + ) 127 + 128 + export const ProfileFeedScreenInner = observer( 129 + function ProfileFeedScreenInnerImpl({ 130 + route, 131 + feedOwnerDid, 132 + }: Props & {feedOwnerDid: string}) { 133 + const pal = usePalette('default') 134 + const store = useStores() 135 + const {track} = useAnalytics() 136 + const feedSectionRef = React.useRef<SectionRef>(null) 137 + const {rkey, name: handleOrDid} = route.params 138 + const uri = useMemo( 139 + () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), 140 + [rkey, feedOwnerDid], 141 + ) 142 + const feedInfo = useCustomFeed(uri) 143 + const feed: PostsFeedModel = useMemo(() => { 144 + const model = new PostsFeedModel(store, 'custom', { 145 + feed: uri, 146 + }) 147 + model.setup() 148 + return model 149 + }, [store, uri]) 150 + const isPinned = store.preferences.isPinnedFeed(uri) 151 + useSetTitle(feedInfo?.displayName) 152 + 153 + // events 154 + // = 155 + 156 + const onToggleSaved = React.useCallback(async () => { 157 + try { 158 + Haptics.default() 159 + if (feedInfo?.isSaved) { 160 + await feedInfo?.unsave() 161 + } else { 162 + await feedInfo?.save() 163 + } 164 + } catch (err) { 165 + Toast.show( 166 + 'There was an an issue updating your feeds, please check your internet connection and try again.', 167 + ) 168 + store.log.error('Failed up update feeds', {err}) 169 + } 170 + }, [store, feedInfo]) 171 + 172 + const onToggleLiked = React.useCallback(async () => { 173 + Haptics.default() 174 + try { 175 + if (feedInfo?.isLiked) { 176 + await feedInfo?.unlike() 177 + } else { 178 + await feedInfo?.like() 179 + } 180 + } catch (err) { 181 + Toast.show( 182 + 'There was an an issue contacting the server, please check your internet connection and try again.', 183 + ) 184 + store.log.error('Failed up toggle like', {err}) 185 + } 186 + }, [store, feedInfo]) 187 + 188 + const onTogglePinned = React.useCallback(async () => { 189 + Haptics.default() 190 + if (feedInfo) { 191 + feedInfo.togglePin().catch(e => { 192 + Toast.show('There was an issue contacting the server') 193 + store.log.error('Failed to toggle pinned feed', {e}) 194 + }) 195 + } 196 + }, [store, feedInfo]) 197 + 198 + const onPressShare = React.useCallback(() => { 199 + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) 200 + shareUrl(url) 201 + track('CustomFeed:Share') 202 + }, [handleOrDid, rkey, track]) 203 + 204 + const onPressReport = React.useCallback(() => { 205 + if (!feedInfo) return 206 + store.shell.openModal({ 207 + name: 'report', 208 + uri: feedInfo.uri, 209 + cid: feedInfo.cid, 210 + }) 211 + }, [store, feedInfo]) 212 + 213 + const onCurrentPageSelected = React.useCallback( 214 + (index: number) => { 215 + if (index === 0) { 216 + feedSectionRef.current?.scrollToTop() 217 + } 218 + }, 219 + [feedSectionRef], 220 + ) 221 + 222 + // render 223 + // = 224 + 225 + const dropdownItems: DropdownItem[] = React.useMemo(() => { 226 + return [ 227 + { 228 + testID: 'feedHeaderDropdownToggleSavedBtn', 229 + label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', 230 + onPress: onToggleSaved, 231 + icon: feedInfo?.isSaved 232 + ? { 233 + ios: { 234 + name: 'trash', 235 + }, 236 + android: 'ic_delete', 237 + web: ['far', 'trash-can'], 238 + } 239 + : { 240 + ios: { 241 + name: 'plus', 242 + }, 243 + android: '', 244 + web: 'plus', 245 + }, 246 + }, 247 + { 248 + testID: 'feedHeaderDropdownReportBtn', 249 + label: 'Report feed', 250 + onPress: onPressReport, 251 + icon: { 252 + ios: { 253 + name: 'exclamationmark.triangle', 254 + }, 255 + android: 'ic_menu_report_image', 256 + web: 'circle-exclamation', 257 + }, 258 + }, 259 + { 260 + testID: 'feedHeaderDropdownShareBtn', 261 + label: 'Share link', 262 + onPress: onPressShare, 263 + icon: { 264 + ios: { 265 + name: 'square.and.arrow.up', 266 + }, 267 + android: 'ic_menu_share', 268 + web: 'share', 269 + }, 270 + }, 271 + ] as DropdownItem[] 272 + }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) 273 + 274 + const renderHeader = useCallback(() => { 275 + return ( 276 + <ProfileSubpageHeader 277 + isLoading={!feedInfo?.hasLoaded} 278 + href={makeCustomFeedLink(feedOwnerDid, rkey)} 279 + title={feedInfo?.displayName} 280 + avatar={feedInfo?.avatar} 281 + isOwner={feedInfo?.isOwner} 282 + creator={ 283 + feedInfo 284 + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} 285 + : undefined 286 + } 287 + avatarType="algo"> 288 + {feedInfo && ( 289 + <> 290 + <Button 291 + type="default" 292 + label={feedInfo?.isSaved ? 'Unsave' : 'Save'} 293 + onPress={onToggleSaved} 294 + style={styles.btn} 295 + /> 296 + <Button 297 + type={isPinned ? 'default' : 'inverted'} 298 + label={isPinned ? 'Unpin' : 'Pin to home'} 299 + onPress={onTogglePinned} 300 + style={styles.btn} 301 + /> 302 + </> 303 + )} 304 + <NativeDropdown 305 + testID="headerDropdownBtn" 306 + items={dropdownItems} 307 + accessibilityLabel="More options" 308 + accessibilityHint=""> 309 + <View style={[pal.viewLight, styles.btn]}> 310 + <FontAwesomeIcon 311 + icon="ellipsis" 312 + size={20} 313 + color={pal.colors.text} 314 + /> 315 + </View> 316 + </NativeDropdown> 317 + </ProfileSubpageHeader> 318 + ) 319 + }, [ 320 + pal, 321 + feedOwnerDid, 322 + rkey, 323 + feedInfo, 324 + isPinned, 325 + onTogglePinned, 326 + onToggleSaved, 327 + dropdownItems, 328 + ]) 329 + 330 + return ( 331 + <View style={s.hContentRegion}> 332 + <PagerWithHeader 333 + items={SECTION_TITLES} 334 + renderHeader={renderHeader} 335 + onCurrentPageSelected={onCurrentPageSelected}> 336 + {({onScroll, headerHeight, isScrolledDown}) => ( 337 + <FeedSection 338 + key="1" 339 + ref={feedSectionRef} 340 + feed={feed} 341 + onScroll={onScroll} 342 + headerHeight={headerHeight} 343 + isScrolledDown={isScrolledDown} 344 + /> 345 + )} 346 + {({onScroll, headerHeight}) => ( 347 + <ScrollView 348 + key="2" 349 + onScroll={onScroll} 350 + scrollEventThrottle={1} 351 + contentContainerStyle={{paddingTop: headerHeight}}> 352 + <AboutSection 353 + feedOwnerDid={feedOwnerDid} 354 + feedRkey={rkey} 355 + feedInfo={feedInfo} 356 + onToggleLiked={onToggleLiked} 357 + /> 358 + </ScrollView> 359 + )} 360 + </PagerWithHeader> 361 + <FAB 362 + testID="composeFAB" 363 + onPress={() => store.shell.openComposer({})} 364 + icon={ 365 + <ComposeIcon2 366 + strokeWidth={1.5} 367 + size={29} 368 + style={{color: 'white'}} 369 + /> 370 + } 371 + accessibilityRole="button" 372 + accessibilityLabel="New post" 373 + accessibilityHint="" 374 + /> 375 + </View> 376 + ) 377 + }, 378 + ) 379 + 380 + interface FeedSectionProps { 381 + feed: PostsFeedModel 382 + onScroll: OnScrollCb 383 + headerHeight: number 384 + isScrolledDown: boolean 385 + } 386 + const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( 387 + function FeedSectionImpl( 388 + {feed, onScroll, headerHeight, isScrolledDown}, 389 + ref, 390 + ) { 391 + const hasNew = feed.hasNewLatest && !feed.isRefreshing 392 + const scrollElRef = React.useRef<FlatList>(null) 393 + 394 + const onScrollToTop = useCallback(() => { 395 + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) 396 + }, [scrollElRef, headerHeight]) 397 + 398 + const onPressLoadLatest = React.useCallback(() => { 399 + onScrollToTop() 400 + feed.refresh() 401 + }, [feed, onScrollToTop]) 402 + 403 + React.useImperativeHandle(ref, () => ({ 404 + scrollToTop: onScrollToTop, 405 + })) 406 + 407 + const renderPostsEmpty = useCallback(() => { 408 + return <EmptyState icon="feed" message="This feed is empty!" /> 409 + }, []) 410 + 411 + return ( 412 + <View> 413 + <Feed 414 + feed={feed} 415 + scrollElRef={scrollElRef} 416 + onScroll={onScroll} 417 + scrollEventThrottle={5} 418 + renderEmptyState={renderPostsEmpty} 419 + headerOffset={headerHeight} 420 + /> 421 + {(isScrolledDown || hasNew) && ( 422 + <LoadLatestBtn 423 + onPress={onPressLoadLatest} 424 + label="Load new posts" 425 + showIndicator={hasNew} 426 + /> 427 + )} 428 + </View> 429 + ) 430 + }, 431 + ) 432 + 433 + const AboutSection = observer(function AboutPageImpl({ 434 + feedOwnerDid, 435 + feedRkey, 436 + feedInfo, 437 + onToggleLiked, 438 + }: { 439 + feedOwnerDid: string 440 + feedRkey: string 441 + feedInfo: FeedSourceModel | undefined 442 + onToggleLiked: () => void 443 + }) { 444 + const pal = usePalette('default') 445 + 446 + if (!feedInfo) { 447 + return <View /> 448 + } 449 + return ( 450 + <View 451 + style={[ 452 + { 453 + borderTopWidth: 1, 454 + paddingVertical: 20, 455 + paddingHorizontal: 20, 456 + gap: 12, 457 + }, 458 + pal.border, 459 + ]}> 460 + {feedInfo.descriptionRT ? ( 461 + <RichText 462 + testID="listDescription" 463 + type="lg" 464 + style={pal.text} 465 + richText={feedInfo.descriptionRT} 466 + /> 467 + ) : ( 468 + <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> 469 + No description 470 + </Text> 471 + )} 472 + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> 473 + <Button 474 + type="default" 475 + testID="toggleLikeBtn" 476 + accessibilityLabel="Like this feed" 477 + accessibilityHint="" 478 + onPress={onToggleLiked} 479 + style={{paddingHorizontal: 10}}> 480 + {feedInfo?.isLiked ? ( 481 + <HeartIconSolid size={19} style={styles.liked} /> 482 + ) : ( 483 + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> 484 + )} 485 + </Button> 486 + {typeof feedInfo.likeCount === 'number' && ( 487 + <TextLink 488 + href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} 489 + text={`Liked by ${feedInfo.likeCount} ${pluralize( 490 + feedInfo.likeCount, 491 + 'user', 492 + )}`} 493 + style={[pal.textLight, s.semiBold]} 494 + /> 495 + )} 496 + </View> 497 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 498 + Created by{' '} 499 + {feedInfo.isOwner ? ( 500 + 'you' 501 + ) : ( 502 + <TextLink 503 + text={sanitizeHandle(feedInfo.creatorHandle, '@')} 504 + href={makeProfileLink({ 505 + did: feedInfo.creatorDid, 506 + handle: feedInfo.creatorHandle, 507 + })} 508 + style={pal.textLight} 509 + /> 510 + )} 511 + </Text> 512 + </View> 513 + ) 514 + }) 515 + 516 + const styles = StyleSheet.create({ 517 + btn: { 518 + flexDirection: 'row', 519 + alignItems: 'center', 520 + gap: 6, 521 + paddingVertical: 7, 522 + paddingHorizontal: 14, 523 + borderRadius: 50, 524 + marginLeft: 6, 525 + }, 526 + liked: { 527 + color: colors.red3, 528 + }, 529 + notFoundContainer: { 530 + margin: 10, 531 + paddingHorizontal: 18, 532 + paddingVertical: 14, 533 + borderRadius: 6, 534 + }, 535 + })
+743 -107
src/view/screens/ProfileList.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet} from 'react-native' 1 + import React, {useCallback, useMemo} from 'react' 2 + import { 3 + ActivityIndicator, 4 + FlatList, 5 + Pressable, 6 + StyleSheet, 7 + View, 8 + } from 'react-native' 3 9 import {useFocusEffect} from '@react-navigation/native' 4 10 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 5 11 import {useNavigation} from '@react-navigation/native' 12 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 13 import {observer} from 'mobx-react-lite' 14 + import {RichText as RichTextAPI} from '@atproto/api' 7 15 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 8 - import {ViewHeader} from 'view/com/util/ViewHeader' 16 + import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 17 + import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' 18 + import {Feed} from 'view/com/posts/Feed' 19 + import {Text} from 'view/com/util/text/Text' 20 + import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' 9 21 import {CenteredView} from 'view/com/util/Views' 10 - import {ListItems} from 'view/com/lists/ListItems' 11 22 import {EmptyState} from 'view/com/util/EmptyState' 23 + import {RichText} from 'view/com/util/text/RichText' 24 + import {Button} from 'view/com/util/forms/Button' 25 + import {TextLink} from 'view/com/util/Link' 12 26 import * as Toast from 'view/com/util/Toast' 27 + import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 28 + import {FAB} from 'view/com/util/fab/FAB' 29 + import {Haptics} from 'lib/haptics' 13 30 import {ListModel} from 'state/models/content/list' 31 + import {PostsFeedModel} from 'state/models/feeds/posts' 14 32 import {useStores} from 'state/index' 15 33 import {usePalette} from 'lib/hooks/usePalette' 16 34 import {useSetTitle} from 'lib/hooks/useSetTitle' 17 35 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 36 + import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 18 37 import {NavigationProp} from 'lib/routes/types' 19 38 import {toShareUrl} from 'lib/strings/url-helpers' 20 39 import {shareUrl} from 'lib/sharing' 21 - import {ListActions} from 'view/com/lists/ListActions' 40 + import {resolveName} from 'lib/api' 22 41 import {s} from 'lib/styles' 42 + import {sanitizeHandle} from 'lib/strings/handles' 43 + import {makeProfileLink, makeListLink} from 'lib/routes/links' 44 + import {ComposeIcon2} from 'lib/icons' 45 + import {ListItems} from 'view/com/lists/ListItems' 46 + 47 + const SECTION_TITLES_CURATE = ['Posts', 'About'] 48 + const SECTION_TITLES_MOD = ['About'] 49 + 50 + interface SectionRef { 51 + scrollToTop: () => void 52 + } 23 53 24 54 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> 25 55 export const ProfileListScreen = withAuthRequired( 26 - observer(function ProfileListScreenImpl({route}: Props) { 56 + observer(function ProfileListScreenImpl(props: Props) { 57 + const pal = usePalette('default') 27 58 const store = useStores() 28 59 const navigation = useNavigation<NavigationProp>() 29 - const {isTabletOrDesktop} = useWebMediaQueries() 30 - const pal = usePalette('default') 31 - const {name, rkey} = route.params 60 + 61 + const {name: handleOrDid} = props.route.params 62 + 63 + const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() 64 + const [error, setError] = React.useState<string | undefined>() 65 + 66 + const onPressBack = useCallback(() => { 67 + if (navigation.canGoBack()) { 68 + navigation.goBack() 69 + } else { 70 + navigation.navigate('Home') 71 + } 72 + }, [navigation]) 73 + 74 + React.useEffect(() => { 75 + /* 76 + * We must resolve the DID of the list owner before we can fetch the list. 77 + */ 78 + async function fetchDid() { 79 + try { 80 + const did = await resolveName(store, handleOrDid) 81 + setListOwnerDid(did) 82 + } catch (e) { 83 + setError( 84 + `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, 85 + ) 86 + } 87 + } 88 + 89 + fetchDid() 90 + }, [store, handleOrDid, setListOwnerDid]) 91 + 92 + if (error) { 93 + return ( 94 + <CenteredView> 95 + <View 96 + style={[ 97 + pal.view, 98 + pal.border, 99 + { 100 + margin: 10, 101 + paddingHorizontal: 18, 102 + paddingVertical: 14, 103 + borderRadius: 6, 104 + }, 105 + ]}> 106 + <Text type="title-lg" style={[pal.text, s.mb10]}> 107 + Could not load list 108 + </Text> 109 + <Text type="md" style={[pal.text, s.mb20]}> 110 + {error} 111 + </Text> 112 + 113 + <View style={{flexDirection: 'row'}}> 114 + <Button 115 + type="default" 116 + accessibilityLabel="Go Back" 117 + accessibilityHint="Return to previous page" 118 + onPress={onPressBack} 119 + style={{flexShrink: 1}}> 120 + <Text type="button" style={pal.text}> 121 + Go Back 122 + </Text> 123 + </Button> 124 + </View> 125 + </View> 126 + </CenteredView> 127 + ) 128 + } 129 + 130 + return listOwnerDid ? ( 131 + <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> 132 + ) : ( 133 + <CenteredView> 134 + <View style={s.p20}> 135 + <ActivityIndicator size="large" /> 136 + </View> 137 + </CenteredView> 138 + ) 139 + }), 140 + ) 141 + 142 + export const ProfileListScreenInner = observer( 143 + function ProfileListScreenInnerImpl({ 144 + route, 145 + listOwnerDid, 146 + }: Props & {listOwnerDid: string}) { 147 + const store = useStores() 148 + const {rkey} = route.params 149 + const feedSectionRef = React.useRef<SectionRef>(null) 150 + const aboutSectionRef = React.useRef<SectionRef>(null) 32 151 33 - const list: ListModel = React.useMemo(() => { 152 + const list: ListModel = useMemo(() => { 34 153 const model = new ListModel( 35 154 store, 36 - `at://${name}/app.bsky.graph.list/${rkey}`, 155 + `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, 37 156 ) 38 157 return model 39 - }, [store, name, rkey]) 40 - useSetTitle(list.list?.name) 158 + }, [store, listOwnerDid, rkey]) 159 + const feed = useMemo( 160 + () => new PostsFeedModel(store, 'list', {list: list.uri}), 161 + [store, list], 162 + ) 163 + useSetTitle(list.data?.name) 41 164 42 165 useFocusEffect( 43 - React.useCallback(() => { 166 + useCallback(() => { 44 167 store.shell.setMinimalShellMode(false) 45 - list.loadMore(true) 46 - }, [store, list]), 168 + list.loadMore(true).then(() => { 169 + if (list.isCuratelist) { 170 + feed.setup() 171 + } 172 + }) 173 + }, [store, list, feed]), 47 174 ) 48 175 49 - const onToggleSubscribed = React.useCallback(async () => { 50 - try { 51 - if (list.list?.viewer?.muted) { 52 - await list.unsubscribe() 53 - } else { 54 - await list.subscribe() 55 - } 56 - } catch (err) { 57 - Toast.show( 58 - 'There was an an issue updating your subscription, please check your internet connection and try again.', 59 - ) 60 - store.log.error('Failed up update subscription', {err}) 61 - } 62 - }, [store, list]) 63 - 64 - const onPressEditList = React.useCallback(() => { 176 + const onPressAddUser = useCallback(() => { 65 177 store.shell.openModal({ 66 - name: 'create-or-edit-mute-list', 178 + name: 'list-add-user', 67 179 list, 68 - onSave() { 69 - list.refresh() 180 + onAdd() { 181 + if (list.isCuratelist) { 182 + feed.refresh() 183 + } 70 184 }, 71 185 }) 72 - }, [store, list]) 186 + }, [store, list, feed]) 187 + 188 + const onCurrentPageSelected = React.useCallback( 189 + (index: number) => { 190 + if (index === 0) { 191 + feedSectionRef.current?.scrollToTop() 192 + } 193 + if (index === 1) { 194 + aboutSectionRef.current?.scrollToTop() 195 + } 196 + }, 197 + [feedSectionRef], 198 + ) 199 + 200 + const renderHeader = useCallback(() => { 201 + return <Header rkey={rkey} list={list} /> 202 + }, [rkey, list]) 203 + 204 + if (list.isCuratelist) { 205 + return ( 206 + <View style={s.hContentRegion}> 207 + <PagerWithHeader 208 + items={SECTION_TITLES_CURATE} 209 + renderHeader={renderHeader} 210 + onCurrentPageSelected={onCurrentPageSelected}> 211 + {({onScroll, headerHeight, isScrolledDown}) => ( 212 + <FeedSection 213 + key="1" 214 + ref={feedSectionRef} 215 + feed={feed} 216 + onScroll={onScroll} 217 + headerHeight={headerHeight} 218 + isScrolledDown={isScrolledDown} 219 + /> 220 + )} 221 + {({onScroll, headerHeight, isScrolledDown}) => ( 222 + <AboutSection 223 + key="2" 224 + ref={aboutSectionRef} 225 + list={list} 226 + descriptionRT={list.descriptionRT} 227 + creator={list.data ? list.data.creator : undefined} 228 + isCurateList={list.isCuratelist} 229 + isOwner={list.isOwner} 230 + onPressAddUser={onPressAddUser} 231 + onScroll={onScroll} 232 + headerHeight={headerHeight} 233 + isScrolledDown={isScrolledDown} 234 + /> 235 + )} 236 + </PagerWithHeader> 237 + <FAB 238 + testID="composeFAB" 239 + onPress={() => store.shell.openComposer({})} 240 + icon={ 241 + <ComposeIcon2 242 + strokeWidth={1.5} 243 + size={29} 244 + style={{color: 'white'}} 245 + /> 246 + } 247 + accessibilityRole="button" 248 + accessibilityLabel="New post" 249 + accessibilityHint="" 250 + /> 251 + </View> 252 + ) 253 + } 254 + if (list.isModlist) { 255 + return ( 256 + <View style={s.hContentRegion}> 257 + <PagerWithHeader 258 + items={SECTION_TITLES_MOD} 259 + renderHeader={renderHeader}> 260 + {({onScroll, headerHeight, isScrolledDown}) => ( 261 + <AboutSection 262 + key="2" 263 + list={list} 264 + descriptionRT={list.descriptionRT} 265 + creator={list.data ? list.data.creator : undefined} 266 + isCurateList={list.isCuratelist} 267 + isOwner={list.isOwner} 268 + onPressAddUser={onPressAddUser} 269 + onScroll={onScroll} 270 + headerHeight={headerHeight} 271 + isScrolledDown={isScrolledDown} 272 + /> 273 + )} 274 + </PagerWithHeader> 275 + <FAB 276 + testID="composeFAB" 277 + onPress={() => store.shell.openComposer({})} 278 + icon={ 279 + <ComposeIcon2 280 + strokeWidth={1.5} 281 + size={29} 282 + style={{color: 'white'}} 283 + /> 284 + } 285 + accessibilityRole="button" 286 + accessibilityLabel="New post" 287 + accessibilityHint="" 288 + /> 289 + </View> 290 + ) 291 + } 292 + return <Header rkey={rkey} list={list} /> 293 + }, 294 + ) 73 295 74 - const onPressDeleteList = React.useCallback(() => { 75 - store.shell.openModal({ 76 - name: 'confirm', 77 - title: 'Delete List', 78 - message: 'Are you sure?', 79 - async onPressConfirm() { 80 - await list.delete() 81 - if (navigation.canGoBack()) { 82 - navigation.goBack() 83 - } else { 84 - navigation.navigate('Home') 85 - } 296 + const Header = observer(function HeaderImpl({ 297 + rkey, 298 + list, 299 + }: { 300 + rkey: string 301 + list: ListModel 302 + }) { 303 + const pal = usePalette('default') 304 + const palInverted = usePalette('inverted') 305 + const store = useStores() 306 + const navigation = useNavigation<NavigationProp>() 307 + 308 + const onTogglePinned = useCallback(async () => { 309 + Haptics.default() 310 + list.togglePin().catch(e => { 311 + Toast.show('There was an issue contacting the server') 312 + store.log.error('Failed to toggle pinned list', {e}) 313 + }) 314 + }, [store, list]) 315 + 316 + const onSubscribeMute = useCallback(() => { 317 + store.shell.openModal({ 318 + name: 'confirm', 319 + title: 'Mute these accounts?', 320 + message: 321 + 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', 322 + confirmBtnText: 'Mute this List', 323 + async onPressConfirm() { 324 + try { 325 + await list.mute() 326 + Toast.show('List muted') 327 + } catch { 328 + Toast.show( 329 + 'There was an issue. Please check your internet connection and try again.', 330 + ) 331 + } 332 + }, 333 + onPressCancel() { 334 + store.shell.closeModal() 335 + }, 336 + }) 337 + }, [store, list]) 338 + 339 + const onUnsubscribeMute = useCallback(async () => { 340 + try { 341 + await list.unmute() 342 + Toast.show('List unmuted') 343 + } catch { 344 + Toast.show( 345 + 'There was an issue. Please check your internet connection and try again.', 346 + ) 347 + } 348 + }, [list]) 349 + 350 + const onSubscribeBlock = useCallback(() => { 351 + store.shell.openModal({ 352 + name: 'confirm', 353 + title: 'Block these accounts?', 354 + message: 355 + 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', 356 + confirmBtnText: 'Block this List', 357 + async onPressConfirm() { 358 + try { 359 + await list.block() 360 + Toast.show('List blocked') 361 + } catch { 362 + Toast.show( 363 + 'There was an issue. Please check your internet connection and try again.', 364 + ) 365 + } 366 + }, 367 + onPressCancel() { 368 + store.shell.closeModal() 369 + }, 370 + }) 371 + }, [store, list]) 372 + 373 + const onUnsubscribeBlock = useCallback(async () => { 374 + try { 375 + await list.unblock() 376 + Toast.show('List unblocked') 377 + } catch { 378 + Toast.show( 379 + 'There was an issue. Please check your internet connection and try again.', 380 + ) 381 + } 382 + }, [list]) 383 + 384 + const onPressEdit = useCallback(() => { 385 + store.shell.openModal({ 386 + name: 'create-or-edit-list', 387 + list, 388 + onSave() { 389 + list.refresh() 390 + }, 391 + }) 392 + }, [store, list]) 393 + 394 + const onPressDelete = useCallback(() => { 395 + store.shell.openModal({ 396 + name: 'confirm', 397 + title: 'Delete List', 398 + message: 'Are you sure?', 399 + async onPressConfirm() { 400 + await list.delete() 401 + Toast.show('List deleted') 402 + if (navigation.canGoBack()) { 403 + navigation.goBack() 404 + } else { 405 + navigation.navigate('Home') 406 + } 407 + }, 408 + }) 409 + }, [store, list, navigation]) 410 + 411 + const onPressReport = useCallback(() => { 412 + if (!list.data) return 413 + store.shell.openModal({ 414 + name: 'report', 415 + uri: list.uri, 416 + cid: list.data.cid, 417 + }) 418 + }, [store, list]) 419 + 420 + const onPressShare = useCallback(() => { 421 + const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) 422 + shareUrl(url) 423 + }, [list.creatorDid, rkey]) 424 + 425 + const dropdownItems: DropdownItem[] = useMemo(() => { 426 + if (!list.hasLoaded) { 427 + return [] 428 + } 429 + let items: DropdownItem[] = [ 430 + { 431 + testID: 'listHeaderDropdownShareBtn', 432 + label: 'Share', 433 + onPress: onPressShare, 434 + icon: { 435 + ios: { 436 + name: 'square.and.arrow.up', 437 + }, 438 + android: '', 439 + web: 'share', 440 + }, 441 + }, 442 + ] 443 + if (list.isOwner) { 444 + items.push({label: 'separator'}) 445 + items.push({ 446 + testID: 'listHeaderDropdownEditBtn', 447 + label: 'Edit List Details', 448 + onPress: onPressEdit, 449 + icon: { 450 + ios: { 451 + name: 'pencil', 452 + }, 453 + android: '', 454 + web: 'pen', 86 455 }, 87 456 }) 88 - }, [store, list, navigation]) 457 + items.push({ 458 + testID: 'listHeaderDropdownDeleteBtn', 459 + label: 'Delete List', 460 + onPress: onPressDelete, 461 + icon: { 462 + ios: { 463 + name: 'trash', 464 + }, 465 + android: '', 466 + web: ['far', 'trash-can'], 467 + }, 468 + }) 469 + } else { 470 + items.push({label: 'separator'}) 471 + items.push({ 472 + testID: 'listHeaderDropdownReportBtn', 473 + label: 'Report List', 474 + onPress: onPressReport, 475 + icon: { 476 + ios: { 477 + name: 'exclamationmark.triangle', 478 + }, 479 + android: '', 480 + web: 'circle-exclamation', 481 + }, 482 + }) 483 + } 484 + return items 485 + }, [ 486 + list.hasLoaded, 487 + list.isOwner, 488 + onPressShare, 489 + onPressEdit, 490 + onPressDelete, 491 + onPressReport, 492 + ]) 493 + 494 + const subscribeDropdownItems: DropdownItem[] = useMemo(() => { 495 + return [ 496 + { 497 + testID: 'subscribeDropdownMuteBtn', 498 + label: 'Mute accounts', 499 + onPress: onSubscribeMute, 500 + icon: { 501 + ios: { 502 + name: 'speaker.slash', 503 + }, 504 + android: '', 505 + web: 'user-slash', 506 + }, 507 + }, 508 + { 509 + testID: 'subscribeDropdownBlockBtn', 510 + label: 'Block accounts', 511 + onPress: onSubscribeBlock, 512 + icon: { 513 + ios: { 514 + name: 'person.fill.xmark', 515 + }, 516 + android: '', 517 + web: 'ban', 518 + }, 519 + }, 520 + ] 521 + }, [onSubscribeMute, onSubscribeBlock]) 522 + 523 + return ( 524 + <ProfileSubpageHeader 525 + isLoading={!list.hasLoaded} 526 + href={makeListLink( 527 + list.data?.creator.handle || list.data?.creator.did || '', 528 + rkey, 529 + )} 530 + title={list.data?.name || 'User list'} 531 + avatar={list.data?.avatar} 532 + isOwner={list.isOwner} 533 + creator={list.data?.creator} 534 + avatarType="list"> 535 + {list.isCuratelist ? ( 536 + <Button 537 + testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} 538 + type={list.isPinned ? 'default' : 'inverted'} 539 + label={list.isPinned ? 'Unpin' : 'Pin to home'} 540 + onPress={onTogglePinned} 541 + /> 542 + ) : list.isModlist ? ( 543 + list.isBlocking ? ( 544 + <Button 545 + testID="unblockBtn" 546 + type="default" 547 + label="Unblock" 548 + onPress={onUnsubscribeBlock} 549 + /> 550 + ) : list.isMuting ? ( 551 + <Button 552 + testID="unmuteBtn" 553 + type="default" 554 + label="Unmute" 555 + onPress={onUnsubscribeMute} 556 + /> 557 + ) : ( 558 + <NativeDropdown 559 + testID="subscribeBtn" 560 + items={subscribeDropdownItems} 561 + accessibilityLabel="Subscribe to this list" 562 + accessibilityHint=""> 563 + <View style={[palInverted.view, styles.btn]}> 564 + <Text style={palInverted.text}>Subscribe</Text> 565 + </View> 566 + </NativeDropdown> 567 + ) 568 + ) : null} 569 + <NativeDropdown 570 + testID="headerDropdownBtn" 571 + items={dropdownItems} 572 + accessibilityLabel="More options" 573 + accessibilityHint=""> 574 + <View style={[pal.viewLight, styles.btn]}> 575 + <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> 576 + </View> 577 + </NativeDropdown> 578 + </ProfileSubpageHeader> 579 + ) 580 + }) 89 581 90 - const onPressReportList = React.useCallback(() => { 91 - if (!list.list) return 92 - store.shell.openModal({ 93 - name: 'report', 94 - uri: list.uri, 95 - cid: list.list.cid, 96 - }) 97 - }, [store, list]) 582 + interface FeedSectionProps { 583 + feed: PostsFeedModel 584 + onScroll: OnScrollCb 585 + headerHeight: number 586 + isScrolledDown: boolean 587 + } 588 + const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( 589 + function FeedSectionImpl( 590 + {feed, onScroll, headerHeight, isScrolledDown}, 591 + ref, 592 + ) { 593 + const hasNew = feed.hasNewLatest && !feed.isRefreshing 594 + const scrollElRef = React.useRef<FlatList>(null) 595 + 596 + const onScrollToTop = useCallback(() => { 597 + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) 598 + }, [scrollElRef, headerHeight]) 599 + 600 + const onPressLoadLatest = React.useCallback(() => { 601 + onScrollToTop() 602 + feed.refresh() 603 + }, [feed, onScrollToTop]) 98 604 99 - const onPressShareList = React.useCallback(() => { 100 - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) 101 - shareUrl(url) 102 - }, [list.creatorDid, rkey]) 605 + React.useImperativeHandle(ref, () => ({ 606 + scrollToTop: onScrollToTop, 607 + })) 103 608 104 - const renderEmptyState = React.useCallback(() => { 105 - return <EmptyState icon="users-slash" message="This list is empty!" /> 609 + const renderPostsEmpty = useCallback(() => { 610 + return <EmptyState icon="feed" message="This feed is empty!" /> 106 611 }, []) 107 612 108 - const renderHeaderBtns = React.useCallback(() => { 109 - return ( 110 - <ListActions 111 - muted={list.list?.viewer?.muted} 112 - isOwner={list.isOwner} 113 - onPressDeleteList={onPressDeleteList} 114 - onPressEditList={onPressEditList} 115 - onToggleSubscribed={onToggleSubscribed} 116 - onPressShareList={onPressShareList} 117 - onPressReportList={onPressReportList} 118 - reversed={true} 613 + return ( 614 + <View> 615 + <Feed 616 + testID="listFeed" 617 + feed={feed} 618 + scrollElRef={scrollElRef} 619 + onScroll={onScroll} 620 + scrollEventThrottle={1} 621 + renderEmptyState={renderPostsEmpty} 622 + headerOffset={headerHeight} 119 623 /> 624 + {(isScrolledDown || hasNew) && ( 625 + <LoadLatestBtn 626 + onPress={onPressLoadLatest} 627 + label="Load new posts" 628 + showIndicator={hasNew} 629 + /> 630 + )} 631 + </View> 632 + ) 633 + }, 634 + ) 635 + 636 + interface AboutSectionProps { 637 + list: ListModel 638 + descriptionRT: RichTextAPI | null 639 + creator: {did: string; handle: string} | undefined 640 + isCurateList: boolean | undefined 641 + isOwner: boolean | undefined 642 + onPressAddUser: () => void 643 + onScroll: OnScrollCb 644 + headerHeight: number 645 + isScrolledDown: boolean 646 + } 647 + const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 648 + function AboutSectionImpl( 649 + { 650 + list, 651 + descriptionRT, 652 + creator, 653 + isCurateList, 654 + isOwner, 655 + onPressAddUser, 656 + onScroll, 657 + headerHeight, 658 + isScrolledDown, 659 + }, 660 + ref, 661 + ) { 662 + const pal = usePalette('default') 663 + const {isMobile} = useWebMediaQueries() 664 + const scrollElRef = React.useRef<FlatList>(null) 665 + 666 + const onScrollToTop = useCallback(() => { 667 + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) 668 + }, [scrollElRef, headerHeight]) 669 + 670 + React.useImperativeHandle(ref, () => ({ 671 + scrollToTop: onScrollToTop, 672 + })) 673 + 674 + const renderHeader = React.useCallback(() => { 675 + if (!list.data) { 676 + return <View /> 677 + } 678 + return ( 679 + <View> 680 + <View 681 + style={[ 682 + { 683 + borderTopWidth: 1, 684 + padding: isMobile ? 14 : 20, 685 + gap: 12, 686 + }, 687 + pal.border, 688 + ]}> 689 + {descriptionRT ? ( 690 + <RichText 691 + testID="listDescription" 692 + type="lg" 693 + style={pal.text} 694 + richText={descriptionRT} 695 + /> 696 + ) : ( 697 + <Text 698 + testID="listDescriptionEmpty" 699 + type="lg" 700 + style={[{fontStyle: 'italic'}, pal.textLight]}> 701 + No description 702 + </Text> 703 + )} 704 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 705 + {isCurateList ? 'User list' : 'Moderation list'} by{' '} 706 + {isOwner ? ( 707 + 'you' 708 + ) : ( 709 + <TextLink 710 + text={sanitizeHandle(creator?.handle || '', '@')} 711 + href={creator ? makeProfileLink(creator) : ''} 712 + style={pal.textLight} 713 + /> 714 + )} 715 + </Text> 716 + </View> 717 + <View 718 + style={[ 719 + { 720 + flexDirection: 'row', 721 + alignItems: 'center', 722 + justifyContent: 'space-between', 723 + paddingHorizontal: isMobile ? 14 : 20, 724 + paddingBottom: isMobile ? 14 : 18, 725 + }, 726 + ]}> 727 + <Text type="lg-bold">Users</Text> 728 + {isOwner && ( 729 + <Pressable 730 + testID="addUserBtn" 731 + accessibilityRole="button" 732 + accessibilityLabel="Add a user to this list" 733 + accessibilityHint="" 734 + onPress={onPressAddUser} 735 + style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> 736 + <FontAwesomeIcon 737 + icon="user-plus" 738 + color={pal.colors.link} 739 + size={16} 740 + /> 741 + <Text style={pal.link}>Add</Text> 742 + </Pressable> 743 + )} 744 + </View> 745 + </View> 120 746 ) 121 747 }, [ 122 - list.isOwner, 123 - list.list?.viewer?.muted, 124 - onPressDeleteList, 125 - onPressEditList, 126 - onPressShareList, 127 - onToggleSubscribed, 128 - onPressReportList, 748 + pal, 749 + list.data, 750 + isMobile, 751 + descriptionRT, 752 + creator, 753 + isCurateList, 754 + isOwner, 755 + onPressAddUser, 129 756 ]) 130 757 758 + const renderEmptyState = useCallback(() => { 759 + return ( 760 + <EmptyState 761 + icon="users-slash" 762 + message="This list is empty!" 763 + style={{paddingTop: 40}} 764 + /> 765 + ) 766 + }, []) 767 + 131 768 return ( 132 - <CenteredView 133 - style={[ 134 - styles.container, 135 - isTabletOrDesktop && styles.containerDesktop, 136 - pal.view, 137 - pal.border, 138 - ]} 139 - testID="moderationMutelistsScreen"> 140 - <ViewHeader title="" renderButton={renderHeaderBtns} /> 769 + <View> 141 770 <ListItems 771 + testID="listItems" 772 + scrollElRef={scrollElRef} 773 + renderHeader={renderHeader} 774 + renderEmptyState={renderEmptyState} 142 775 list={list} 143 - renderEmptyState={renderEmptyState} 144 - onToggleSubscribed={onToggleSubscribed} 145 - onPressEditList={onPressEditList} 146 - onPressDeleteList={onPressDeleteList} 147 - onPressReportList={onPressReportList} 148 - onPressShareList={onPressShareList} 149 - style={[s.flex1]} 776 + headerOffset={headerHeight} 777 + onScroll={onScroll} 778 + scrollEventThrottle={1} 150 779 /> 151 - </CenteredView> 780 + {isScrolledDown && ( 781 + <LoadLatestBtn 782 + onPress={onScrollToTop} 783 + label="Scroll to top" 784 + showIndicator={false} 785 + /> 786 + )} 787 + </View> 152 788 ) 153 - }), 789 + }, 154 790 ) 155 791 156 792 const styles = StyleSheet.create({ 157 - container: { 158 - flex: 1, 159 - paddingBottom: 100, 160 - }, 161 - containerDesktop: { 162 - borderLeftWidth: 1, 163 - borderRightWidth: 1, 164 - paddingBottom: 0, 793 + btn: { 794 + flexDirection: 'row', 795 + alignItems: 'center', 796 + gap: 6, 797 + paddingVertical: 7, 798 + paddingHorizontal: 14, 799 + borderRadius: 50, 800 + marginLeft: 6, 165 801 }, 166 802 })
+22 -14
src/view/screens/SavedFeeds.tsx
··· 14 14 import {CommonNavigatorParams} from 'lib/routes/types' 15 15 import {observer} from 'mobx-react-lite' 16 16 import {useStores} from 'state/index' 17 + import {SavedFeedsModel} from 'state/models/ui/saved-feeds' 17 18 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 18 19 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 19 20 import {ViewHeader} from 'view/com/util/ViewHeader' ··· 25 26 ShadowDecorator, 26 27 ScaleDecorator, 27 28 } from 'react-native-draggable-flatlist' 28 - import {CustomFeed} from 'view/com/feeds/CustomFeed' 29 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 30 + import {FeedSourceModel} from 'state/models/content/feed-source' 29 31 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 30 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 31 32 import * as Toast from 'view/com/util/Toast' 32 33 import {Haptics} from 'lib/haptics' 33 34 import {Link, TextLink} from 'view/com/util/Link' ··· 41 42 const {isMobile, isTabletOrDesktop} = useWebMediaQueries() 42 43 const {screen} = useAnalytics() 43 44 44 - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) 45 + const savedFeeds = useMemo(() => { 46 + const model = new SavedFeedsModel(store) 47 + model.refresh() 48 + return model 49 + }, [store]) 45 50 useFocusEffect( 46 51 useCallback(() => { 47 52 screen('SavedFeeds') ··· 102 107 const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) 103 108 104 109 const onDragEnd = useCallback( 105 - async ({data}: {data: CustomFeedModel[]}) => { 110 + async ({data}: {data: FeedSourceModel[]}) => { 106 111 try { 107 112 await savedFeeds.reorderPinnedFeeds(data) 108 113 } catch (e) { ··· 123 128 <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> 124 129 <DraggableFlatList 125 130 containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]} 126 - data={savedFeeds.all} 127 - keyExtractor={item => item.data.uri} 131 + data={savedFeeds.pinned.concat(savedFeeds.unpinned)} 132 + keyExtractor={item => item.uri} 128 133 refreshing={savedFeeds.isRefreshing} 129 134 refreshControl={ 130 135 <RefreshControl ··· 134 139 titleColor={pal.colors.text} 135 140 /> 136 141 } 137 - renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} 142 + renderItem={({item, drag}) => ( 143 + <ListItem savedFeeds={savedFeeds} item={item} drag={drag} /> 144 + )} 138 145 getItemLayout={(data, index) => ({ 139 146 length: 77, 140 147 offset: 77 * index, ··· 152 159 ) 153 160 154 161 const ListItem = observer(function ListItemImpl({ 162 + savedFeeds, 155 163 item, 156 164 drag, 157 165 }: { 158 - item: CustomFeedModel 166 + savedFeeds: SavedFeedsModel 167 + item: FeedSourceModel 159 168 drag: () => void 160 169 }) { 161 170 const pal = usePalette('default') 162 171 const store = useStores() 163 - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) 164 - const isPinned = savedFeeds.isPinned(item) 172 + const isPinned = item.isPinned 165 173 166 174 const onTogglePinned = useCallback(() => { 167 175 Haptics.default() 168 - savedFeeds.togglePinnedFeed(item).catch(e => { 176 + item.togglePin().catch(e => { 169 177 Toast.show('There was an issue contacting the server') 170 178 store.log.error('Failed to toggle pinned feed', {e}) 171 179 }) 172 - }, [savedFeeds, item, store]) 180 + }, [item, store]) 173 181 const onPressUp = useCallback( 174 182 () => 175 183 savedFeeds.movePinnedFeed(item, 'up').catch(e => { ··· 222 230 style={s.ml20} 223 231 /> 224 232 ) : null} 225 - <CustomFeed 226 - key={item.data.uri} 233 + <FeedSourceCard 234 + key={item.uri} 227 235 item={item} 228 236 showSaveBtn 229 237 style={styles.noBorder}
+14
src/view/shell/Drawer.tsx
··· 29 29 MagnifyingGlassIcon2Solid, 30 30 UserIconSolid, 31 31 HashtagIcon, 32 + ListIcon, 32 33 HandIcon, 33 34 } from 'lib/icons' 34 35 import {UserAvatar} from 'view/com/util/UserAvatar' ··· 105 106 () => onPressTab('Feeds'), 106 107 [onPressTab], 107 108 ) 109 + 110 + const onPressLists = React.useCallback(() => { 111 + track('Menu:ItemClicked', {url: 'Lists'}) 112 + navigation.navigate('Lists') 113 + store.shell.closeDrawer() 114 + }, [navigation, track, store.shell]) 108 115 109 116 const onPressModeration = React.useCallback(() => { 110 117 track('Menu:ItemClicked', {url: 'Moderation'}) ··· 275 282 accessibilityHint="" 276 283 bold={isAtFeeds} 277 284 onPress={onPressMyFeeds} 285 + /> 286 + <MenuItem 287 + icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} 288 + label="Lists" 289 + accessibilityLabel="Lists" 290 + accessibilityHint="" 291 + onPress={onPressLists} 278 292 /> 279 293 <MenuItem 280 294 icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />}
+13 -10
src/view/shell/desktop/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {View, StyleSheet} from 'react-native' 3 3 import {useNavigationState} from '@react-navigation/native' 4 - import {AtUri} from '@atproto/api' 5 4 import {observer} from 'mobx-react-lite' 6 5 import {useStores} from 'state/index' 7 6 import {usePalette} from 'lib/hooks/usePalette' 7 + import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems' 8 8 import {TextLink} from 'view/com/util/Link' 9 9 import {getCurrentRoute} from 'lib/routes/helpers' 10 10 11 11 export const DesktopFeeds = observer(function DesktopFeeds() { 12 12 const store = useStores() 13 13 const pal = usePalette('default') 14 + const items = useDesktopRightNavItems(store.preferences.pinnedFeeds) 14 15 15 16 const route = useNavigationState(state => { 16 17 if (!state) { ··· 22 23 return ( 23 24 <View style={[styles.container, pal.view, pal.border]}> 24 25 <FeedItem href="/" title="Following" current={route.name === 'Home'} /> 25 - {store.me.savedFeeds.pinned.map(feed => { 26 + {items.map(item => { 26 27 try { 27 - const {hostname, rkey} = new AtUri(feed.uri) 28 - const href = `/profile/${hostname}/feed/${rkey}` 29 28 const params = route.params as Record<string, string> 29 + const routeName = 30 + item.collection === 'app.bsky.feed.generator' 31 + ? 'ProfileFeed' 32 + : 'ProfileList' 30 33 return ( 31 34 <FeedItem 32 - key={feed.uri} 33 - href={href} 34 - title={feed.displayName} 35 + key={item.uri} 36 + href={item.href} 37 + title={item.displayName} 35 38 current={ 36 - route.name === 'CustomFeed' && 37 - params.name === hostname && 38 - params.rkey === rkey 39 + route.name === routeName && 40 + params.name === item.hostname && 41 + params.rkey === item.rkey 39 42 } 40 43 /> 41 44 )
+21 -2
src/view/shell/desktop/LeftNav.tsx
··· 31 31 CogIcon, 32 32 CogIconSolid, 33 33 ComposeIcon2, 34 + ListIcon, 35 + HashtagIcon, 34 36 HandIcon, 35 - HashtagIcon, 36 37 } from 'lib/icons' 37 38 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' 38 39 import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' ··· 320 321 label="Notifications" 321 322 /> 322 323 <NavItem 324 + href="/lists" 325 + icon={ 326 + <ListIcon 327 + style={pal.text} 328 + size={isDesktop ? 26 : 30} 329 + strokeWidth={2} 330 + /> 331 + } 332 + iconFilled={ 333 + <ListIcon 334 + style={pal.text} 335 + size={isDesktop ? 26 : 30} 336 + strokeWidth={3} 337 + /> 338 + } 339 + label="Lists" 340 + /> 341 + <NavItem 323 342 href="/moderation" 324 343 icon={ 325 344 <HandIcon 326 - strokeWidth={5.5} 327 345 style={pal.text} 328 346 size={isDesktop ? 24 : 27} 347 + strokeWidth={5.5} 329 348 /> 330 349 } 331 350 iconFilled={
+12
yarn.lock
··· 8202 8202 invariant "*" 8203 8203 prop-types "*" 8204 8204 8205 + dequal@1.0.0: 8206 + version "1.0.0" 8207 + resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.0.tgz#41c6065e70de738541c82cdbedea5292277a017e" 8208 + integrity sha512-/Nd1EQbQbI9UbSHrMiKZjFLrXSnU328iQdZKPQf78XQI6C+gutkFUeoHpG5J08Ioa6HeRbRNFpSIclh1xyG0mw== 8209 + 8205 8210 dequal@^1.0.0: 8206 8211 version "1.0.1" 8207 8212 resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.1.tgz#dbbf9795ec626e9da8bd68782f4add1d23700d8b" ··· 18339 18344 integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w== 18340 18345 dependencies: 18341 18346 tslib "^2.0.0" 18347 + 18348 + use-deep-compare@^1.1.0: 18349 + version "1.1.0" 18350 + resolved "https://registry.yarnpkg.com/use-deep-compare/-/use-deep-compare-1.1.0.tgz#85580dde751f68400bf6ef7e043c7f986595cef8" 18351 + integrity sha512-6yY3zmKNCJ1jjIivfZMZMReZjr8e6iC6Uqtp701jvWJ6ejC/usXD+JjmslZDPJQgX8P4B1Oi5XSLHkOLeYSJsA== 18352 + dependencies: 18353 + dequal "1.0.0" 18342 18354 18343 18355 use-latest-callback@^0.1.5: 18344 18356 version "0.1.6"