Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e52a1cd7 |
@@ -1,3 +1 @@
|
||||
SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
describe('Curate lists', () => {
|
||||
beforeAll(async () => {
|
||||
await createServer('?users&follows&posts')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
})
|
||||
|
||||
it('Login and create a curatelists', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eGotoLists')).tap()
|
||||
await element(by.id('newUserListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Good Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They good')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They good')
|
||||
})
|
||||
|
||||
it('Edit display name and description via the edit curatelist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).clearText()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove description via the edit curatelist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listDescription'))).not.toBeVisible()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit curatelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove avi via the edit curatelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Delete the curatelist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Delete List')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
await expect(element(by.id('listsEmpty'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Create a new curatelist', async () => {
|
||||
await element(by.id('newUserListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Good Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They good')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They good')
|
||||
})
|
||||
|
||||
it('Adds users on curatelists from the list', async () => {
|
||||
await element(by.text('About')).tap()
|
||||
await element(by.id('addUserBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).toBeVisible()
|
||||
await waitFor(element(by.id('user-bob.test-addBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('user-bob.test-addBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('user-bob.test'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows posts by the users in the list', async () => {
|
||||
await element(by.text('Posts')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Pins the list', async () => {
|
||||
await element(by.id('pinBtn')).tap()
|
||||
await element(by.id('e2eGotoHome')).tap()
|
||||
await element(by.id('homeScreenFeedTabs-Good Ppl')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
|
||||
await element(by.id('bottomBarFeedsBtn')).tap()
|
||||
await element(by.id('saved-feed-Good Ppl')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
|
||||
await element(by.id('unpinBtn')).tap()
|
||||
await element(by.id('bottomBarHomeBtn')).tap()
|
||||
await expect(
|
||||
element(by.id('homeScreenFeedTabs-Good Ppl')),
|
||||
).not.toBeVisible()
|
||||
|
||||
await element(by.id('e2eGotoLists')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Removes users on curatelists from the list', async () => {
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('user-bob.test'))).toBeVisible()
|
||||
await element(by.id('user-bob.test-editBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows the curatelist on my profile', async () => {
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Adds and removes users on curatelists from the profile', async () => {
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('bob')
|
||||
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
|
||||
await expect(element(by.id('profileView'))).toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can report a user list', async () => {
|
||||
await element(by.id('e2eGotoSettings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
await loginAsBob()
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('alice')
|
||||
await element(by.id('searchAutoCompleteResult-alice.test')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-3')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Report List')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await expect(element(by.text('Report List'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
describe('Mod lists', () => {
|
||||
describe('Mute lists', () => {
|
||||
beforeAll(async () => {
|
||||
await createServer('?users&follows&labels')
|
||||
await openApp({
|
||||
@@ -10,11 +10,11 @@ describe('Mod lists', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Login and view my modlists', async () => {
|
||||
it('Login and view my mutelists', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eGotoModeration')).tap()
|
||||
await element(by.id('moderationlistsBtn')).tap()
|
||||
await element(by.id('mutelistsBtn')).tap()
|
||||
await expect(element(by.id('list-Muted Users'))).toBeVisible()
|
||||
await element(by.id('list-Muted Users')).tap()
|
||||
await expect(
|
||||
@@ -22,128 +22,101 @@ describe('Mod lists', () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('Toggle mute subscription', async () => {
|
||||
await element(by.id('unmuteBtn')).tap()
|
||||
await element(by.id('subscribeBtn')).tap()
|
||||
await element(by.text('Mute accounts')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
it('Toggle subscription', async () => {
|
||||
await element(by.id('unsubscribeListBtn')).tap()
|
||||
await element(by.id('subscribeListBtn')).tap()
|
||||
})
|
||||
|
||||
it('Edit display name and description via the edit modlist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Edit display name and description via the edit mutelist modal', async () => {
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).clearText()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove description via the edit modlist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Remove description via the edit mutelist modal', async () => {
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listDescription'))).not.toBeVisible()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit modlist modal', async () => {
|
||||
it('Set avi via the edit mutelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove avi via the edit modlist modal', async () => {
|
||||
it('Remove avi via the edit mutelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Delete the modlist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Delete List')).tap()
|
||||
it('Delete the mutelist', async () => {
|
||||
await element(by.id('deleteListBtn')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
await expect(element(by.id('listsEmpty'))).toBeVisible()
|
||||
await expect(element(by.id('emptyMuteLists'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Create a new modlist', async () => {
|
||||
await element(by.id('newModListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Create a new mutelist', async () => {
|
||||
await element(by.id('emptyMuteLists-button')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
})
|
||||
|
||||
it('Adds and removes users on modlists from the list', async () => {
|
||||
await element(by.id('addUserBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).toBeVisible()
|
||||
await waitFor(element(by.id('user-warn-posts.test-addBtn')))
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('user-warn-posts.test-addBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
|
||||
await element(by.id('listItems-flatlist')).swipe(
|
||||
'down',
|
||||
'slow',
|
||||
1,
|
||||
0.5,
|
||||
0.5,
|
||||
)
|
||||
await expect(element(by.id('user-warn-posts.test'))).toBeVisible()
|
||||
await element(by.id('user-warn-posts.test-editBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows the modlist on my profile', async () => {
|
||||
it('Shows the mutelist on my profile', async () => {
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await element(by.id('list-Bad Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Adds and removes users on modlists from the profile', async () => {
|
||||
it('Adds and removes users on mutelists', async () => {
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('bob')
|
||||
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
|
||||
@@ -151,17 +124,17 @@ describe('Mod lists', () => {
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can report a mute list', async () => {
|
||||
@@ -174,8 +147,7 @@ describe('Mod lists', () => {
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-3')).tap()
|
||||
await element(by.id('list-Bad Ppl')).tap()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Report List')).tap()
|
||||
await element(by.id('reportListBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await expect(element(by.text('Report List'))).toBeVisible()
|
||||
await element(
|
||||
+3
-3
@@ -6,7 +6,7 @@ module.exports = function () {
|
||||
slug: 'bluesky',
|
||||
scheme: 'bluesky',
|
||||
owner: 'blueskysocial',
|
||||
version: '1.56.0',
|
||||
version: '1.55.0',
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
@@ -19,7 +19,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '3',
|
||||
buildNumber: '1',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -43,7 +43,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: 46,
|
||||
versionCode: 44,
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#ffffff',
|
||||
|
||||
@@ -37,7 +37,6 @@ module.exports = function (api) {
|
||||
{
|
||||
alias: {
|
||||
// This needs to be mirrored in tsconfig.json
|
||||
'#': './src',
|
||||
lib: './src/lib',
|
||||
platform: './src/platform',
|
||||
state: './src/state',
|
||||
|
||||
@@ -181,9 +181,8 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/lists", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
e.GET("/moderation/mute-lists", server.WebGeneric)
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
|
||||
+14
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
## App Build
|
||||
|
||||
- Set up your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
||||
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
||||
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
|
||||
- yarn global add detox-cli
|
||||
- brew tap wix/brew
|
||||
@@ -20,7 +20,7 @@
|
||||
- iOS: `yarn ios`
|
||||
- Android: `yarn android`
|
||||
- Web: `yarn web`
|
||||
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
|
||||
- If you are cloning or forking this repo as an open source developer, please check the tips below as well
|
||||
- Run e2e tests
|
||||
- Start in various console tabs:
|
||||
- `yarn e2e:mock-server`
|
||||
@@ -28,14 +28,14 @@
|
||||
- Run once: `yarn e2e:build`
|
||||
- Each test run: `yarn e2e:run`
|
||||
- Tips
|
||||
- Copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on the device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` and `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- Make sure to copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- If the Android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The Android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- If the android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- For instance, the locally-hosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
|
||||
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file that doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
|
||||
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
|
||||
|
||||
### Adding Sentry
|
||||
|
||||
@@ -81,24 +81,24 @@ To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-me
|
||||
- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal
|
||||
- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal
|
||||
- iOS Device: Shake the device, or touch 3 fingers to the screen
|
||||
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture or press Cmd ⌘ + d
|
||||
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d
|
||||
|
||||
### Running E2E Tests
|
||||
|
||||
- Make sure you've set your environment following the above
|
||||
- Make sure you've setup your environment following above
|
||||
- Make sure Metro and the dev server are running
|
||||
- Run `yarn e2e`
|
||||
- Find the artifacts in the `artifact` folder
|
||||
|
||||
### Polyfills
|
||||
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
|
||||
### Sentry sourcemaps
|
||||
|
||||
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to take the following steps to upload sourcemaps to Sentry:
|
||||
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry:
|
||||
|
||||
- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step.
|
||||
- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS).
|
||||
@@ -122,6 +122,6 @@ dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
|
||||
|
||||
### OTA updates
|
||||
|
||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. All the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||
|
||||
The clients which can receive an OTA update are governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update to update. If we are manually setting `runtimeVersion`, we should increment the version each time the native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||
|
||||
+13
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.56.0",
|
||||
"version": "1.55.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
@@ -13,25 +13,25 @@
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
"test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"test": "jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "jest --watchAll",
|
||||
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
|
||||
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build": "detox build -c ios.sim.debug",
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
"perf:test": "NODE_ENV=test maestro test",
|
||||
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"perf:test": "maestro test",
|
||||
"perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "flashlight report .perf/results.json",
|
||||
"perf:measure": "flashlight measure",
|
||||
"build:apk": "eas build -p android --profile dev-android-apk"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.6.23",
|
||||
"@atproto/api": "^0.6.21",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -80,7 +80,6 @@
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"base64-js": "^1.5.1",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
@@ -119,7 +118,6 @@
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
"nanoid": "^5.0.2",
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
@@ -130,6 +128,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-native": "0.72.5",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-draggable-flatlist": "^4.0.1",
|
||||
"react-native-drawer-layout": "^3.2.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "^2.12.1",
|
||||
@@ -158,7 +157,6 @@
|
||||
"sentry-expo": "~7.0.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
@@ -238,11 +236,8 @@
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
"transform": {
|
||||
"\\.[jt]sx?$": "babel-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
+13
-18
@@ -43,17 +43,16 @@ import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from './view/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {CustomFeedScreen} from './view/screens/CustomFeed'
|
||||
import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
@@ -96,20 +95,15 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => NotFoundScreen}
|
||||
options={{title: title('Not Found')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Lists"
|
||||
component={ListsScreen}
|
||||
options={{title: title('Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Moderation"
|
||||
getComponent={() => ModerationScreen}
|
||||
options={{title: title('Moderation')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationModlists"
|
||||
getComponent={() => ModerationModlistsScreen}
|
||||
options={{title: title('Moderation Lists')}}
|
||||
name="ModerationMuteLists"
|
||||
getComponent={() => ModerationMuteListsScreen}
|
||||
options={{title: title('Mute Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMutedAccounts"
|
||||
@@ -156,7 +150,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title('List')}}
|
||||
options={{title: title('Mute List')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
@@ -174,13 +168,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
name="CustomFeed"
|
||||
getComponent={() => CustomFeedScreen}
|
||||
options={{title: title('Feed')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeedLikedBy"
|
||||
getComponent={() => ProfileFeedLikedByScreen}
|
||||
name="CustomFeedLikedBy"
|
||||
getComponent={() => CustomFeedLikedByScreen}
|
||||
options={{title: title('Liked by')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -349,6 +343,7 @@ function NotificationsTabNavigator() {
|
||||
|
||||
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
return (
|
||||
<MyProfileTab.Navigator
|
||||
screenOptions={{
|
||||
@@ -363,7 +358,7 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
getComponent={() => ProfileScreen}
|
||||
initialParams={{
|
||||
name: 'me',
|
||||
name: store.me.did,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export const IS_TEST = process.env.NODE_ENV === 'test'
|
||||
export const IS_DEV = __DEV__
|
||||
export const IS_PROD = !IS_DEV
|
||||
export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
|
||||
export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
|
||||
| 'debug'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'error'
|
||||
@@ -10,7 +10,6 @@ import {RootStoreModel, AppInfo} from 'state/models/root-store'
|
||||
import {useStores} from 'state/models/root-store'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {ScreenEvent, TrackEvent} from './types'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const segmentClient = createClient({
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
@@ -55,9 +54,9 @@ export function init(store: RootStoreModel) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
store.log.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
}
|
||||
}
|
||||
@@ -69,19 +68,19 @@ export function init(store: RootStoreModel) {
|
||||
// -prf
|
||||
segmentClient.isReady.onChange(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
logger.debug('Prevented a metrics ping while the app was backgrounded')
|
||||
store.log.debug('Prevented a metrics ping while the app was backgrounded')
|
||||
return
|
||||
}
|
||||
const context = segmentClient.context.get()
|
||||
if (typeof context?.app === 'undefined') {
|
||||
logger.debug('Aborted metrics ping due to unavailable context')
|
||||
store.log.debug('Aborted metrics ping due to unavailable context')
|
||||
return
|
||||
}
|
||||
|
||||
const oldAppInfo = store.appInfo
|
||||
const newAppInfo = context.app as AppInfo
|
||||
store.setAppInfo(newAppInfo)
|
||||
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
|
||||
if (typeof oldAppInfo === 'undefined') {
|
||||
if (store.session.hasSession) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {useStores} from 'state/models/root-store'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const segmentClient = createClient(
|
||||
{
|
||||
@@ -50,9 +49,9 @@ export function init(store: RootStoreModel) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
store.log.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,10 @@ interface TrackPropertiesMap {
|
||||
// LISTS events
|
||||
'Lists:onRefresh': {}
|
||||
'Lists:onEndReached': {}
|
||||
'CreateList:AvatarSelected': {}
|
||||
'CreateList:SaveCurateList': {} // CAN BE SERVER
|
||||
'CreateList:SaveModList': {} // CAN BE SERVER
|
||||
'Lists:Mute': {} // CAN BE SERVER
|
||||
'Lists:Unmute': {} // CAN BE SERVER
|
||||
'Lists:Block': {} // CAN BE SERVER
|
||||
'Lists:Unblock': {} // CAN BE SERVER
|
||||
'CreateMuteList:AvatarSelected': {}
|
||||
'CreateMuteList:Save': {} // CAN BE SERVER
|
||||
'Lists:Subscribe': {} // CAN BE SERVER
|
||||
'Lists:Unsubscribe': {} // CAN BE SERVER
|
||||
// CUSTOM FEED events
|
||||
'CustomFeed:Save': {}
|
||||
'CustomFeed:Unsave': {}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetListFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-41
@@ -114,8 +114,13 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
if (this.customFeeds.length === 0) {
|
||||
this.customFeeds = shuffle(
|
||||
this.rootStore.preferences.savedFeeds.map(
|
||||
feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
|
||||
this.rootStore.me.savedFeeds.all.map(
|
||||
feed =>
|
||||
new MergeFeedSource_Custom(
|
||||
this.rootStore,
|
||||
feed.uri,
|
||||
feed.displayName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -208,56 +213,43 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
minDate: Date
|
||||
|
||||
constructor(public rootStore: RootStoreModel, public feedUri: string) {
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedUri: string,
|
||||
public feedDisplayName: string,
|
||||
) {
|
||||
super(rootStore)
|
||||
this.sourceInfo = {
|
||||
displayName: feedUri.split('/').pop() || '',
|
||||
displayName: feedDisplayName,
|
||||
uri: feedUriToHref(feedUri),
|
||||
}
|
||||
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
|
||||
this.rootStore.agent.app.bsky.feed
|
||||
.getFeedGenerator({
|
||||
feed: feedUri,
|
||||
})
|
||||
.then(
|
||||
res => {
|
||||
if (this.sourceInfo) {
|
||||
this.sourceInfo.displayName = res.data.view.displayName
|
||||
}
|
||||
},
|
||||
_err => {},
|
||||
)
|
||||
}
|
||||
|
||||
protected async _getFeed(
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
try {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
})
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -prf
|
||||
if (limit && res.data.feed.length > limit) {
|
||||
res.data.feed = res.data.feed.slice(0, limit)
|
||||
}
|
||||
// filter out older posts
|
||||
res.data.feed = res.data.feed.filter(
|
||||
post => new Date(post.post.indexedAt) > this.minDate,
|
||||
)
|
||||
// attach source info
|
||||
for (const post of res.data.feed) {
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return res
|
||||
} catch {
|
||||
// dont bubble custom-feed errors
|
||||
return {success: false, headers: {}, data: {feed: []}}
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
})
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -prf
|
||||
if (limit && res.data.feed.length > limit) {
|
||||
res.data.feed = res.data.feed.slice(0, limit)
|
||||
}
|
||||
// filter out older posts
|
||||
res.data.feed = res.data.feed.filter(
|
||||
post => new Date(post.post.indexedAt) > this.minDate,
|
||||
)
|
||||
// attach source info
|
||||
for (const post of res.data.feed) {
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
@@ -179,9 +178,10 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
logger.warn('Unexpected image format for thumbnail, skipping', {
|
||||
thumbnail: opts.extLink.localThumb.path,
|
||||
})
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface AccumulateResponse<T> {
|
||||
cursor?: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export type AccumulateFetchFn<T> = (
|
||||
cursor: string | undefined,
|
||||
) => Promise<AccumulateResponse<T>>
|
||||
|
||||
export async function accumulate<T>(
|
||||
fn: AccumulateFetchFn<T>,
|
||||
pageLimit = 100,
|
||||
): Promise<T[]> {
|
||||
let cursor: string | undefined
|
||||
let acc: T[] = []
|
||||
for (let i = 0; i < pageLimit; i++) {
|
||||
const res = await fn(cursor)
|
||||
cursor = res.cursor
|
||||
acc = acc.concat(res.items)
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {timeout} from './timeout'
|
||||
|
||||
export async function until(
|
||||
retries: number,
|
||||
delay: number,
|
||||
cond: (v: any, err: any) => boolean,
|
||||
fn: () => Promise<any>,
|
||||
): Promise<boolean> {
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const v = await fn()
|
||||
if (cond(v, undefined)) {
|
||||
return true
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (cond(undefined, e)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
await timeout(delay)
|
||||
retries--
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export function useCustomFeed(uri: string): FeedSourceModel | undefined {
|
||||
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
|
||||
const store = useStores()
|
||||
const [item, setItem] = useState<FeedSourceModel | undefined>()
|
||||
const [item, setItem] = useState<CustomFeedModel | undefined>()
|
||||
useEffect(() => {
|
||||
async function fetchView() {
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: uri,
|
||||
})
|
||||
const view = res.data.view
|
||||
return view
|
||||
}
|
||||
async function buildFeedItem() {
|
||||
const model = new FeedSourceModel(store, uri)
|
||||
await model.setup()
|
||||
setItem(model)
|
||||
const view = await fetchView()
|
||||
if (view) {
|
||||
const temp = new CustomFeedModel(store, view)
|
||||
setItem(temp)
|
||||
}
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
|
||||
interface RightNavItem {
|
||||
uri: string
|
||||
href: string
|
||||
hostname: string
|
||||
collection: string
|
||||
rkey: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function useDesktopRightNavItems(uris: string[]): RightNavItem[] {
|
||||
const store = useStores()
|
||||
const [items, setItems] = useState<RightNavItem[]>([])
|
||||
const [lastUris, setLastUris] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEqual(uris, lastUris)) {
|
||||
// no changes
|
||||
return
|
||||
}
|
||||
|
||||
async function fetchFeedInfo() {
|
||||
const models = uris
|
||||
.slice(0, 25)
|
||||
.map(uri => new FeedSourceModel(store, uri))
|
||||
await Promise.all(models.map(m => m.setup()))
|
||||
setItems(
|
||||
models.map(model => {
|
||||
const {hostname, collection, rkey} = new AtUri(model.uri)
|
||||
return {
|
||||
uri: model.uri,
|
||||
href: model.href,
|
||||
hostname,
|
||||
collection,
|
||||
rkey,
|
||||
displayName: model.displayName,
|
||||
}
|
||||
}),
|
||||
)
|
||||
setLastUris(uris)
|
||||
}
|
||||
fetchFeedInfo()
|
||||
}, [store, uris, lastUris, setLastUris, setItems])
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
const store = useStores()
|
||||
@@ -23,7 +22,7 @@ export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
following: false,
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to delete follow', {error: e})
|
||||
store.log.error('Failed to delete follow', e)
|
||||
throw e
|
||||
}
|
||||
} else if (state === FollowState.NotFollowing) {
|
||||
@@ -41,7 +40,7 @@ export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
following: true,
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to create follow', {error: e})
|
||||
store.log.error('Failed to create follow', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
|
||||
export function useHomeTabs(uris: string[]): string[] {
|
||||
const store = useStores()
|
||||
const [tabs, setTabs] = useState<string[]>(['Following'])
|
||||
const [lastUris, setLastUris] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEqual(uris, lastUris)) {
|
||||
// no changes
|
||||
return
|
||||
}
|
||||
|
||||
async function fetchFeedInfo() {
|
||||
const models = uris
|
||||
.slice(0, 25)
|
||||
.map(uri => new FeedSourceModel(store, uri))
|
||||
await Promise.all(models.map(m => m.setup()))
|
||||
setTabs(['Following'].concat(models.map(f => f.displayName)))
|
||||
setLastUris(uris)
|
||||
}
|
||||
fetchFeedInfo()
|
||||
}, [store, uris, lastUris, setLastUris, setTabs])
|
||||
|
||||
return tabs
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function useIsKeyboardVisible({
|
||||
const [isKeyboardVisible, setKeyboardVisible] = useState(false)
|
||||
|
||||
// NOTE
|
||||
// only iOS supports the "will" events
|
||||
// only iOS suppose the "will" events
|
||||
// -prf
|
||||
const showEvent =
|
||||
isIOS && iosUseWillEvents ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as Updates from 'expo-updates'
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export function useOTAUpdate() {
|
||||
const store = useStores()
|
||||
@@ -22,7 +21,7 @@ export function useOTAUpdate() {
|
||||
})
|
||||
}, [store.shell])
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
logger.debug('useOTAUpdate: Checking for update...')
|
||||
store.log.debug('useOTAUpdate: Checking for update...')
|
||||
try {
|
||||
// Check if new OTA update is available
|
||||
const update = await Updates.checkForUpdateAsync()
|
||||
@@ -35,18 +34,18 @@ export function useOTAUpdate() {
|
||||
// show a popup modal
|
||||
showUpdatePopup()
|
||||
} catch (e) {
|
||||
logger.error('useOTAUpdate: Error while checking for update', {
|
||||
error: e,
|
||||
})
|
||||
console.error('useOTAUpdate: Error while checking for update', e)
|
||||
store.log.error('useOTAUpdate: Error while checking for update', e)
|
||||
}
|
||||
}, [showUpdatePopup])
|
||||
}, [showUpdatePopup, store.log])
|
||||
const updateEventListener = useCallback(
|
||||
(event: Updates.UpdateEvent) => {
|
||||
logger.debug('useOTAUpdate: Listening for update...')
|
||||
store.log.debug('useOTAUpdate: Listening for update...')
|
||||
if (event.type === Updates.UpdateEventType.ERROR) {
|
||||
logger.error('useOTAUpdate: Error while listening for update', {
|
||||
message: event.message,
|
||||
})
|
||||
store.log.error(
|
||||
'useOTAUpdate: Error while listening for update',
|
||||
event.message,
|
||||
)
|
||||
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
|
||||
// Handle no update available
|
||||
// do nothing
|
||||
@@ -56,7 +55,7 @@ export function useOTAUpdate() {
|
||||
showUpdatePopup()
|
||||
}
|
||||
},
|
||||
[showUpdatePopup],
|
||||
[showUpdatePopup, store.log],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -947,30 +947,3 @@ export function ShieldExclamation({
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1.5}
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function getLikelyType(url: URL | string): LikelyType {
|
||||
}
|
||||
|
||||
const ext = url.pathname.split('.').pop() || ''
|
||||
if (ext === 'html' || ext === 'htm' || ext === 'php') {
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
return LikelyType.HTML
|
||||
}
|
||||
const mimeType = EXT_MIME_TYPES[ext]
|
||||
|
||||
+3
-12
@@ -17,18 +17,9 @@ export function describeModerationCause(
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocking') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
name: `User Blocked by "${cause.source.list.name}"`,
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'User Blocked',
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
return {
|
||||
name: 'User Blocked',
|
||||
description: 'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocked-by') {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {RootStoreModel} from '../../state'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
@@ -30,27 +29,19 @@ export function init(store: RootStoreModel) {
|
||||
token: token.data,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
logger.debug(
|
||||
'Notifications: Sent push token (init)',
|
||||
{
|
||||
tokenType: token.type,
|
||||
token: token.data,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Sent push token (init)', {
|
||||
type: token.type,
|
||||
token: token.data,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Notifications: Failed to set push token', {error})
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
|
||||
// listens for new changes to the push token
|
||||
// In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
|
||||
Notifications.addPushTokenListener(async ({data: t, type}) => {
|
||||
logger.debug(
|
||||
'Notifications: Push token changed',
|
||||
{t, tokenType: type},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Push token changed', {t, type})
|
||||
if (t) {
|
||||
try {
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
@@ -59,16 +50,12 @@ export function init(store: RootStoreModel) {
|
||||
token: t,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
logger.debug(
|
||||
'Notifications: Sent push token (event)',
|
||||
{
|
||||
tokenType: type,
|
||||
token: t,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Sent push token (event)', {
|
||||
type,
|
||||
token: t,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Notifications: Failed to set push token', {error})
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -76,11 +63,7 @@ export function init(store: RootStoreModel) {
|
||||
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
Notifications.addNotificationReceivedListener(event => {
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{event},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: received', event)
|
||||
if (event.request.trigger.type === 'push') {
|
||||
// refresh notifications in the background
|
||||
store.me.notifications.syncQueue()
|
||||
@@ -92,11 +75,7 @@ export function init(store: RootStoreModel) {
|
||||
// TODO: handle android payload deeplink
|
||||
}
|
||||
if (payload) {
|
||||
logger.debug(
|
||||
'Notifications: received payload',
|
||||
payload,
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: received payload', payload)
|
||||
// TODO: deeplink notif here
|
||||
}
|
||||
}
|
||||
@@ -105,20 +84,15 @@ export function init(store: RootStoreModel) {
|
||||
// handle notifications that are tapped on
|
||||
const sub = Notifications.addNotificationResponseReceivedListener(
|
||||
response => {
|
||||
logger.debug(
|
||||
store.log.debug(
|
||||
'Notifications: response received',
|
||||
{
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
response.actionIdentifier,
|
||||
)
|
||||
if (
|
||||
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
|
||||
) {
|
||||
logger.debug(
|
||||
store.log.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
store.me.notifications.refresh() // refresh notifications
|
||||
|
||||
@@ -13,15 +13,3 @@ export function makeProfileLink(
|
||||
...segments,
|
||||
].join('/')
|
||||
}
|
||||
|
||||
export function makeCustomFeedLink(
|
||||
did: string,
|
||||
rkey: string,
|
||||
...segments: string[]
|
||||
) {
|
||||
return [`/profile`, did, 'feed', rkey, ...segments].join('/')
|
||||
}
|
||||
|
||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
export type CommonNavigatorParams = {
|
||||
NotFound: undefined
|
||||
Lists: undefined
|
||||
Moderation: undefined
|
||||
ModerationModlists: undefined
|
||||
ModerationMuteLists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
Settings: undefined
|
||||
@@ -19,8 +18,8 @@ export type CommonNavigatorParams = {
|
||||
PostThread: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
ProfileFeed: {name: string; rkey: string}
|
||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||
CustomFeed: {name: string; rkey: string}
|
||||
CustomFeedLikedBy: {name: string; rkey: string}
|
||||
Debug: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Logger
|
||||
|
||||
Simple logger for Bluesky. Supports log levels, debug contexts, and separate
|
||||
transports for production, dev, and test mode.
|
||||
|
||||
## At a Glance
|
||||
|
||||
```typescript
|
||||
import { logger } from '#/logger'
|
||||
|
||||
logger.debug(message[, metadata, debugContext])
|
||||
logger.info(message[, metadata])
|
||||
logger.log(message[, metadata])
|
||||
logger.warn(message[, metadata])
|
||||
logger.error(error[, metadata])
|
||||
```
|
||||
|
||||
#### Modes
|
||||
|
||||
The "modes" referred to here are inferred from the values exported from `#/env`.
|
||||
Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`.
|
||||
|
||||
#### Log Levels
|
||||
|
||||
Log levels are used to filter which logs are either printed to the console
|
||||
and/or sent to Sentry and other reporting services. To configure, set the
|
||||
`EXPO_PUBLIC_LOG_LEVEL` environment variable in `.env` to one of `debug`,
|
||||
`info`, `log`, `warn`, or `error`.
|
||||
|
||||
This variable should be `info` in production, and `debug` in dev. If it gets too
|
||||
noisy in dev, simply set it to a higher level, such as `warn`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { logger } from '#/logger';
|
||||
```
|
||||
|
||||
### `logger.error`
|
||||
|
||||
The `error` level is for... well, errors. These are sent to Sentry in production mode.
|
||||
|
||||
`error`, along with all log levels, supports an additional parameter, `metadata: Record<string, unknown>`. Use this to provide values to the [Sentry
|
||||
breadcrumb](https://docs.sentry.io/platforms/react-native/enriching-events/breadcrumbs/#manual-breadcrumbs).
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// some async code
|
||||
} catch (e) {
|
||||
logger.error(e, { ...metadata });
|
||||
}
|
||||
```
|
||||
|
||||
### `logger.warn`
|
||||
|
||||
Warnings will be sent to Sentry as a separate Issue with level `warning`, as
|
||||
well as as breadcrumbs, with a severity level of `warning`
|
||||
|
||||
### `logger.log`
|
||||
|
||||
Logs with level `log` will be sent to Sentry as a separate Issue with level `log`, as
|
||||
well as as breadcrumbs, with a severity level of `default`.
|
||||
|
||||
### `logger.info`
|
||||
|
||||
The `info` level should be used for information that would be helpful in a
|
||||
tracing context, like Sentry. In production mode, `info` logs are sent
|
||||
to Sentry as breadcrumbs, which decorate log levels above `info` such as `log`,
|
||||
`warn`, and `error`.
|
||||
|
||||
### `logger.debug`
|
||||
|
||||
Debug level is really only intended for local development. Use this instead of
|
||||
`console.log`.
|
||||
|
||||
```typescript
|
||||
logger.debug(message, { ...metadata });
|
||||
```
|
||||
|
||||
Inspired by [debug](https://www.npmjs.com/package/debug), when writing debug
|
||||
logs, you can optionally pass a _context_, which can be then filtered when in
|
||||
debug mode.
|
||||
|
||||
This value should be related to the feature, component, or screen
|
||||
the code is running within, and **it should be defined in `#/logger/debugContext`**.
|
||||
This way we know if a relevant context already exists, and we can trace all
|
||||
active contexts in use in our app. This const enum is conveniently available on
|
||||
the `logger` at `logger.DebugContext`.
|
||||
|
||||
For example, a debug log like this:
|
||||
|
||||
```typescript
|
||||
logger.debug(message, {}, logger.DebugContext.composer);
|
||||
```
|
||||
|
||||
Would be logged to the console in dev mode if `EXPO_PUBLIC_LOG_LEVEL=debug`, _or_ if you
|
||||
pass a separate environment variable `LOG_DEBUG=composer`. This variable supports
|
||||
multiple contexts using commas like `LOG_DEBUG=composer,profile`, and _automatically
|
||||
sets the log level to `debug`, regardless of `EXPO_PUBLIC_LOG_LEVEL`._
|
||||
@@ -1,36 +0,0 @@
|
||||
import {expect, test} from '@jest/globals'
|
||||
|
||||
import {ConsoleTransportEntry, LogLevel} from '#/logger'
|
||||
import {add, getEntries} from '#/logger/logDump'
|
||||
|
||||
test('works', () => {
|
||||
const items: ConsoleTransportEntry[] = [
|
||||
{
|
||||
id: '1',
|
||||
level: LogLevel.Debug,
|
||||
message: 'hello',
|
||||
metadata: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
level: LogLevel.Debug,
|
||||
message: 'hello',
|
||||
metadata: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
level: LogLevel.Debug,
|
||||
message: 'hello',
|
||||
metadata: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of items) {
|
||||
add(item)
|
||||
}
|
||||
|
||||
expect(getEntries()).toEqual(items.reverse())
|
||||
})
|
||||
@@ -1,424 +0,0 @@
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
import {jest, describe, expect, test, beforeAll} from '@jest/globals'
|
||||
import {Native as Sentry} from 'sentry-expo'
|
||||
|
||||
import {Logger, LogLevel, sentryTransport} from '#/logger'
|
||||
|
||||
jest.mock('#/env', () => ({
|
||||
IS_TEST: true,
|
||||
IS_DEV: false,
|
||||
IS_PROD: false,
|
||||
/*
|
||||
* Forces debug mode for tests using the default logger. Most tests create
|
||||
* their own logger instance.
|
||||
*/
|
||||
LOG_LEVEL: 'debug',
|
||||
LOG_DEBUG: '',
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
Native: {
|
||||
addBreadcrumb: jest.fn(),
|
||||
captureException: jest.fn(),
|
||||
captureMessage: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('general functionality', () => {
|
||||
test('default params', () => {
|
||||
const logger = new Logger()
|
||||
expect(logger.enabled).toBeFalsy()
|
||||
expect(logger.level).toEqual(LogLevel.Debug) // mocked above
|
||||
})
|
||||
|
||||
test('can override default params', () => {
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Info,
|
||||
})
|
||||
expect(logger.enabled).toBeTruthy()
|
||||
expect(logger.level).toEqual(LogLevel.Info)
|
||||
})
|
||||
|
||||
test('disabled logger does not report', () => {
|
||||
const logger = new Logger({
|
||||
enabled: false,
|
||||
level: LogLevel.Debug,
|
||||
})
|
||||
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
logger.debug('message')
|
||||
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('disablement', () => {
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Debug,
|
||||
})
|
||||
|
||||
logger.disable()
|
||||
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
logger.debug('message')
|
||||
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('passing debug contexts automatically enables debug mode', () => {
|
||||
const logger = new Logger({debug: 'specific'})
|
||||
expect(logger.level).toEqual(LogLevel.Debug)
|
||||
})
|
||||
|
||||
test('supports extra metadata', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({enabled: true})
|
||||
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
|
||||
const extra = {foo: true}
|
||||
logger.warn('message', extra)
|
||||
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
'message',
|
||||
extra,
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
|
||||
test('supports nullish/falsy metadata', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({enabled: true})
|
||||
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
const remove = logger.addTransport(mockTransport)
|
||||
|
||||
// @ts-expect-error testing the JS case
|
||||
logger.warn('a', null)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
'a',
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
// @ts-expect-error testing the JS case
|
||||
logger.warn('b', false)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
'b',
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
// @ts-expect-error testing the JS case
|
||||
logger.warn('c', 0)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
'c',
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
remove()
|
||||
|
||||
logger.addTransport((level, message, metadata) => {
|
||||
expect(typeof metadata).toEqual('object')
|
||||
})
|
||||
|
||||
// @ts-expect-error testing the JS case
|
||||
logger.warn('message', null)
|
||||
})
|
||||
|
||||
test('sentryTransport', () => {
|
||||
const message = 'message'
|
||||
const timestamp = Date.now()
|
||||
const sentryTimestamp = timestamp / 1000
|
||||
|
||||
sentryTransport(LogLevel.Debug, message, {}, timestamp)
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
message,
|
||||
data: {},
|
||||
type: 'default',
|
||||
level: LogLevel.Debug,
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
|
||||
sentryTransport(
|
||||
LogLevel.Info,
|
||||
message,
|
||||
{type: 'info', prop: true},
|
||||
timestamp,
|
||||
)
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
message,
|
||||
data: {prop: true},
|
||||
type: 'info',
|
||||
level: LogLevel.Info,
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
|
||||
sentryTransport(LogLevel.Log, message, {}, timestamp)
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
message,
|
||||
data: {},
|
||||
type: 'default',
|
||||
level: 'debug', // Sentry bug, log becomes debug
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
|
||||
level: 'log',
|
||||
tags: undefined,
|
||||
extra: {},
|
||||
})
|
||||
|
||||
sentryTransport(LogLevel.Warn, message, {}, timestamp)
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
message,
|
||||
data: {},
|
||||
type: 'default',
|
||||
level: 'warning',
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
|
||||
level: 'warning',
|
||||
tags: undefined,
|
||||
extra: {},
|
||||
})
|
||||
|
||||
const e = new Error('error')
|
||||
const tags = {
|
||||
prop: 'prop',
|
||||
}
|
||||
|
||||
sentryTransport(
|
||||
LogLevel.Error,
|
||||
e,
|
||||
{
|
||||
tags,
|
||||
prop: true,
|
||||
},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(e, {
|
||||
tags,
|
||||
extra: {
|
||||
prop: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('add/remove transport', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({enabled: true})
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
const remove = logger.addTransport(mockTransport)
|
||||
|
||||
logger.warn('warn')
|
||||
|
||||
remove()
|
||||
|
||||
logger.warn('warn')
|
||||
|
||||
// only called once bc it was removed
|
||||
expect(mockTransport).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
LogLevel.Warn,
|
||||
'warn',
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('debug contexts', () => {
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
test('specific', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
debug: 'specific',
|
||||
})
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
logger.debug(message, {}, 'specific')
|
||||
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Debug,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
|
||||
test('namespaced', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
debug: 'namespace*',
|
||||
})
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
logger.debug(message, {}, 'namespace')
|
||||
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Debug,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores inactive', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
debug: 'namespace:foo:*',
|
||||
})
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
logger.debug(message, {}, 'namespace:bar:baz')
|
||||
|
||||
expect(mockTransport).not.toHaveBeenCalledWith(
|
||||
LogLevel.Debug,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supports levels', () => {
|
||||
test('debug', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Debug,
|
||||
})
|
||||
const message = nanoid()
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
|
||||
logger.debug(message)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Debug,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
logger.info(message)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Info,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
logger.warn(message)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
|
||||
const e = new Error(message)
|
||||
logger.error(e)
|
||||
expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
|
||||
})
|
||||
|
||||
test('info', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Info,
|
||||
})
|
||||
const message = nanoid()
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
|
||||
logger.debug(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
logger.info(message)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Info,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
|
||||
test('warn', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Warn,
|
||||
})
|
||||
const message = nanoid()
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
|
||||
logger.debug(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
logger.info(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
logger.warn(message)
|
||||
expect(mockTransport).toHaveBeenCalledWith(
|
||||
LogLevel.Warn,
|
||||
message,
|
||||
{},
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
|
||||
test('error', () => {
|
||||
const timestamp = Date.now()
|
||||
const logger = new Logger({
|
||||
enabled: true,
|
||||
level: LogLevel.Error,
|
||||
})
|
||||
const message = nanoid()
|
||||
const mockTransport = jest.fn()
|
||||
|
||||
logger.addTransport(mockTransport)
|
||||
|
||||
logger.debug(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
logger.info(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
logger.warn(message)
|
||||
expect(mockTransport).not.toHaveBeenCalled()
|
||||
|
||||
const e = new Error('original message')
|
||||
logger.error(e)
|
||||
expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* *Do not import this directly.* Instead, use the shortcut reference `logger.DebugContext`.
|
||||
*
|
||||
* Add debug contexts here. Although convention typically calls for enums ito
|
||||
* be capitalized, for parity with the `LOG_DEBUG` env var, please use all
|
||||
* lowercase.
|
||||
*/
|
||||
export const DebugContext = {
|
||||
// e.g. composer: 'composer'
|
||||
session: 'session',
|
||||
notifications: 'notifications',
|
||||
} as const
|
||||
@@ -1,290 +0,0 @@
|
||||
import format from 'date-fns/format'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {Sentry} from '#/logger/sentry'
|
||||
import * as env from '#/env'
|
||||
import {DebugContext} from '#/logger/debugContext'
|
||||
import {add} from '#/logger/logDump'
|
||||
|
||||
export enum LogLevel {
|
||||
Debug = 'debug',
|
||||
Info = 'info',
|
||||
Log = 'log',
|
||||
Warn = 'warn',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
type Transport = (
|
||||
level: LogLevel,
|
||||
message: string | Error,
|
||||
metadata: Metadata,
|
||||
timestamp: number,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* A union of some of Sentry's breadcrumb properties as well as Sentry's
|
||||
* `captureException` parameter, `CaptureContext`.
|
||||
*/
|
||||
type Metadata = {
|
||||
/**
|
||||
* Applied as Sentry breadcrumb types. Defaults to `default`.
|
||||
*
|
||||
* @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
|
||||
*/
|
||||
type?:
|
||||
| 'default'
|
||||
| 'debug'
|
||||
| 'error'
|
||||
| 'navigation'
|
||||
| 'http'
|
||||
| 'info'
|
||||
| 'query'
|
||||
| 'transaction'
|
||||
| 'ui'
|
||||
| 'user'
|
||||
|
||||
/**
|
||||
* Passed through to `Sentry.captureException`
|
||||
*
|
||||
* @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
|
||||
*/
|
||||
tags?: {
|
||||
[key: string]:
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| bigint
|
||||
| symbol
|
||||
| null
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Any additional data, passed through to Sentry as `extra` param on
|
||||
* exceptions, or the `data` param on breadcrumbs.
|
||||
*/
|
||||
[key: string]: unknown
|
||||
} & Parameters<typeof Sentry.captureException>[1]
|
||||
|
||||
export type ConsoleTransportEntry = {
|
||||
id: string
|
||||
timestamp: number
|
||||
level: LogLevel
|
||||
message: string | Error
|
||||
metadata: Metadata
|
||||
}
|
||||
|
||||
const enabledLogLevels: {
|
||||
[key in LogLevel]: LogLevel[]
|
||||
} = {
|
||||
[LogLevel.Debug]: [
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
LogLevel.Log,
|
||||
LogLevel.Warn,
|
||||
LogLevel.Error,
|
||||
],
|
||||
[LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
|
||||
[LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
|
||||
[LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
|
||||
[LogLevel.Error]: [LogLevel.Error],
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in dev mode to nicely log to the console
|
||||
*/
|
||||
export const consoleTransport: Transport = (
|
||||
level,
|
||||
message,
|
||||
metadata,
|
||||
timestamp,
|
||||
) => {
|
||||
const extra = Object.keys(metadata).length
|
||||
? ' ' + JSON.stringify(metadata, null, ' ')
|
||||
: ''
|
||||
const log = {
|
||||
[LogLevel.Debug]: console.debug,
|
||||
[LogLevel.Info]: console.info,
|
||||
[LogLevel.Log]: console.log,
|
||||
[LogLevel.Warn]: console.warn,
|
||||
[LogLevel.Error]: console.error,
|
||||
}[level]
|
||||
|
||||
log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
|
||||
}
|
||||
|
||||
export const sentryTransport: Transport = (
|
||||
level,
|
||||
message,
|
||||
{type, tags, ...metadata},
|
||||
timestamp,
|
||||
) => {
|
||||
/**
|
||||
* If a string, report a breadcrumb
|
||||
*/
|
||||
if (typeof message === 'string') {
|
||||
const severity = (
|
||||
{
|
||||
[LogLevel.Debug]: 'debug',
|
||||
[LogLevel.Info]: 'info',
|
||||
[LogLevel.Log]: 'log', // Sentry value here is undefined
|
||||
[LogLevel.Warn]: 'warning',
|
||||
[LogLevel.Error]: 'error',
|
||||
} as const
|
||||
)[level]
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
data: metadata,
|
||||
type: type || 'default',
|
||||
level: severity,
|
||||
timestamp: timestamp / 1000, // Sentry expects seconds
|
||||
})
|
||||
|
||||
/**
|
||||
* Send all higher levels with `captureMessage`, with appropriate severity
|
||||
* level
|
||||
*/
|
||||
if (level === 'error' || level === 'warn' || level === 'log') {
|
||||
const messageLevel = ({
|
||||
[LogLevel.Log]: 'log',
|
||||
[LogLevel.Warn]: 'warning',
|
||||
[LogLevel.Error]: 'error',
|
||||
}[level] || 'log') as Sentry.Breadcrumb['level']
|
||||
|
||||
Sentry.captureMessage(message, {
|
||||
level: messageLevel,
|
||||
tags,
|
||||
extra: metadata,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* It's otherwise an Error and should be reported with captureException
|
||||
*/
|
||||
Sentry.captureException(message, {
|
||||
tags,
|
||||
extra: metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main class. Defaults are provided in the constructor so that subclasses are
|
||||
* technically possible, if we need to go that route in the future.
|
||||
*/
|
||||
export class Logger {
|
||||
LogLevel = LogLevel
|
||||
DebugContext = DebugContext
|
||||
|
||||
enabled: boolean
|
||||
level: LogLevel
|
||||
transports: Transport[] = []
|
||||
|
||||
protected debugContextRegexes: RegExp[] = []
|
||||
|
||||
constructor({
|
||||
enabled = !env.IS_TEST,
|
||||
level = env.LOG_LEVEL as LogLevel,
|
||||
debug = env.LOG_DEBUG || '',
|
||||
}: {
|
||||
enabled?: boolean
|
||||
level?: LogLevel
|
||||
debug?: string
|
||||
} = {}) {
|
||||
this.enabled = enabled !== false
|
||||
this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info
|
||||
this.debugContextRegexes = (debug || '').split(',').map(context => {
|
||||
return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'))
|
||||
})
|
||||
}
|
||||
|
||||
debug(message: string, metadata: Metadata = {}, context?: string) {
|
||||
if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
|
||||
return
|
||||
this.transport(LogLevel.Debug, message, metadata)
|
||||
}
|
||||
|
||||
info(message: string, metadata: Metadata = {}) {
|
||||
this.transport(LogLevel.Info, message, metadata)
|
||||
}
|
||||
|
||||
log(message: string, metadata: Metadata = {}) {
|
||||
this.transport(LogLevel.Log, message, metadata)
|
||||
}
|
||||
|
||||
warn(message: string, metadata: Metadata = {}) {
|
||||
this.transport(LogLevel.Warn, message, metadata)
|
||||
}
|
||||
|
||||
error(error: Error | string, metadata: Metadata = {}) {
|
||||
this.transport(LogLevel.Error, error, metadata)
|
||||
}
|
||||
|
||||
addTransport(transport: Transport) {
|
||||
this.transports.push(transport)
|
||||
return () => {
|
||||
this.transports.splice(this.transports.indexOf(transport), 1)
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
protected transport(
|
||||
level: LogLevel,
|
||||
message: string | Error,
|
||||
metadata: Metadata = {},
|
||||
) {
|
||||
if (!this.enabled) return
|
||||
if (!enabledLogLevels[this.level].includes(level)) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
const meta = metadata || {}
|
||||
|
||||
for (const transport of this.transports) {
|
||||
transport(level, message, meta, timestamp)
|
||||
}
|
||||
|
||||
add({
|
||||
id: nanoid(),
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
metadata: meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger instance. See `@/logger/README` for docs.
|
||||
*
|
||||
* Basic usage:
|
||||
*
|
||||
* `logger.debug(message[, metadata, debugContext])`
|
||||
* `logger.info(message[, metadata])`
|
||||
* `logger.warn(message[, metadata])`
|
||||
* `logger.error(error[, metadata])`
|
||||
* `logger.disable()`
|
||||
* `logger.enable()`
|
||||
*/
|
||||
export const logger = new Logger()
|
||||
|
||||
/**
|
||||
* Report to console in dev, Sentry in prod, nothing in test.
|
||||
*/
|
||||
if (env.IS_DEV && !env.IS_TEST) {
|
||||
logger.addTransport(consoleTransport)
|
||||
|
||||
/**
|
||||
* Uncomment this to test Sentry in dev
|
||||
*/
|
||||
// logger.addTransport(sentryTransport);
|
||||
} else if (env.IS_PROD) {
|
||||
logger.addTransport(sentryTransport)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type {ConsoleTransportEntry} from '#/logger'
|
||||
|
||||
let entries: ConsoleTransportEntry[] = []
|
||||
|
||||
export function add(entry: ConsoleTransportEntry) {
|
||||
entries.unshift(entry)
|
||||
entries = entries.slice(0, 50)
|
||||
}
|
||||
|
||||
export function getEntries() {
|
||||
return entries
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {Native as Sentry} from 'sentry-expo'
|
||||
@@ -1 +0,0 @@
|
||||
export {Browser as Sentry} from 'sentry-expo'
|
||||
+3
-4
@@ -7,9 +7,8 @@ export const router = new Router({
|
||||
Notifications: '/notifications',
|
||||
Settings: '/settings',
|
||||
LanguageSettings: '/settings/language',
|
||||
Lists: '/lists',
|
||||
Moderation: '/moderation',
|
||||
ModerationModlists: '/moderation/modlists',
|
||||
ModerationMuteLists: '/moderation/mute-lists',
|
||||
ModerationMutedAccounts: '/moderation/muted-accounts',
|
||||
ModerationBlockedAccounts: '/moderation/blocked-accounts',
|
||||
Profile: '/profile/:name',
|
||||
@@ -19,8 +18,8 @@ export const router = new Router({
|
||||
PostThread: '/profile/:name/post/:rkey',
|
||||
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
|
||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||
ProfileFeed: '/profile/:name/feed/:rkey',
|
||||
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
||||
CustomFeed: '/profile/:name/feed/:rkey',
|
||||
CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
||||
Debug: '/sys/debug',
|
||||
Log: '/sys/log',
|
||||
AppPasswords: '/settings/app-passwords',
|
||||
|
||||
+2
-3
@@ -4,7 +4,6 @@ import {BskyAgent} from '@atproto/api'
|
||||
import {RootStoreModel} from './models/root-store'
|
||||
import * as apiPolyfill from 'lib/api/api-polyfill'
|
||||
import * as storage from 'lib/storage'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
@@ -23,10 +22,10 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
||||
rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
|
||||
try {
|
||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||
logger.debug('Initial hydrate', {hasSession: !!data.session})
|
||||
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
|
||||
rootStore.hydrate(data)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to load state from storage', {error: e})
|
||||
rootStore.log.error('Failed to load state from storage', e)
|
||||
}
|
||||
rootStore.attemptSessionResumption()
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class FeedSourceModel {
|
||||
// state
|
||||
_reactKey: string
|
||||
hasLoaded = false
|
||||
error: string | undefined
|
||||
|
||||
// data
|
||||
uri: string
|
||||
cid: string = ''
|
||||
type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
|
||||
avatar: string | undefined = ''
|
||||
displayName: string = ''
|
||||
descriptionRT: RichText | null = null
|
||||
creatorDid: string = ''
|
||||
creatorHandle: string = ''
|
||||
likeCount: number | undefined = 0
|
||||
likeUri: string | undefined = ''
|
||||
|
||||
constructor(public rootStore: RootStoreModel, uri: string) {
|
||||
this._reactKey = uri
|
||||
this.uri = uri
|
||||
|
||||
try {
|
||||
const urip = new AtUri(uri)
|
||||
if (urip.collection === 'app.bsky.feed.generator') {
|
||||
this.type = 'feed-generator'
|
||||
} else if (urip.collection === 'app.bsky.graph.list') {
|
||||
this.type = 'list'
|
||||
}
|
||||
} catch {}
|
||||
this.displayName = uri.split('/').pop() || ''
|
||||
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get href() {
|
||||
const urip = new AtUri(this.uri)
|
||||
const collection =
|
||||
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
|
||||
return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
|
||||
}
|
||||
|
||||
get isSaved() {
|
||||
return this.rootStore.preferences.savedFeeds.includes(this.uri)
|
||||
}
|
||||
|
||||
get isPinned() {
|
||||
return this.rootStore.preferences.isPinnedFeed(this.uri)
|
||||
}
|
||||
|
||||
get isLiked() {
|
||||
return !!this.likeUri
|
||||
}
|
||||
|
||||
get isOwner() {
|
||||
return this.creatorDid === this.rootStore.me.did
|
||||
}
|
||||
|
||||
setup = bundleAsync(async () => {
|
||||
try {
|
||||
if (this.type === 'feed-generator') {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: this.uri,
|
||||
})
|
||||
this.hydrateFeedGenerator(res.data.view)
|
||||
} else if (this.type === 'list') {
|
||||
const res = await this.rootStore.agent.app.bsky.graph.getList({
|
||||
list: this.uri,
|
||||
limit: 1,
|
||||
})
|
||||
this.hydrateList(res.data.list)
|
||||
}
|
||||
} catch (e) {
|
||||
runInAction(() => {
|
||||
this.error = cleanError(e)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
|
||||
this.uri = view.uri
|
||||
this.cid = view.cid
|
||||
this.avatar = view.avatar
|
||||
this.displayName = view.displayName
|
||||
? sanitizeDisplayName(view.displayName)
|
||||
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
|
||||
this.descriptionRT = new RichText({
|
||||
text: view.description || '',
|
||||
facets: (view.descriptionFacets || [])?.slice(),
|
||||
})
|
||||
this.creatorDid = view.creator.did
|
||||
this.creatorHandle = view.creator.handle
|
||||
this.likeCount = view.likeCount
|
||||
this.likeUri = view.viewer?.like
|
||||
this.hasLoaded = true
|
||||
}
|
||||
|
||||
hydrateList(view: AppBskyGraphDefs.ListView) {
|
||||
this.uri = view.uri
|
||||
this.cid = view.cid
|
||||
this.avatar = view.avatar
|
||||
this.displayName = view.name
|
||||
? sanitizeDisplayName(view.name)
|
||||
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`
|
||||
this.descriptionRT = new RichText({
|
||||
text: view.description || '',
|
||||
facets: (view.descriptionFacets || [])?.slice(),
|
||||
})
|
||||
this.creatorDid = view.creator.did
|
||||
this.creatorHandle = view.creator.handle
|
||||
this.likeCount = undefined
|
||||
this.hasLoaded = true
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.rootStore.preferences.addSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Save')
|
||||
}
|
||||
}
|
||||
|
||||
async unsave() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to unsave feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Unsave')
|
||||
}
|
||||
}
|
||||
|
||||
async pin() {
|
||||
try {
|
||||
await this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to pin feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async togglePin() {
|
||||
if (!this.isPinned) {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
return this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} else {
|
||||
track('CustomFeed:Unpin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
return this.rootStore.preferences.removePinnedFeed(this.uri)
|
||||
}
|
||||
}
|
||||
|
||||
async like() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.likeUri = 'pending'
|
||||
this.likeCount = (this.likeCount || 0) + 1
|
||||
const res = await this.rootStore.agent.like(this.uri, this.cid)
|
||||
this.likeUri = res.uri
|
||||
} catch (e: any) {
|
||||
this.likeUri = undefined
|
||||
this.likeCount = (this.likeCount || 1) - 1
|
||||
logger.error('Failed to like feed', {error: e})
|
||||
} finally {
|
||||
track('CustomFeed:Like')
|
||||
}
|
||||
}
|
||||
|
||||
async unlike() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
if (!this.likeUri) {
|
||||
return
|
||||
}
|
||||
const uri = this.likeUri
|
||||
try {
|
||||
this.likeUri = undefined
|
||||
this.likeCount = (this.likeCount || 1) - 1
|
||||
await this.rootStore.agent.deleteLike(uri!)
|
||||
} catch (e: any) {
|
||||
this.likeUri = uri
|
||||
this.likeCount = (this.likeCount || 0) + 1
|
||||
logger.error('Failed to unlike feed', {error: e})
|
||||
} finally {
|
||||
track('CustomFeed:Unlike')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,21 +110,14 @@ export class ListMembershipModel {
|
||||
})
|
||||
}
|
||||
|
||||
async updateTo(
|
||||
uris: string[],
|
||||
): Promise<{added: string[]; removed: string[]}> {
|
||||
const added = []
|
||||
const removed = []
|
||||
async updateTo(uris: string[]) {
|
||||
for (const uri of uris) {
|
||||
await this.add(uri)
|
||||
added.push(uri)
|
||||
}
|
||||
for (const membership of this.memberships) {
|
||||
if (!uris.includes(membership.value.list)) {
|
||||
await this.remove(membership.value.list)
|
||||
removed.push(membership.value.list)
|
||||
}
|
||||
}
|
||||
return {added, removed}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AtUri,
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetList as GetList,
|
||||
AppBskyGraphDefs as GraphDefs,
|
||||
AppBskyGraphList,
|
||||
AppBskyGraphListitem,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import chunk from 'lodash.chunk'
|
||||
@@ -15,8 +13,6 @@ import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {until} from 'lib/async/until'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -41,32 +37,19 @@ export class ListModel {
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
data: GraphDefs.ListView | null = null
|
||||
list: GraphDefs.ListView | null = null
|
||||
items: GraphDefs.ListItemView[] = []
|
||||
descriptionRT: RichText | null = null
|
||||
|
||||
static async createList(
|
||||
static async createModList(
|
||||
rootStore: RootStoreModel,
|
||||
{
|
||||
purpose,
|
||||
name,
|
||||
description,
|
||||
avatar,
|
||||
}: {
|
||||
purpose: string
|
||||
name: string
|
||||
description: string
|
||||
avatar: RNImage | null | undefined
|
||||
},
|
||||
}: {name: string; description: string; avatar: RNImage | null | undefined},
|
||||
) {
|
||||
if (
|
||||
purpose !== 'app.bsky.graph.defs#curatelist' &&
|
||||
purpose !== 'app.bsky.graph.defs#modlist'
|
||||
) {
|
||||
throw new Error('Invalid list purpose: must be curatelist or modlist')
|
||||
}
|
||||
const record: AppBskyGraphList.Record = {
|
||||
purpose,
|
||||
purpose: 'app.bsky.graph.defs#modlist',
|
||||
name,
|
||||
description,
|
||||
avatar: undefined,
|
||||
@@ -86,20 +69,7 @@ export class ListModel {
|
||||
},
|
||||
record,
|
||||
)
|
||||
|
||||
// wait for the appview to update
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
(v: GetList.Response, _e: any) => {
|
||||
return typeof v?.data?.list.uri === 'string'
|
||||
},
|
||||
() =>
|
||||
rootStore.agent.app.bsky.graph.getList({
|
||||
list: res.uri,
|
||||
limit: 1,
|
||||
}),
|
||||
)
|
||||
await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -125,40 +95,16 @@ export class ListModel {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get isCuratelist() {
|
||||
return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
|
||||
}
|
||||
|
||||
get isModlist() {
|
||||
return this.data?.purpose === 'app.bsky.graph.defs#modlist'
|
||||
}
|
||||
|
||||
get isOwner() {
|
||||
return this.data?.creator.did === this.rootStore.me.did
|
||||
return this.list?.creator.did === this.rootStore.me.did
|
||||
}
|
||||
|
||||
get isBlocking() {
|
||||
return !!this.data?.viewer?.blocked
|
||||
}
|
||||
|
||||
get isMuting() {
|
||||
return !!this.data?.viewer?.muted
|
||||
}
|
||||
|
||||
get isPinned() {
|
||||
return this.rootStore.preferences.isPinnedFeed(this.uri)
|
||||
get isSubscribed() {
|
||||
return this.list?.viewer?.muted
|
||||
}
|
||||
|
||||
get creatorDid() {
|
||||
return this.data?.creator.did
|
||||
}
|
||||
|
||||
getMembership(did: string) {
|
||||
return this.items.find(item => item.subject.did === did)
|
||||
}
|
||||
|
||||
isMember(did: string) {
|
||||
return !!this.getMembership(did)
|
||||
return this.list?.creator.did
|
||||
}
|
||||
|
||||
// public api
|
||||
@@ -191,15 +137,6 @@ export class ListModel {
|
||||
}
|
||||
})
|
||||
|
||||
async loadAll() {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (!this.hasMore) {
|
||||
break
|
||||
}
|
||||
await this.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadata({
|
||||
name,
|
||||
description,
|
||||
@@ -209,7 +146,7 @@ export class ListModel {
|
||||
description: string
|
||||
avatar: RNImage | null | undefined
|
||||
}) {
|
||||
if (!this.data) {
|
||||
if (!this.list) {
|
||||
return
|
||||
}
|
||||
if (!this.isOwner) {
|
||||
@@ -246,7 +183,7 @@ export class ListModel {
|
||||
}
|
||||
|
||||
async delete() {
|
||||
if (!this.data) {
|
||||
if (!this.list) {
|
||||
return
|
||||
}
|
||||
await this._resolveUri()
|
||||
@@ -291,144 +228,31 @@ export class ListModel {
|
||||
})
|
||||
}
|
||||
|
||||
/* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
this.rootStore.emitListDeleted(this.uri)
|
||||
}
|
||||
|
||||
async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
if (this.isMember(profile.did)) {
|
||||
return
|
||||
}
|
||||
await this.rootStore.agent.app.bsky.graph.listitem.create(
|
||||
{
|
||||
repo: this.rootStore.me.did,
|
||||
},
|
||||
{
|
||||
subject: profile.did,
|
||||
list: this.uri,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
runInAction(() => {
|
||||
this.items = this.items.concat([
|
||||
{_reactKey: profile.did, subject: profile},
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Just adds to local cache; used to reflect changes affected elsewhere
|
||||
*/
|
||||
cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
if (!this.isMember(profile.did)) {
|
||||
this.items = this.items.concat([
|
||||
{_reactKey: profile.did, subject: profile},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Just removes from local cache; used to reflect changes affected elsewhere
|
||||
*/
|
||||
cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
if (this.isMember(profile.did)) {
|
||||
this.items = this.items.filter(item => item.subject.did !== profile.did)
|
||||
}
|
||||
}
|
||||
|
||||
async pin() {
|
||||
try {
|
||||
await this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to pin feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.data?.name || '',
|
||||
uri: this.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async togglePin() {
|
||||
if (!this.isPinned) {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.data?.name || '',
|
||||
uri: this.uri,
|
||||
})
|
||||
return this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} else {
|
||||
track('CustomFeed:Unpin', {
|
||||
name: this.data?.name || '',
|
||||
uri: this.uri,
|
||||
})
|
||||
// TEMPORARY
|
||||
// lists are temporarily piggybacking on the saved/pinned feeds preferences
|
||||
// we'll eventually replace saved feeds with the bookmarks API
|
||||
// until then, we need to unsave lists instead of just unpin them
|
||||
// -prf
|
||||
// return this.rootStore.preferences.removePinnedFeed(this.uri)
|
||||
return this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
}
|
||||
}
|
||||
|
||||
async mute() {
|
||||
if (!this.data) {
|
||||
async subscribe() {
|
||||
if (!this.list) {
|
||||
return
|
||||
}
|
||||
await this._resolveUri()
|
||||
await this.rootStore.agent.muteModList(this.data.uri)
|
||||
track('Lists:Mute')
|
||||
runInAction(() => {
|
||||
if (this.data) {
|
||||
const d = this.data
|
||||
this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
|
||||
}
|
||||
await this.rootStore.agent.app.bsky.graph.muteActorList({
|
||||
list: this.list.uri,
|
||||
})
|
||||
track('Lists:Subscribe')
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async unmute() {
|
||||
if (!this.data) {
|
||||
async unsubscribe() {
|
||||
if (!this.list) {
|
||||
return
|
||||
}
|
||||
await this._resolveUri()
|
||||
await this.rootStore.agent.unmuteModList(this.data.uri)
|
||||
track('Lists:Unmute')
|
||||
runInAction(() => {
|
||||
if (this.data) {
|
||||
const d = this.data
|
||||
this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async block() {
|
||||
if (!this.data) {
|
||||
return
|
||||
}
|
||||
await this._resolveUri()
|
||||
const res = await this.rootStore.agent.blockModList(this.data.uri)
|
||||
track('Lists:Block')
|
||||
runInAction(() => {
|
||||
if (this.data) {
|
||||
const d = this.data
|
||||
this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async unblock() {
|
||||
if (!this.data || !this.data.viewer?.blocked) {
|
||||
return
|
||||
}
|
||||
await this._resolveUri()
|
||||
await this.rootStore.agent.unblockModList(this.data.uri)
|
||||
track('Lists:Unblock')
|
||||
runInAction(() => {
|
||||
if (this.data) {
|
||||
const d = this.data
|
||||
this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
|
||||
}
|
||||
await this.rootStore.agent.app.bsky.graph.unmuteActorList({
|
||||
list: this.list.uri,
|
||||
})
|
||||
track('Lists:Unsubscribe')
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,12 +280,10 @@ export class ListModel {
|
||||
this.error = cleanError(err)
|
||||
this.loadMoreError = cleanError(loadMoreErr)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user items', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user items', err)
|
||||
}
|
||||
if (loadMoreErr) {
|
||||
logger.error('Failed to fetch user items', {
|
||||
error: loadMoreErr,
|
||||
})
|
||||
this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,17 +314,9 @@ export class ListModel {
|
||||
_appendAll(res: GetList.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.data = res.data.list
|
||||
this.list = res.data.list
|
||||
this.items = this.items.concat(
|
||||
res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
|
||||
)
|
||||
if (this.data.description) {
|
||||
this.descriptionRT = new RichText({
|
||||
text: this.data.description,
|
||||
facets: (this.data.descriptionFacets || [])?.slice(),
|
||||
})
|
||||
} else {
|
||||
this.descriptionRT = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {ThreadViewPreference} from '../ui/preferences'
|
||||
import {PostThreadItemModel} from './post-thread-item'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class PostThreadModel {
|
||||
// state
|
||||
@@ -164,7 +163,7 @@ export class PostThreadModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch post thread', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch post thread', err)
|
||||
}
|
||||
this.notFound = err instanceof GetPostThread.NotFoundError
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {cleanError} from 'lib/strings/errors'
|
||||
import {FollowState} from '../cache/my-follows'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class ProfileViewerModel {
|
||||
muted?: boolean
|
||||
@@ -23,8 +22,7 @@ export class ProfileViewerModel {
|
||||
following?: string
|
||||
followedBy?: string
|
||||
blockedBy?: boolean
|
||||
blocking?: string
|
||||
blockingByList?: AppBskyGraphDefs.ListViewBasic;
|
||||
blocking?: string;
|
||||
[key: string]: unknown
|
||||
|
||||
constructor() {
|
||||
@@ -236,7 +234,7 @@ export class ProfileModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch profile', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch profile', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {logger} from '#/logger'
|
||||
import {CustomFeedModel} from '../feeds/custom-feed'
|
||||
|
||||
const DEFAULT_LIMIT = 50
|
||||
|
||||
@@ -17,7 +16,7 @@ export class FeedsDiscoveryModel {
|
||||
loadMoreCursor: string | undefined = undefined
|
||||
|
||||
// data
|
||||
feeds: FeedSourceModel[] = []
|
||||
feeds: CustomFeedModel[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
@@ -121,7 +120,7 @@ export class FeedsDiscoveryModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch popular feeds', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch popular feeds', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +137,7 @@ export class FeedsDiscoveryModel {
|
||||
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
|
||||
// 1. push data into feeds array
|
||||
for (const f of res.data.feeds) {
|
||||
const model = new FeedSourceModel(this.rootStore, f.uri)
|
||||
model.hydrateFeedGenerator(f)
|
||||
this.feeds.push(model)
|
||||
this.feeds.push(new CustomFeedModel(this.rootStore, f))
|
||||
}
|
||||
// 2. set loadMoreCursor
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
|
||||
@@ -3,7 +3,6 @@ import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -145,7 +144,7 @@ export class SuggestedActorsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch suggested actors', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch suggested actors', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export class UserAutocompleteModel {
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
this.isLoading = true
|
||||
await this.rootStore.me.follows.syncIfNeeded()
|
||||
runInAction(() => {
|
||||
for (const did in this.rootStore.me.follows.byDid) {
|
||||
@@ -57,7 +56,6 @@ export class UserAutocompleteModel {
|
||||
this.knownHandles.add(info.handle)
|
||||
}
|
||||
}
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
|
||||
export class CustomFeedModel {
|
||||
// data
|
||||
_reactKey: string
|
||||
data: AppBskyFeedDefs.GeneratorView
|
||||
isOnline: boolean
|
||||
isValid: boolean
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
view: AppBskyFeedDefs.GeneratorView,
|
||||
isOnline?: boolean,
|
||||
isValid?: boolean,
|
||||
) {
|
||||
this._reactKey = view.uri
|
||||
this.data = view
|
||||
this.isOnline = isOnline ?? true
|
||||
this.isValid = isValid ?? true
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// local actions
|
||||
// =
|
||||
|
||||
get uri() {
|
||||
return this.data.uri
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
if (this.data.displayName) {
|
||||
return sanitizeDisplayName(this.data.displayName)
|
||||
}
|
||||
return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}`
|
||||
}
|
||||
|
||||
get isSaved() {
|
||||
return this.rootStore.preferences.savedFeeds.includes(this.uri)
|
||||
}
|
||||
|
||||
get isLiked() {
|
||||
return this.data.viewer?.like
|
||||
}
|
||||
|
||||
// public apis
|
||||
// =
|
||||
|
||||
async save() {
|
||||
try {
|
||||
await this.rootStore.preferences.addSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to save feed', error)
|
||||
} finally {
|
||||
track('CustomFeed:Save')
|
||||
}
|
||||
}
|
||||
|
||||
async pin() {
|
||||
try {
|
||||
await this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to pin feed', error)
|
||||
} finally {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.data.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async unsave() {
|
||||
try {
|
||||
await this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to unsave feed', error)
|
||||
} finally {
|
||||
track('CustomFeed:Unsave')
|
||||
}
|
||||
}
|
||||
|
||||
async like() {
|
||||
try {
|
||||
await updateDataOptimistically(
|
||||
this.data,
|
||||
() => {
|
||||
this.data.viewer = this.data.viewer || {}
|
||||
this.data.viewer.like = 'pending'
|
||||
this.data.likeCount = (this.data.likeCount || 0) + 1
|
||||
},
|
||||
() => this.rootStore.agent.like(this.data.uri, this.data.cid),
|
||||
res => {
|
||||
this.data.viewer = this.data.viewer || {}
|
||||
this.data.viewer.like = res.uri
|
||||
},
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.error('Failed to like feed', e)
|
||||
} finally {
|
||||
track('CustomFeed:Like')
|
||||
}
|
||||
}
|
||||
|
||||
async unlike() {
|
||||
if (!this.data.viewer?.like) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const likeUri = this.data.viewer.like
|
||||
await updateDataOptimistically(
|
||||
this.data,
|
||||
() => {
|
||||
this.data.viewer = this.data.viewer || {}
|
||||
this.data.viewer.like = undefined
|
||||
this.data.likeCount = (this.data.likeCount || 1) - 1
|
||||
},
|
||||
() => this.rootStore.agent.deleteLike(likeUri),
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.error('Failed to unlike feed', e)
|
||||
} finally {
|
||||
track('CustomFeed:Unlike')
|
||||
}
|
||||
}
|
||||
|
||||
async reload() {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: this.data.uri,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.data = res.data.view
|
||||
this.isOnline = res.data.isOnline
|
||||
this.isValid = res.data.isValid
|
||||
})
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return JSON.stringify(this.data)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {PostThreadModel} from '../content/post-thread'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
||||
const PAGE_SIZE = 30
|
||||
@@ -211,7 +210,7 @@ export class NotificationsFeedItemModel {
|
||||
if (valid.success) {
|
||||
return v
|
||||
} else {
|
||||
logger.warn('Received an invalid record', {
|
||||
this.rootStore.log.warn('Received an invalid record', {
|
||||
record: v,
|
||||
error: valid.error,
|
||||
})
|
||||
@@ -219,9 +218,9 @@ export class NotificationsFeedItemModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.warn(
|
||||
this.rootStore.log.warn(
|
||||
'app.bsky.notifications.list served an unsupported record type',
|
||||
{record: v},
|
||||
v,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,7 +319,7 @@ export class NotificationsFeedModel {
|
||||
* Nuke all data
|
||||
*/
|
||||
clear() {
|
||||
logger.debug('NotificationsModel:clear')
|
||||
this.rootStore.log.debug('NotificationsModel:clear')
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
@@ -337,7 +336,7 @@ export class NotificationsFeedModel {
|
||||
* Load for first render
|
||||
*/
|
||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||
logger.debug('NotificationsModel:refresh', {isRefreshing})
|
||||
this.rootStore.log.debug('NotificationsModel:refresh', {isRefreshing})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
this._xLoading(isRefreshing)
|
||||
@@ -369,7 +368,7 @@ export class NotificationsFeedModel {
|
||||
* Sync the next set of notifications to show
|
||||
*/
|
||||
syncQueue = bundleAsync(async () => {
|
||||
logger.debug('NotificationsModel:syncQueue')
|
||||
this.rootStore.log.debug('NotificationsModel:syncQueue')
|
||||
if (this.unreadCount >= MAX_VISIBLE_NOTIFS) {
|
||||
return // no need to check
|
||||
}
|
||||
@@ -402,9 +401,7 @@ export class NotificationsFeedModel {
|
||||
this._setQueued(this._filterNotifications(queueModels))
|
||||
this._countUnread()
|
||||
} catch (e) {
|
||||
logger.error('NotificationsModel:syncQueue failed', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.error('NotificationsModel:syncQueue failed', {e})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
@@ -464,7 +461,10 @@ export class NotificationsFeedModel {
|
||||
}
|
||||
}
|
||||
await Promise.all(promises).catch(e => {
|
||||
logger.error('Uncaught failure during notifications update()', e)
|
||||
this.rootStore.log.error(
|
||||
'Uncaught failure during notifications update()',
|
||||
e,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -481,9 +481,7 @@ export class NotificationsFeedModel {
|
||||
this.lastSync ? this.lastSync.toISOString() : undefined,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.warn('Failed to update notifications read state', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.warn('Failed to update notifications read state', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,12 +501,13 @@ export class NotificationsFeedModel {
|
||||
this.error = cleanError(error)
|
||||
this.loadMoreError = cleanError(loadMoreError)
|
||||
if (error) {
|
||||
logger.error('Failed to fetch notifications', {error})
|
||||
this.rootStore.log.error('Failed to fetch notifications', error)
|
||||
}
|
||||
if (loadMoreError) {
|
||||
logger.error('Failed to load more notifications', {
|
||||
error: loadMoreError,
|
||||
})
|
||||
this.rootStore.log.error(
|
||||
'Failed to load more notifications',
|
||||
loadMoreError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {RootStoreModel} from '../root-store'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
@@ -43,16 +42,17 @@ export class PostsFeedItemModel {
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
logger.warn('Received an invalid app.bsky.feed.post record', {
|
||||
error: valid.error,
|
||||
})
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
valid.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
logger.warn(
|
||||
rootStore.log.warn(
|
||||
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
|
||||
{record: this.post.record},
|
||||
this.post.record,
|
||||
)
|
||||
}
|
||||
this.reply = v.reply
|
||||
@@ -133,7 +133,7 @@ export class PostsFeedItemModel {
|
||||
track('Post:Like')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle like', {error})
|
||||
this.rootStore.log.error('Failed to toggle like', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export class PostsFeedItemModel {
|
||||
track('Post:Repost')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle repost', {error})
|
||||
this.rootStore.log.error('Failed to toggle repost', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ export class PostsFeedItemModel {
|
||||
track('Post:ThreadMute')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle thread mute', {error})
|
||||
this.rootStore.log.error('Failed to toggle thread mute', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export class PostsFeedItemModel {
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete post', {error})
|
||||
this.rootStore.log.error('Failed to delete post', error)
|
||||
} finally {
|
||||
track('Post:Delete')
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
@@ -20,23 +19,10 @@ import {FollowingFeedAPI} from 'lib/api/feed/following'
|
||||
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
||||
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
|
||||
|
||||
export enum KnownError {
|
||||
FeedgenDoesNotExist,
|
||||
FeedgenMisconfigured,
|
||||
FeedgenBadResponse,
|
||||
FeedgenOffline,
|
||||
FeedgenUnknown,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Formats the feed in a flat array with no threading of replies, just
|
||||
@@ -50,7 +36,6 @@ type QueryParams =
|
||||
| GetAuthorFeed.QueryParams
|
||||
| GetActorLikes.QueryParams
|
||||
| GetCustomFeed.QueryParams
|
||||
| GetListFeed.QueryParams
|
||||
|
||||
export class PostsFeedModel {
|
||||
// state
|
||||
@@ -61,7 +46,6 @@ export class PostsFeedModel {
|
||||
isBlocking = false
|
||||
isBlockedBy = false
|
||||
error = ''
|
||||
knownError: KnownError | undefined
|
||||
loadMoreError = ''
|
||||
params: QueryParams
|
||||
hasMore = true
|
||||
@@ -82,7 +66,7 @@ export class PostsFeedModel {
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedType: FeedType,
|
||||
public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
|
||||
params: QueryParams,
|
||||
options?: Options,
|
||||
) {
|
||||
@@ -115,26 +99,11 @@ export class PostsFeedModel {
|
||||
rootStore,
|
||||
params as GetCustomFeed.QueryParams,
|
||||
)
|
||||
} else if (feedType === 'list') {
|
||||
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
|
||||
} else {
|
||||
this.api = new FollowingFeedAPI(rootStore)
|
||||
}
|
||||
}
|
||||
|
||||
get reactKey() {
|
||||
if (this.feedType === 'author') {
|
||||
return (this.params as GetAuthorFeed.QueryParams).actor
|
||||
}
|
||||
if (this.feedType === 'custom') {
|
||||
return (this.params as GetCustomFeed.QueryParams).feed
|
||||
}
|
||||
if (this.feedType === 'list') {
|
||||
return (this.params as GetListFeed.QueryParams).list
|
||||
}
|
||||
return this.feedType
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.slices.length !== 0
|
||||
}
|
||||
@@ -148,7 +117,7 @@ export class PostsFeedModel {
|
||||
}
|
||||
|
||||
get isLoadingMore() {
|
||||
return this.isLoading && !this.isRefreshing && this.hasContent
|
||||
return this.isLoading && !this.isRefreshing
|
||||
}
|
||||
|
||||
setHasNewLatest(v: boolean) {
|
||||
@@ -162,7 +131,7 @@ export class PostsFeedModel {
|
||||
* Nuke all data
|
||||
*/
|
||||
clear() {
|
||||
logger.debug('FeedModel:clear')
|
||||
this.rootStore.log.debug('FeedModel:clear')
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasNewLatest = false
|
||||
@@ -178,7 +147,7 @@ export class PostsFeedModel {
|
||||
* Load for first render
|
||||
*/
|
||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||
logger.debug('FeedModel:setup', {isRefreshing})
|
||||
this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
}
|
||||
@@ -312,7 +281,6 @@ export class PostsFeedModel {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
this.knownError = undefined
|
||||
}
|
||||
|
||||
_xIdle(error?: any, loadMoreError?: any) {
|
||||
@@ -322,15 +290,15 @@ export class PostsFeedModel {
|
||||
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
|
||||
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
|
||||
this.error = cleanError(error)
|
||||
this.knownError = detectKnownError(this.feedType, error)
|
||||
this.loadMoreError = cleanError(loadMoreError)
|
||||
if (error) {
|
||||
logger.error('Posts feed request failed', {error})
|
||||
this.rootStore.log.error('Posts feed request failed', error)
|
||||
}
|
||||
if (loadMoreError) {
|
||||
logger.error('Posts feed load-more request failed', {
|
||||
error: loadMoreError,
|
||||
})
|
||||
this.rootStore.log.error(
|
||||
'Posts feed load-more request failed',
|
||||
loadMoreError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,39 +359,3 @@ export class PostsFeedModel {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function detectKnownError(
|
||||
feedType: FeedType,
|
||||
error: any,
|
||||
): KnownError | undefined {
|
||||
if (!error) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof error !== 'string') {
|
||||
error = error.toString()
|
||||
}
|
||||
if (feedType !== 'custom') {
|
||||
return KnownError.Unknown
|
||||
}
|
||||
if (error.includes('could not find feed')) {
|
||||
return KnownError.FeedgenDoesNotExist
|
||||
}
|
||||
if (error.includes('feed unavailable')) {
|
||||
return KnownError.FeedgenOffline
|
||||
}
|
||||
if (error.includes('invalid did document')) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (error.includes('could not resolve did document')) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (
|
||||
error.includes('invalid feed generator service details in did document')
|
||||
) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (error.includes('feed provided an invalid response')) {
|
||||
return KnownError.FeedgenBadResponse
|
||||
}
|
||||
return KnownError.FeedgenUnknown
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class InvitedUsers {
|
||||
copiedInvites: string[] = []
|
||||
@@ -64,9 +63,10 @@ export class InvitedUsers {
|
||||
})
|
||||
this.rootStore.me.follows.hydrateMany(this.profiles)
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch profiles for invited users', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.error(
|
||||
'Failed to fetch profiles for invited users',
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {logger} from '#/logger'
|
||||
import {CustomFeedModel} from '../feeds/custom-feed'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -18,7 +17,7 @@ export class ActorFeedsModel {
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
feeds: FeedSourceModel[] = []
|
||||
feeds: CustomFeedModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
@@ -99,7 +98,7 @@ export class ActorFeedsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user followers', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user followers', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +114,7 @@ export class ActorFeedsModel {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
for (const f of res.data.feeds) {
|
||||
const model = new FeedSourceModel(this.rootStore, f.uri)
|
||||
model.hydrateFeedGenerator(f)
|
||||
this.feeds.push(model)
|
||||
this.feeds.push(new CustomFeedModel(this.rootStore, f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -87,7 +86,7 @@ export class BlockedAccountsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user followers', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user followers', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -98,7 +97,7 @@ export class LikesModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch likes', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch likes', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyGraphGetLists as GetLists,
|
||||
AppBskyGraphGetListMutes as GetListMutes,
|
||||
AppBskyGraphDefs as GraphDefs,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {accumulate} from 'lib/async/accumulate'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -23,7 +25,7 @@ export class ListsListModel {
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
|
||||
public source: 'my-modlists' | string,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -46,26 +48,6 @@ export class ListsListModel {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get curatelists() {
|
||||
return this.lists.filter(
|
||||
list => list.purpose === 'app.bsky.graph.defs#curatelist',
|
||||
)
|
||||
}
|
||||
|
||||
get isCuratelistsEmpty() {
|
||||
return this.hasLoaded && this.curatelists.length === 0
|
||||
}
|
||||
|
||||
get modlists() {
|
||||
return this.lists.filter(
|
||||
list => list.purpose === 'app.bsky.graph.defs#modlist',
|
||||
)
|
||||
}
|
||||
|
||||
get isModlistsEmpty() {
|
||||
return this.hasLoaded && this.modlists.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes posts from the feed upon deletion.
|
||||
*/
|
||||
@@ -94,85 +76,44 @@ export class ListsListModel {
|
||||
}
|
||||
this._xLoading(replace)
|
||||
try {
|
||||
let cursor: string | undefined
|
||||
let lists: GraphDefs.ListView[] = []
|
||||
if (
|
||||
this.source === 'mine' ||
|
||||
this.source === 'my-curatelists' ||
|
||||
this.source === 'my-modlists'
|
||||
) {
|
||||
const promises = [
|
||||
accumulate(cursor =>
|
||||
this.rootStore.agent.app.bsky.graph
|
||||
.getLists({
|
||||
actor: this.rootStore.me.did,
|
||||
cursor,
|
||||
limit: 50,
|
||||
})
|
||||
.then(res => ({cursor: res.data.cursor, items: res.data.lists})),
|
||||
),
|
||||
]
|
||||
if (this.source === 'my-modlists') {
|
||||
promises.push(
|
||||
accumulate(cursor =>
|
||||
this.rootStore.agent.app.bsky.graph
|
||||
.getListMutes({
|
||||
cursor,
|
||||
limit: 50,
|
||||
})
|
||||
.then(res => ({
|
||||
cursor: res.data.cursor,
|
||||
items: res.data.lists,
|
||||
})),
|
||||
),
|
||||
)
|
||||
promises.push(
|
||||
accumulate(cursor =>
|
||||
this.rootStore.agent.app.bsky.graph
|
||||
.getListBlocks({
|
||||
cursor,
|
||||
limit: 50,
|
||||
})
|
||||
.then(res => ({
|
||||
cursor: res.data.cursor,
|
||||
items: res.data.lists,
|
||||
})),
|
||||
),
|
||||
)
|
||||
let res: GetLists.Response
|
||||
if (this.source === 'my-modlists') {
|
||||
res = {
|
||||
success: true,
|
||||
headers: {},
|
||||
data: {
|
||||
subject: undefined,
|
||||
lists: [],
|
||||
},
|
||||
}
|
||||
const resultset = await Promise.all(promises)
|
||||
for (const res of resultset) {
|
||||
for (let list of res) {
|
||||
if (
|
||||
this.source === 'my-curatelists' &&
|
||||
list.purpose !== 'app.bsky.graph.defs#curatelist'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
this.source === 'my-modlists' &&
|
||||
list.purpose !== 'app.bsky.graph.defs#modlist'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!lists.find(l => l.uri === list.uri)) {
|
||||
lists.push(list)
|
||||
}
|
||||
const [res1, res2] = await Promise.all([
|
||||
fetchAllUserLists(this.rootStore, this.rootStore.me.did),
|
||||
fetchAllMyMuteLists(this.rootStore),
|
||||
])
|
||||
for (let list of res1.data.lists) {
|
||||
if (list.purpose === 'app.bsky.graph.defs#modlist') {
|
||||
res.data.lists.push(list)
|
||||
}
|
||||
}
|
||||
for (let list of res2.data.lists) {
|
||||
if (
|
||||
list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||
!res.data.lists.find(l => l.uri === list.uri)
|
||||
) {
|
||||
res.data.lists.push(list)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const res = await this.rootStore.agent.app.bsky.graph.getLists({
|
||||
res = await this.rootStore.agent.app.bsky.graph.getLists({
|
||||
actor: this.source,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
lists = res.data.lists
|
||||
cursor = res.data.cursor
|
||||
}
|
||||
if (replace) {
|
||||
this._replaceAll({lists, cursor})
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
this._appendAll({lists, cursor})
|
||||
this._appendAll(res)
|
||||
}
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -205,40 +146,85 @@ export class ListsListModel {
|
||||
this.error = cleanError(err)
|
||||
this.loadMoreError = cleanError(loadMoreErr)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user lists', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user lists', err)
|
||||
}
|
||||
if (loadMoreErr) {
|
||||
logger.error('Failed to fetch user lists', {
|
||||
error: loadMoreErr,
|
||||
})
|
||||
this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll({
|
||||
lists,
|
||||
cursor,
|
||||
}: {
|
||||
lists: GraphDefs.ListView[]
|
||||
cursor: string | undefined
|
||||
}) {
|
||||
_replaceAll(res: GetLists.Response | GetListMutes.Response) {
|
||||
this.lists = []
|
||||
this._appendAll({lists, cursor})
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
_appendAll({
|
||||
lists,
|
||||
cursor,
|
||||
}: {
|
||||
lists: GraphDefs.ListView[]
|
||||
cursor: string | undefined
|
||||
}) {
|
||||
this.loadMoreCursor = cursor
|
||||
_appendAll(res: GetLists.Response | GetListMutes.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.lists = this.lists.concat(
|
||||
lists.map(list => ({...list, _reactKey: list.uri})),
|
||||
res.data.lists.map(list => ({...list, _reactKey: list.uri})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllUserLists(
|
||||
store: RootStoreModel,
|
||||
did: string,
|
||||
): Promise<GetLists.Response> {
|
||||
let acc: GetLists.Response = {
|
||||
success: true,
|
||||
headers: {},
|
||||
data: {
|
||||
subject: undefined,
|
||||
lists: [],
|
||||
},
|
||||
}
|
||||
|
||||
let cursor
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({
|
||||
actor: did,
|
||||
cursor,
|
||||
limit: 50,
|
||||
})
|
||||
cursor = res.data.cursor
|
||||
acc.data.lists = acc.data.lists.concat(res.data.lists)
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
async function fetchAllMyMuteLists(
|
||||
store: RootStoreModel,
|
||||
): Promise<GetListMutes.Response> {
|
||||
let acc: GetListMutes.Response = {
|
||||
success: true,
|
||||
headers: {},
|
||||
data: {
|
||||
subject: undefined,
|
||||
lists: [],
|
||||
},
|
||||
}
|
||||
|
||||
let cursor
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res: GetListMutes.Response =
|
||||
await store.agent.app.bsky.graph.getListMutes({
|
||||
cursor,
|
||||
limit: 50,
|
||||
})
|
||||
cursor = res.data.cursor
|
||||
acc.data.lists = acc.data.lists.concat(res.data.lists)
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -87,7 +86,7 @@ export class MutedAccountsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user followers', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user followers', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -101,7 +100,7 @@ export class RepostedByModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch reposted by view', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch reposted by view', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -100,7 +99,7 @@ export class UserFollowersModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user followers', {error: err})
|
||||
this.rootStore.log.error('Failed to fetch user followers', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -100,7 +99,7 @@ export class UserFollowsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user follows', err)
|
||||
this.rootStore.log.error('Failed to fetch user follows', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
// import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO
|
||||
|
||||
const MAX_ENTRIES = 300
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
type?: string
|
||||
summary?: string
|
||||
details?: string
|
||||
ts?: number
|
||||
}
|
||||
|
||||
let _lastTs: string
|
||||
let _lastId: string
|
||||
function genId(): string {
|
||||
let candidate = String(Date.now())
|
||||
if (_lastTs === candidate) {
|
||||
const id = _lastId + 'x'
|
||||
_lastId = id
|
||||
return id
|
||||
}
|
||||
_lastTs = candidate
|
||||
_lastId = candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
export class LogModel {
|
||||
entries: LogEntry[] = []
|
||||
timers = new Map<string, number>()
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
add(entry: LogEntry) {
|
||||
this.entries.push(entry)
|
||||
while (this.entries.length > MAX_ENTRIES) {
|
||||
this.entries = this.entries.slice(50)
|
||||
}
|
||||
}
|
||||
|
||||
debug(summary: string, details?: any) {
|
||||
details = detailsToStr(details)
|
||||
console.debug(summary, details || '')
|
||||
this.add({
|
||||
id: genId(),
|
||||
type: 'debug',
|
||||
summary,
|
||||
details,
|
||||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
warn(summary: string, details?: any) {
|
||||
details = detailsToStr(details)
|
||||
console.debug(summary, details || '')
|
||||
this.add({
|
||||
id: genId(),
|
||||
type: 'warn',
|
||||
summary,
|
||||
details,
|
||||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
error(summary: string, details?: any) {
|
||||
details = detailsToStr(details)
|
||||
console.debug(summary, details || '')
|
||||
this.add({
|
||||
id: genId(),
|
||||
type: 'error',
|
||||
summary,
|
||||
details,
|
||||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
time = (label = 'default') => {
|
||||
this.timers.set(label, performance.now())
|
||||
}
|
||||
|
||||
timeEnd = (label = 'default', warn = false) => {
|
||||
const endTime = performance.now()
|
||||
if (this.timers.has(label)) {
|
||||
const elapsedTime = endTime - this.timers.get(label)!
|
||||
console.log(`${label}: ${elapsedTime.toFixed(3)}ms`)
|
||||
this.timers.delete(label)
|
||||
} else {
|
||||
warn && console.warn(`Timer with label '${label}' does not exist.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detailsToStr(details?: any) {
|
||||
if (details && typeof details !== 'string') {
|
||||
if (
|
||||
// details instanceof XRPCInvalidResponseError || TODO
|
||||
details.constructor.name === 'XRPCInvalidResponseError'
|
||||
) {
|
||||
return `The server gave an ill-formatted response.\nMethod: ${
|
||||
details.lexiconNsid
|
||||
}.\nError: ${details.validationError.toString()}`
|
||||
} else if (
|
||||
// details instanceof XRPCError || TODO
|
||||
details.constructor.name === 'XRPCError'
|
||||
) {
|
||||
return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}`
|
||||
} else if (details instanceof Error) {
|
||||
return details.toString()
|
||||
}
|
||||
return JSON.stringify(details, null, 2)
|
||||
}
|
||||
return details
|
||||
}
|
||||
+11
-26
@@ -6,10 +6,9 @@ import {
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {PostsFeedModel} from './feeds/posts'
|
||||
import {NotificationsFeedModel} from './feeds/notifications'
|
||||
import {MyFeedsUIModel} from './ui/my-feeds'
|
||||
import {MyFollowsCache} from './cache/my-follows'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
import {SavedFeedsModel} from './ui/saved-feeds'
|
||||
|
||||
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
|
||||
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
|
||||
@@ -23,8 +22,8 @@ export class MeModel {
|
||||
followsCount: number | undefined
|
||||
followersCount: number | undefined
|
||||
mainFeed: PostsFeedModel
|
||||
savedFeeds: SavedFeedsModel
|
||||
notifications: NotificationsFeedModel
|
||||
myFeeds: MyFeedsUIModel
|
||||
follows: MyFollowsCache
|
||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
||||
@@ -45,14 +44,13 @@ export class MeModel {
|
||||
algorithm: 'reverse-chronological',
|
||||
})
|
||||
this.notifications = new NotificationsFeedModel(this.rootStore)
|
||||
this.myFeeds = new MyFeedsUIModel(this.rootStore)
|
||||
this.follows = new MyFollowsCache(this.rootStore)
|
||||
this.savedFeeds = new SavedFeedsModel(this.rootStore)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.mainFeed.clear()
|
||||
this.notifications.clear()
|
||||
this.myFeeds.clear()
|
||||
this.follows.clear()
|
||||
this.rootStore.profiles.cache.clear()
|
||||
this.rootStore.posts.cache.clear()
|
||||
@@ -105,26 +103,17 @@ export class MeModel {
|
||||
|
||||
async load() {
|
||||
const sess = this.rootStore.session
|
||||
logger.debug('MeModel:load', {hasSession: sess.hasSession})
|
||||
this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
|
||||
if (sess.hasSession) {
|
||||
this.did = sess.currentSession?.did || ''
|
||||
await this.fetchProfile()
|
||||
this.mainFeed.clear()
|
||||
/* dont await */ this.mainFeed.setup().catch(e => {
|
||||
logger.error('Failed to setup main feed model', {error: e})
|
||||
this.rootStore.log.error('Failed to setup main feed model', e)
|
||||
})
|
||||
/* dont await */ this.notifications.setup().catch(e => {
|
||||
logger.error('Failed to setup notifications model', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.error('Failed to setup notifications model', e)
|
||||
})
|
||||
/* dont await */ this.notifications.setup().catch(e => {
|
||||
logger.error('Failed to setup notifications model', {
|
||||
error: e,
|
||||
})
|
||||
})
|
||||
this.myFeeds.clear()
|
||||
/* dont await */ this.myFeeds.saved.refresh()
|
||||
this.rootStore.emitSessionLoaded()
|
||||
await this.fetchInviteCodes()
|
||||
await this.fetchAppPasswords()
|
||||
@@ -135,7 +124,7 @@ export class MeModel {
|
||||
|
||||
async updateIfNeeded() {
|
||||
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
|
||||
logger.debug('Updating me profile information')
|
||||
this.rootStore.log.debug('Updating me profile information')
|
||||
this.lastProfileStateUpdate = Date.now()
|
||||
await this.fetchProfile()
|
||||
await this.fetchInviteCodes()
|
||||
@@ -189,9 +178,7 @@ export class MeModel {
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch user invite codes', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.error('Failed to fetch user invite codes', e)
|
||||
}
|
||||
await this.rootStore.invitedUsers.fetch(this.invites)
|
||||
}
|
||||
@@ -206,9 +193,7 @@ export class MeModel {
|
||||
this.appPasswords = res.data.passwords
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch user app passwords', {
|
||||
error: e,
|
||||
})
|
||||
this.rootStore.log.error('Failed to fetch user app passwords', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +214,7 @@ export class MeModel {
|
||||
})
|
||||
return res.data
|
||||
} catch (e) {
|
||||
logger.error('Failed to create app password', {error: e})
|
||||
this.rootStore.log.error('Failed to create app password', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +229,7 @@ export class MeModel {
|
||||
this.appPasswords = this.appPasswords.filter(p => p.name !== name)
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('Failed to delete app password', {error: e})
|
||||
this.rootStore.log.error('Failed to delete app password', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
@@ -189,7 +188,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
this.cropped = cropped
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to crop photo', {error: err})
|
||||
this.rootStore.log.error('Failed to crop photo', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {createContext, useContext} from 'react'
|
||||
import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
|
||||
import {z} from 'zod'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {LogModel} from './log'
|
||||
import {SessionModel} from './session'
|
||||
import {ShellUiModel} from './ui/shell'
|
||||
import {HandleResolutionsCache} from './cache/handle-resolutions'
|
||||
@@ -22,7 +23,6 @@ import {ImageSizesCache} from './cache/image-sizes'
|
||||
import {MutedThreads} from './muted-threads'
|
||||
import {Reminders} from './ui/reminders'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
// TEMPORARY (APP-700)
|
||||
// remove after backend testing finishes
|
||||
@@ -41,6 +41,7 @@ export type AppInfo = z.infer<typeof appInfo>
|
||||
export class RootStoreModel {
|
||||
agent: BskyAgent
|
||||
appInfo?: AppInfo
|
||||
log = new LogModel()
|
||||
session = new SessionModel(this)
|
||||
shell = new ShellUiModel(this)
|
||||
preferences = new PreferencesModel(this)
|
||||
@@ -121,15 +122,15 @@ export class RootStoreModel {
|
||||
* Called during init to resume any stored session.
|
||||
*/
|
||||
async attemptSessionResumption() {
|
||||
logger.debug('RootStoreModel:attemptSessionResumption')
|
||||
this.log.debug('RootStoreModel:attemptSessionResumption')
|
||||
try {
|
||||
await this.session.attemptSessionResumption()
|
||||
logger.debug('Session initialized', {
|
||||
this.log.debug('Session initialized', {
|
||||
hasSession: this.session.hasSession,
|
||||
})
|
||||
this.updateSessionState()
|
||||
} catch (e: any) {
|
||||
logger.warn('Failed to initialize session', {error: e})
|
||||
this.log.warn('Failed to initialize session', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ export class RootStoreModel {
|
||||
agent: BskyAgent,
|
||||
{hadSession}: {hadSession: boolean},
|
||||
) {
|
||||
logger.debug('RootStoreModel:handleSessionChange')
|
||||
this.log.debug('RootStoreModel:handleSessionChange')
|
||||
this.agent = agent
|
||||
applyDebugHeader(this.agent)
|
||||
this.me.clear()
|
||||
@@ -156,7 +157,7 @@ export class RootStoreModel {
|
||||
* Called by the session model. Handles session drops by informing the user.
|
||||
*/
|
||||
async handleSessionDrop() {
|
||||
logger.debug('RootStoreModel:handleSessionDrop')
|
||||
this.log.debug('RootStoreModel:handleSessionDrop')
|
||||
resetToTab('HomeTab')
|
||||
this.me.clear()
|
||||
this.emitSessionDropped()
|
||||
@@ -166,7 +167,7 @@ export class RootStoreModel {
|
||||
* Clears all session-oriented state.
|
||||
*/
|
||||
clearAllSessionState() {
|
||||
logger.debug('RootStoreModel:clearAllSessionState')
|
||||
this.log.debug('RootStoreModel:clearAllSessionState')
|
||||
this.session.clear()
|
||||
resetToTab('HomeTab')
|
||||
this.me.clear()
|
||||
@@ -183,7 +184,7 @@ export class RootStoreModel {
|
||||
await this.me.updateIfNeeded()
|
||||
await this.preferences.sync()
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to fetch latest state', {error: e})
|
||||
this.log.error('Failed to fetch latest state', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {z} from 'zod'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {IS_PROD} from 'lib/constants'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export type ServiceDescription = DescribeServer.OutputSchema
|
||||
|
||||
@@ -57,7 +56,7 @@ export class SessionModel {
|
||||
),
|
||||
isResumingSession: this.isResumingSession,
|
||||
}
|
||||
logger.debug(message, details, logger.DebugContext.session)
|
||||
this.rootStore.log.debug(message, details)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@ import {createFullHandle} from 'lib/strings/handles'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {getAge} from 'lib/strings/time'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
|
||||
|
||||
@@ -77,9 +76,9 @@ export class CreateAccountModel {
|
||||
this.setServiceDescription(desc)
|
||||
this.setUserDomain(desc.availableUserDomains[0])
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
this.rootStore.log.warn(
|
||||
`Failed to fetch service description for ${this.serviceUrl}`,
|
||||
{error: err},
|
||||
err,
|
||||
)
|
||||
this.setError(
|
||||
'Unable to contact your service. Please check your Internet connection.',
|
||||
@@ -128,7 +127,7 @@ export class CreateAccountModel {
|
||||
errMsg =
|
||||
'Invite code not accepted. Check that you input it correctly and try again.'
|
||||
}
|
||||
logger.error('Failed to create account', {error: e})
|
||||
this.rootStore.log.error('Failed to create account', e)
|
||||
this.setIsProcessing(false)
|
||||
this.setError(cleanError(errMsg))
|
||||
throw e
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {makeAutoObservable, reaction} from 'mobx'
|
||||
import {SavedFeedsModel} from './saved-feeds'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {FeedsDiscoveryModel} from '../discovery/feeds'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {CustomFeedModel} from '../feeds/custom-feed'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
export type MyFeedsItem =
|
||||
@@ -30,7 +29,7 @@ export type MyFeedsItem =
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'saved-feed'
|
||||
feed: FeedSourceModel
|
||||
feed: CustomFeedModel
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
@@ -47,19 +46,21 @@ export type MyFeedsItem =
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'discover-feed'
|
||||
feed: FeedSourceModel
|
||||
feed: CustomFeedModel
|
||||
}
|
||||
|
||||
export class MyFeedsUIModel {
|
||||
saved: SavedFeedsModel
|
||||
discovery: FeedsDiscoveryModel
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this)
|
||||
this.saved = new SavedFeedsModel(this.rootStore)
|
||||
this.discovery = new FeedsDiscoveryModel(this.rootStore)
|
||||
}
|
||||
|
||||
get saved() {
|
||||
return this.rootStore.me.savedFeeds
|
||||
}
|
||||
|
||||
get isRefreshing() {
|
||||
return !this.saved.isLoading && this.saved.isRefreshing
|
||||
}
|
||||
@@ -77,26 +78,6 @@ export class MyFeedsUIModel {
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.saved.clear()
|
||||
this.discovery.clear()
|
||||
}
|
||||
|
||||
registerListeners() {
|
||||
const dispose1 = reaction(
|
||||
() => this.rootStore.preferences.savedFeeds,
|
||||
() => this.saved.refresh(),
|
||||
)
|
||||
const dispose2 = reaction(
|
||||
() => this.rootStore.preferences.pinnedFeeds,
|
||||
() => this.saved.refresh(),
|
||||
)
|
||||
return () => {
|
||||
dispose1()
|
||||
dispose2()
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
return Promise.all([this.saved.refresh(), this.discovery.refresh()])
|
||||
}
|
||||
@@ -112,7 +93,7 @@ export class MyFeedsUIModel {
|
||||
_reactKey: '__saved_feeds_header__',
|
||||
type: 'saved-feeds-header',
|
||||
})
|
||||
if (this.saved.isLoading && !this.saved.hasContent) {
|
||||
if (this.saved.isLoading) {
|
||||
items.push({
|
||||
_reactKey: '__saved_feeds_loading__',
|
||||
type: 'saved-feeds-loading',
|
||||
|
||||
@@ -14,7 +14,6 @@ import {deviceLocales} from 'platform/detection'
|
||||
import {getAge} from 'lib/strings/time'
|
||||
import {FeedTuner} from 'lib/api/feed-manip'
|
||||
import {LANGUAGES} from '../../../locale/languages'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
export type LabelPreference = APILabelPreference | 'show'
|
||||
@@ -195,7 +194,7 @@ export class PreferencesModel {
|
||||
/**
|
||||
* This function fetches preferences and sets defaults for missing items.
|
||||
*/
|
||||
async sync() {
|
||||
async sync({clearCache}: {clearCache?: boolean} = {}) {
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
// fetch preferences
|
||||
@@ -247,12 +246,14 @@ export class PreferencesModel {
|
||||
})
|
||||
await this.rootStore.agent.setSavedFeeds(saved, pinned)
|
||||
} catch (error) {
|
||||
logger.error('Failed to set default feeds', {error})
|
||||
this.rootStore.log.error('Failed to set default feeds', {error})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
|
||||
await this.rootStore.me.savedFeeds.updateCache(clearCache)
|
||||
}
|
||||
|
||||
async syncLegacyPreferences() {
|
||||
@@ -285,9 +286,6 @@ export class PreferencesModel {
|
||||
}
|
||||
}
|
||||
|
||||
// languages
|
||||
// =
|
||||
|
||||
hasContentLanguage(code2: string) {
|
||||
return this.contentLanguages.includes(code2)
|
||||
}
|
||||
@@ -360,9 +358,6 @@ export class PreferencesModel {
|
||||
return all.join(', ')
|
||||
}
|
||||
|
||||
// moderation
|
||||
// =
|
||||
|
||||
async setContentLabelPref(
|
||||
key: keyof LabelPreferencesModel,
|
||||
value: LabelPreference,
|
||||
@@ -414,13 +409,6 @@ export class PreferencesModel {
|
||||
}
|
||||
}
|
||||
|
||||
// feeds
|
||||
// =
|
||||
|
||||
isPinnedFeed(uri: string) {
|
||||
return this.pinnedFeeds.includes(uri)
|
||||
}
|
||||
|
||||
async _optimisticUpdateSavedFeeds(
|
||||
saved: string[],
|
||||
pinned: string[],
|
||||
@@ -486,9 +474,6 @@ export class PreferencesModel {
|
||||
)
|
||||
}
|
||||
|
||||
// other
|
||||
// =
|
||||
|
||||
async setBirthDate(birthDate: Date) {
|
||||
this.birthDate = birthDate
|
||||
await this.lock.acquireAsync()
|
||||
@@ -617,7 +602,7 @@ export class PreferencesModel {
|
||||
}
|
||||
|
||||
getFeedTuners(
|
||||
feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
|
||||
feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
|
||||
) {
|
||||
if (feedType === 'custom') {
|
||||
return [
|
||||
@@ -625,9 +610,6 @@ export class PreferencesModel {
|
||||
FeedTuner.preferredLangOnly(this.contentLanguages),
|
||||
]
|
||||
}
|
||||
if (feedType === 'list') {
|
||||
return [FeedTuner.dedupReposts]
|
||||
}
|
||||
if (feedType === 'home' || feedType === 'following') {
|
||||
const feedTuners = []
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {ProfileModel} from '../content/profile'
|
||||
import {PostsFeedModel} from '../feeds/posts'
|
||||
import {ActorFeedsModel} from '../lists/actor-feeds'
|
||||
import {ListsListModel} from '../lists/lists-list'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export enum Sections {
|
||||
PostsNoReplies = 'Posts',
|
||||
@@ -224,10 +223,10 @@ export class ProfileUiModel {
|
||||
await Promise.all([
|
||||
this.profile
|
||||
.setup()
|
||||
.catch(err => logger.error('Failed to fetch profile', {error: err})),
|
||||
.catch(err => this.rootStore.log.error('Failed to fetch profile', err)),
|
||||
this.feed
|
||||
.setup()
|
||||
.catch(err => logger.error('Failed to fetch feed', {error: err})),
|
||||
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
|
||||
])
|
||||
runInAction(() => {
|
||||
this.isAuthenticatedUser =
|
||||
@@ -238,7 +237,7 @@ export class ProfileUiModel {
|
||||
this.lists.source = this.profile.did
|
||||
this.lists
|
||||
.loadMore()
|
||||
.catch(err => logger.error('Failed to fetch lists', {error: err}))
|
||||
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -2,9 +2,8 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {CustomFeedModel} from '../feeds/custom-feed'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class SavedFeedsModel {
|
||||
// state
|
||||
@@ -14,7 +13,7 @@ export class SavedFeedsModel {
|
||||
error = ''
|
||||
|
||||
// data
|
||||
all: FeedSourceModel[] = []
|
||||
_feedModelCache: Record<string, CustomFeedModel> = {}
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
@@ -39,11 +38,20 @@ export class SavedFeedsModel {
|
||||
}
|
||||
|
||||
get pinned() {
|
||||
return this.all.filter(feed => feed.isPinned)
|
||||
return this.rootStore.preferences.pinnedFeeds
|
||||
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
get unpinned() {
|
||||
return this.all.filter(feed => !feed.isPinned)
|
||||
return this.rootStore.preferences.savedFeeds
|
||||
.filter(uri => !this.isPinned(uri))
|
||||
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
get all() {
|
||||
return [...this.pinned, ...this.unpinned]
|
||||
}
|
||||
|
||||
get pinnedFeedNames() {
|
||||
@@ -53,9 +61,52 @@ export class SavedFeedsModel {
|
||||
// public api
|
||||
// =
|
||||
|
||||
clear() {
|
||||
this.all = []
|
||||
}
|
||||
/**
|
||||
* Syncs the cached models against the current state
|
||||
* - Should only be called by the preferences model after syncing state
|
||||
*/
|
||||
updateCache = bundleAsync(async (clearCache?: boolean) => {
|
||||
let newFeedModels: Record<string, CustomFeedModel> = {}
|
||||
if (!clearCache) {
|
||||
newFeedModels = {...this._feedModelCache}
|
||||
}
|
||||
|
||||
// collect the feed URIs that havent been synced yet
|
||||
const neededFeedUris = []
|
||||
for (const feedUri of this.rootStore.preferences.savedFeeds) {
|
||||
if (!(feedUri in newFeedModels)) {
|
||||
neededFeedUris.push(feedUri)
|
||||
}
|
||||
}
|
||||
|
||||
// early exit if no feeds need to be fetched
|
||||
if (!neededFeedUris.length || neededFeedUris.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// fetch the missing models
|
||||
try {
|
||||
for (let i = 0; i < neededFeedUris.length; i += 25) {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
|
||||
feeds: neededFeedUris.slice(i, 25),
|
||||
})
|
||||
for (const feedInfo of res.data.feeds) {
|
||||
newFeedModels[feedInfo.uri] = new CustomFeedModel(
|
||||
this.rootStore,
|
||||
feedInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch feed models', error)
|
||||
this.rootStore.log.error('Failed to fetch feed models', error)
|
||||
}
|
||||
|
||||
// merge into the cache
|
||||
runInAction(() => {
|
||||
this._feedModelCache = newFeedModels
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Refresh the preferences then reload all feed infos
|
||||
@@ -63,50 +114,88 @@ export class SavedFeedsModel {
|
||||
refresh = bundleAsync(async () => {
|
||||
this._xLoading(true)
|
||||
try {
|
||||
await this.rootStore.preferences.sync()
|
||||
const uris = dedup(
|
||||
this.rootStore.preferences.pinnedFeeds.concat(
|
||||
this.rootStore.preferences.savedFeeds,
|
||||
),
|
||||
)
|
||||
const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri))
|
||||
await Promise.all(feeds.map(f => f.setup()))
|
||||
runInAction(() => {
|
||||
this.all = feeds
|
||||
this._updatePinSortOrder()
|
||||
})
|
||||
await this.rootStore.preferences.sync({clearCache: true})
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
|
||||
this._updatePinSortOrder(feeds.map(f => f.uri))
|
||||
await this.rootStore.preferences.setSavedFeeds(
|
||||
async save(feed: CustomFeedModel) {
|
||||
try {
|
||||
await feed.save()
|
||||
await this.updateCache()
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.error('Failed to save feed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async unsave(feed: CustomFeedModel) {
|
||||
const uri = feed.uri
|
||||
try {
|
||||
if (this.isPinned(feed)) {
|
||||
await this.rootStore.preferences.removePinnedFeed(uri)
|
||||
}
|
||||
await feed.unsave()
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.error('Failed to unsave feed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async togglePinnedFeed(feed: CustomFeedModel) {
|
||||
if (!this.isPinned(feed)) {
|
||||
track('CustomFeed:Pin', {
|
||||
name: feed.data.displayName,
|
||||
uri: feed.uri,
|
||||
})
|
||||
return this.rootStore.preferences.addPinnedFeed(feed.uri)
|
||||
} else {
|
||||
track('CustomFeed:Unpin', {
|
||||
name: feed.data.displayName,
|
||||
uri: feed.uri,
|
||||
})
|
||||
return this.rootStore.preferences.removePinnedFeed(feed.uri)
|
||||
}
|
||||
}
|
||||
|
||||
async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
|
||||
return this.rootStore.preferences.setSavedFeeds(
|
||||
this.rootStore.preferences.savedFeeds,
|
||||
feeds.filter(feed => feed.isPinned).map(feed => feed.uri),
|
||||
feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
|
||||
)
|
||||
}
|
||||
|
||||
async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
|
||||
isPinned(feedOrUri: CustomFeedModel | string) {
|
||||
let uri: string
|
||||
if (typeof feedOrUri === 'string') {
|
||||
uri = feedOrUri
|
||||
} else {
|
||||
uri = feedOrUri.uri
|
||||
}
|
||||
return this.rootStore.preferences.pinnedFeeds.includes(uri)
|
||||
}
|
||||
|
||||
async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
|
||||
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
|
||||
const index = pinned.indexOf(item.uri)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
if (direction === 'up' && index !== 0) {
|
||||
;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
|
||||
const temp = pinned[index]
|
||||
pinned[index] = pinned[index - 1]
|
||||
pinned[index - 1] = temp
|
||||
} else if (direction === 'down' && index < pinned.length - 1) {
|
||||
;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
|
||||
const temp = pinned[index]
|
||||
pinned[index] = pinned[index + 1]
|
||||
pinned[index + 1] = temp
|
||||
}
|
||||
this._updatePinSortOrder(pinned.concat(this.unpinned.map(f => f.uri)))
|
||||
await this.rootStore.preferences.setSavedFeeds(
|
||||
this.rootStore.preferences.savedFeeds,
|
||||
pinned,
|
||||
)
|
||||
track('CustomFeed:Reorder', {
|
||||
name: item.displayName,
|
||||
name: item.data.displayName,
|
||||
uri: item.uri,
|
||||
index: pinned.indexOf(item.uri),
|
||||
})
|
||||
@@ -127,23 +216,7 @@ export class SavedFeedsModel {
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user feeds', {err})
|
||||
this.rootStore.log.error('Failed to fetch user feeds', err)
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
_updatePinSortOrder(order?: string[]) {
|
||||
order ??= this.rootStore.preferences.pinnedFeeds.concat(
|
||||
this.rootStore.preferences.savedFeeds,
|
||||
)
|
||||
this.all.sort((a, b) => {
|
||||
return order!.indexOf(a.uri) - order!.indexOf(b.uri)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function dedup(strings: string[]): string[] {
|
||||
return Array.from(new Set(strings))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
|
||||
import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {ProfileModel} from '../content/profile'
|
||||
@@ -60,25 +60,17 @@ export type ReportModal = {
|
||||
| {did: string}
|
||||
)
|
||||
|
||||
export interface CreateOrEditListModal {
|
||||
name: 'create-or-edit-list'
|
||||
purpose?: string
|
||||
export interface CreateOrEditMuteListModal {
|
||||
name: 'create-or-edit-mute-list'
|
||||
list?: ListModel
|
||||
onSave?: (uri: string) => void
|
||||
}
|
||||
|
||||
export interface UserAddRemoveListsModal {
|
||||
name: 'user-add-remove-lists'
|
||||
export interface ListAddRemoveUserModal {
|
||||
name: 'list-add-remove-user'
|
||||
subject: string
|
||||
displayName: string
|
||||
onAdd?: (listUri: string) => void
|
||||
onRemove?: (listUri: string) => void
|
||||
}
|
||||
|
||||
export interface ListAddUserModal {
|
||||
name: 'list-add-user'
|
||||
list: ListModel
|
||||
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export interface EditImageModal {
|
||||
@@ -188,11 +180,8 @@ export type Modal =
|
||||
// Moderation
|
||||
| ModerationDetailsModal
|
||||
| ReportModal
|
||||
|
||||
// Lists
|
||||
| CreateOrEditListModal
|
||||
| UserAddRemoveListsModal
|
||||
| ListAddUserModal
|
||||
| CreateOrEditMuteListModal
|
||||
| ListAddRemoveUserModal
|
||||
|
||||
// Posts
|
||||
| AltTextImageModal
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
@@ -29,8 +28,6 @@ import {isNetworkError} from 'lib/strings/errors'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
enum Forms {
|
||||
Login,
|
||||
@@ -82,9 +79,10 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
if (aborted) {
|
||||
return
|
||||
}
|
||||
logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
|
||||
error: err,
|
||||
})
|
||||
store.log.warn(
|
||||
`Failed to fetch service description for ${serviceUrl}`,
|
||||
err,
|
||||
)
|
||||
setError(
|
||||
'Unable to contact your service. Please check your Internet connection.',
|
||||
)
|
||||
@@ -93,7 +91,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [store.session, serviceUrl, retryDescribeTrigger])
|
||||
}, [store.session, store.log, serviceUrl, retryDescribeTrigger])
|
||||
|
||||
const onPressRetryConnect = () => setRetryDescribeTrigger({})
|
||||
const onPressForgotPassword = () => {
|
||||
@@ -204,7 +202,7 @@ const ChooseAccountForm = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
|
||||
<View testID="chooseAccountForm">
|
||||
<Text
|
||||
type="2xl-medium"
|
||||
style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
|
||||
@@ -269,7 +267,7 @@ const ChooseAccountForm = ({
|
||||
<View style={s.flex1} />
|
||||
{isProcessing && <ActivityIndicator />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -349,7 +347,7 @@ const LoginForm = ({
|
||||
})
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
logger.warn('Failed to login', {error: e})
|
||||
store.log.warn('Failed to login', e)
|
||||
setIsProcessing(false)
|
||||
if (errMsg.includes('Authentication Required')) {
|
||||
setError('Invalid username or password')
|
||||
@@ -578,7 +576,7 @@ const ForgotPasswordForm = ({
|
||||
onEmailSent()
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
logger.warn('Failed to request password reset', {error: e})
|
||||
store.log.warn('Failed to request password reset', e)
|
||||
setIsProcessing(false)
|
||||
if (isNetworkError(e)) {
|
||||
setError(
|
||||
@@ -694,6 +692,7 @@ const ForgotPasswordForm = ({
|
||||
}
|
||||
|
||||
const SetNewPasswordForm = ({
|
||||
store,
|
||||
error,
|
||||
serviceUrl,
|
||||
setError,
|
||||
@@ -733,7 +732,7 @@ const SetNewPasswordForm = ({
|
||||
onPasswordSet()
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
logger.warn('Failed to set new password', {error: e})
|
||||
store.log.warn('Failed to set new password', e)
|
||||
setIsProcessing(false)
|
||||
if (isNetworkError(e)) {
|
||||
setError(
|
||||
@@ -991,10 +990,4 @@ const styles = StyleSheet.create({
|
||||
marginRight: 5,
|
||||
},
|
||||
dimmed: {opacity: 0.5},
|
||||
|
||||
maxHeight: {
|
||||
// @ts-ignore web only -prf
|
||||
maxHeight: isWeb ? '100vh' : undefined,
|
||||
height: !isWeb ? '100%' : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
|
||||
type Props = {
|
||||
@@ -39,9 +39,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
||||
}
|
||||
|
||||
return (feeds.length ? feeds : []).map(feed => {
|
||||
const model = new FeedSourceModel(store, feed.uri)
|
||||
model.hydrateFeedGenerator(feed)
|
||||
return model
|
||||
return new CustomFeedModel(store, feed)
|
||||
})
|
||||
} catch (e) {
|
||||
return []
|
||||
|
||||
@@ -3,7 +3,6 @@ import {View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {RichText} from 'view/com/util/text/RichText'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
@@ -11,12 +10,12 @@ import {HeartIcon} from 'lib/icons'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
item,
|
||||
}: {
|
||||
item: FeedSourceModel
|
||||
item: CustomFeedModel
|
||||
}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
@@ -55,7 +54,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
},
|
||||
]}>
|
||||
<View style={{marginTop: 2}}>
|
||||
<UserAvatar type="algo" size={42} avatar={item.avatar} />
|
||||
<UserAvatar type="algo" size={42} avatar={item.data.avatar} />
|
||||
</View>
|
||||
<View style={{flex: isMobile ? 1 : undefined}}>
|
||||
<Text
|
||||
@@ -66,11 +65,11 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
</Text>
|
||||
|
||||
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
|
||||
by {sanitizeHandle(item.creatorHandle, '@')}
|
||||
by {sanitizeHandle(item.data.creator.handle, '@')}
|
||||
</Text>
|
||||
|
||||
{item.descriptionRT ? (
|
||||
<RichText
|
||||
{item.data.description ? (
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.text,
|
||||
@@ -80,9 +79,9 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
marginBottom: 18,
|
||||
},
|
||||
]}
|
||||
richText={item.descriptionRT}
|
||||
numberOfLines={6}
|
||||
/>
|
||||
numberOfLines={6}>
|
||||
{item.data.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
||||
@@ -130,7 +129,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
style={[pal.textLight, {position: 'relative', top: 2}]}
|
||||
/>
|
||||
<Text type="lg-medium" style={[pal.text, pal.textLight]}>
|
||||
{item.likeCount || 0}
|
||||
{item.data.likeCount || 0}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {useCameraPermission} from 'lib/hooks/usePermissions'
|
||||
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {isMobileWeb, isNative} from 'platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
type Props = {
|
||||
gallery: GalleryModel
|
||||
@@ -40,7 +39,7 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||
gallery.add(img)
|
||||
} catch (err: any) {
|
||||
// ignore
|
||||
logger.warn('Error using camera', {error: err})
|
||||
store.log.warn('Error using camera', err)
|
||||
}
|
||||
}, [gallery, track, store, requestCameraAccessIfNeeded])
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from 'lib/strings/url-helpers'
|
||||
import {ComposerOpts} from 'state/models/ui/shell'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export function useExternalLinkFetch({
|
||||
setQuote,
|
||||
@@ -47,9 +46,7 @@ export function useExternalLinkFetch({
|
||||
setExtLink(undefined)
|
||||
},
|
||||
err => {
|
||||
logger.error('Failed to fetch post for quote embedding', {
|
||||
error: err,
|
||||
})
|
||||
store.log.error('Failed to fetch post for quote embedding', {err})
|
||||
setExtLink(undefined)
|
||||
},
|
||||
)
|
||||
@@ -67,7 +64,7 @@ export function useExternalLinkFetch({
|
||||
})
|
||||
},
|
||||
err => {
|
||||
logger.error('Failed to fetch feed for embedding', {error: err})
|
||||
store.log.error('Failed to fetch feed for embedding', {err})
|
||||
setExtLink(undefined)
|
||||
},
|
||||
)
|
||||
@@ -85,7 +82,7 @@ export function useExternalLinkFetch({
|
||||
})
|
||||
},
|
||||
err => {
|
||||
logger.error('Failed to fetch list for embedding', {error: err})
|
||||
store.log.error('Failed to fetch list for embedding', {err})
|
||||
setExtLink(undefined)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,12 +2,11 @@ import React from 'react'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useStores} from 'state/index'
|
||||
@@ -15,16 +14,15 @@ import {pluralize} from 'lib/strings/helpers'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
export const CustomFeed = observer(function CustomFeedImpl({
|
||||
item,
|
||||
style,
|
||||
showSaveBtn = false,
|
||||
showDescription = false,
|
||||
showLikes = false,
|
||||
}: {
|
||||
item: FeedSourceModel
|
||||
item: CustomFeedModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
showSaveBtn?: boolean
|
||||
showDescription?: boolean
|
||||
@@ -42,21 +40,21 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
message: `Remove ${item.displayName} from my feeds?`,
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await item.unsave()
|
||||
await store.me.savedFeeds.unsave(item)
|
||||
Toast.show('Removed from my feeds')
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
logger.error('Failed to unsave feed', {error: e})
|
||||
store.log.error('Failed to unsave feed', {e})
|
||||
}
|
||||
},
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
await item.save()
|
||||
await store.me.savedFeeds.save(item)
|
||||
Toast.show('Added to my feeds')
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
logger.error('Failed to save feed', {error: e})
|
||||
store.log.error('Failed to save feed', {e})
|
||||
}
|
||||
}
|
||||
}, [store, item])
|
||||
@@ -67,29 +65,22 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
accessibilityRole="button"
|
||||
style={[styles.container, pal.border, style]}
|
||||
onPress={() => {
|
||||
if (item.type === 'feed-generator') {
|
||||
navigation.push('ProfileFeed', {
|
||||
name: item.creatorDid,
|
||||
rkey: new AtUri(item.uri).rkey,
|
||||
})
|
||||
} else if (item.type === 'list') {
|
||||
navigation.push('ProfileList', {
|
||||
name: item.creatorDid,
|
||||
rkey: new AtUri(item.uri).rkey,
|
||||
})
|
||||
}
|
||||
navigation.push('CustomFeed', {
|
||||
name: item.data.creator.did,
|
||||
rkey: new AtUri(item.data.uri).rkey,
|
||||
})
|
||||
}}
|
||||
key={item.uri}>
|
||||
key={item.data.uri}>
|
||||
<View style={[styles.headerContainer]}>
|
||||
<View style={[s.mr10]}>
|
||||
<UserAvatar type="algo" size={36} avatar={item.avatar} />
|
||||
<UserAvatar type="algo" size={36} avatar={item.data.avatar} />
|
||||
</View>
|
||||
<View style={[styles.headerTextContainer]}>
|
||||
<Text style={[pal.text, s.bold]} numberOfLines={3}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<Text style={[pal.textLight]} numberOfLines={3}>
|
||||
by {sanitizeHandle(item.creatorHandle, '@')}
|
||||
by {sanitizeHandle(item.data.creator.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
{showSaveBtn && (
|
||||
@@ -121,18 +112,16 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showDescription && item.descriptionRT ? (
|
||||
<RichText
|
||||
style={[pal.textLight, styles.description]}
|
||||
richText={item.descriptionRT}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
{showDescription && item.data.description ? (
|
||||
<Text style={[pal.textLight, styles.description]} numberOfLines={3}>
|
||||
{item.data.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{showLikes ? (
|
||||
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
|
||||
Liked by {item.likeCount || 0}{' '}
|
||||
{pluralize(item.likeCount || 0, 'user')}
|
||||
Liked by {item.data.likeCount || 0}{' '}
|
||||
{pluralize(item.data.likeCount || 0, 'user')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -20,7 +20,6 @@ import {TextLink} from '../util/Link'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
|
||||
import useAppState from 'react-native-appstate-hook'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const FeedPage = observer(function FeedPageImpl({
|
||||
testID,
|
||||
@@ -67,10 +66,10 @@ export const FeedPage = observer(function FeedPageImpl({
|
||||
if (feed.isLoading) {
|
||||
return
|
||||
}
|
||||
logger.debug('HomeScreen: Polling for new posts')
|
||||
store.log.debug('HomeScreen: Polling for new posts')
|
||||
feed.checkForLatest()
|
||||
},
|
||||
[appState, isScreenFocused, isPageFocused, feed],
|
||||
[appState, isScreenFocused, isPageFocused, store, feed],
|
||||
)
|
||||
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
@@ -97,7 +96,7 @@ export const FeedPage = observer(function FeedPageImpl({
|
||||
const pollInterval = setInterval(doPoll, POLL_FREQ)
|
||||
|
||||
screen('Feed')
|
||||
logger.debug('HomeScreen: Updating feed')
|
||||
store.log.debug('HomeScreen: Updating feed')
|
||||
feed.checkForLatest()
|
||||
|
||||
return () => {
|
||||
@@ -112,6 +111,10 @@ export const FeedPage = observer(function FeedPageImpl({
|
||||
store.shell.openComposer({})
|
||||
}, [store, track])
|
||||
|
||||
const onPressTryAgain = React.useCallback(() => {
|
||||
feed.refresh()
|
||||
}, [feed])
|
||||
|
||||
const onPressLoadLatest = React.useCallback(() => {
|
||||
scrollToTop()
|
||||
feed.refresh()
|
||||
@@ -176,8 +179,10 @@ export const FeedPage = observer(function FeedPageImpl({
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
<Feed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
key="default"
|
||||
feed={feed}
|
||||
scrollElRef={scrollElRef}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
onScroll={onMainScroll}
|
||||
scrollEventThrottle={100}
|
||||
renderEmptyState={renderEmptyState}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const ListActions = ({
|
||||
muted,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
isOwner,
|
||||
onPressDeleteList,
|
||||
onPressShareList,
|
||||
onPressReportList,
|
||||
reversed = false, // Default value of reversed is false
|
||||
}: {
|
||||
isOwner: boolean
|
||||
muted?: boolean
|
||||
onToggleSubscribed?: () => void
|
||||
onPressEditList?: () => void
|
||||
onPressDeleteList?: () => void
|
||||
onPressShareList?: () => void
|
||||
onPressReportList?: () => void
|
||||
reversed?: boolean // New optional prop
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
let buttons = [
|
||||
<Button
|
||||
key="subscribeListBtn"
|
||||
testID={muted ? 'unsubscribeListBtn' : 'subscribeListBtn'}
|
||||
type={muted ? 'inverted' : 'primary'}
|
||||
label={muted ? 'Unsubscribe' : 'Subscribe & Mute'}
|
||||
accessibilityLabel={muted ? 'Unsubscribe' : 'Subscribe and mute'}
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSubscribed}
|
||||
/>,
|
||||
isOwner && (
|
||||
<Button
|
||||
key="editListBtn"
|
||||
testID="editListBtn"
|
||||
type="default"
|
||||
label="Edit List"
|
||||
accessibilityLabel="Edit list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressEditList}
|
||||
/>
|
||||
),
|
||||
isOwner && (
|
||||
<Button
|
||||
key="deleteListBtn"
|
||||
testID="deleteListBtn"
|
||||
type="default"
|
||||
accessibilityLabel="Delete list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressDeleteList}>
|
||||
<FontAwesomeIcon icon={['far', 'trash-can']} style={[pal.text]} />
|
||||
</Button>
|
||||
),
|
||||
<Button
|
||||
key="shareListBtn"
|
||||
testID="shareListBtn"
|
||||
type="default"
|
||||
accessibilityLabel="Share list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressShareList}>
|
||||
<FontAwesomeIcon icon={'share'} style={[pal.text]} />
|
||||
</Button>,
|
||||
!isOwner && (
|
||||
<Button
|
||||
key="reportListBtn"
|
||||
testID="reportListBtn"
|
||||
type="default"
|
||||
accessibilityLabel="Report list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressReportList}>
|
||||
<FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} />
|
||||
</Button>
|
||||
),
|
||||
]
|
||||
|
||||
// If reversed is true, reverse the array to reverse the order of the buttons
|
||||
if (reversed) {
|
||||
buttons = buttons.filter(Boolean).reverse() // filterting out any falsey values and reversing the array
|
||||
} else {
|
||||
buttons = buttons.filter(Boolean) // filterting out any falsey values
|
||||
}
|
||||
|
||||
return <View style={styles.headerBtns}>{buttons}</View>
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerBtns: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
})
|
||||
@@ -76,10 +76,7 @@ export const ListCard = ({
|
||||
{sanitizeDisplayName(list.name)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||
'Moderation list '}
|
||||
by{' '}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: sanitizeHandle(list.creator.handle, '@')}
|
||||
|
||||
@@ -3,27 +3,34 @@ import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
FlatList,
|
||||
} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {FlatList} from '../util/Views'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {logger} from '#/logger'
|
||||
import {ListActions} from './ListActions'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const HEADER_ITEM = {_reactKey: '__header__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
@@ -32,35 +39,36 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
list,
|
||||
style,
|
||||
scrollElRef,
|
||||
onScroll,
|
||||
onPressTryAgain,
|
||||
renderHeader,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
onPressShareList,
|
||||
onPressReportList,
|
||||
renderEmptyState,
|
||||
testID,
|
||||
scrollEventThrottle,
|
||||
headerOffset = 0,
|
||||
desktopFixedHeightOffset,
|
||||
}: {
|
||||
list: ListModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onScroll?: OnScrollCb
|
||||
onPressTryAgain?: () => void
|
||||
renderHeader: () => JSX.Element
|
||||
renderEmptyState: () => JSX.Element
|
||||
onToggleSubscribed: () => void
|
||||
onPressEditList: () => void
|
||||
onPressDeleteList: () => void
|
||||
onPressShareList: () => void
|
||||
onPressReportList: () => void
|
||||
renderEmptyState?: () => JSX.Element
|
||||
testID?: string
|
||||
scrollEventThrottle?: number
|
||||
headerOffset?: number
|
||||
desktopFixedHeightOffset?: number
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let items: any[] = []
|
||||
let items: any[] = [HEADER_ITEM]
|
||||
if (list.hasLoaded) {
|
||||
if (list.hasError) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
@@ -95,7 +103,7 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
try {
|
||||
await list.refresh()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh lists', {error: err})
|
||||
list.rootStore.log.error('Failed to refresh lists', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [list, track, setIsRefreshing])
|
||||
@@ -105,7 +113,7 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
try {
|
||||
await list.loadMore()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more lists', {error: err})
|
||||
list.rootStore.log.error('Failed to load more lists', err)
|
||||
}
|
||||
}, [list, track])
|
||||
|
||||
@@ -116,18 +124,11 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
const onPressEditMembership = React.useCallback(
|
||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||
store.shell.openModal({
|
||||
name: 'user-add-remove-lists',
|
||||
name: 'list-add-remove-user',
|
||||
subject: profile.did,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
onAdd(listUri: string) {
|
||||
if (listUri === list.uri) {
|
||||
list.cacheAddMember(profile)
|
||||
}
|
||||
},
|
||||
onRemove(listUri: string) {
|
||||
if (listUri === list.uri) {
|
||||
list.cacheRemoveMember(profile)
|
||||
}
|
||||
onUpdate() {
|
||||
list.refresh()
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -144,7 +145,6 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
testID={`user-${profile.handle}-editBtn`}
|
||||
type="default"
|
||||
label="Edit"
|
||||
onPress={() => onPressEditMembership(profile)}
|
||||
@@ -157,7 +157,22 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_ITEM) {
|
||||
return renderEmptyState()
|
||||
if (renderEmptyState) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === HEADER_ITEM) {
|
||||
return list.list ? (
|
||||
<ListHeader
|
||||
list={list.list}
|
||||
isOwner={list.isOwner}
|
||||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressEditList={onPressEditList}
|
||||
onPressDeleteList={onPressDeleteList}
|
||||
onPressShareList={onPressShareList}
|
||||
onPressReportList={onPressReportList}
|
||||
/>
|
||||
) : null
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
@@ -182,59 +197,178 @@ export const ListItems = observer(function ListItemsImpl({
|
||||
}`}
|
||||
profile={(item as AppBskyGraphDefs.ListItemView).subject}
|
||||
renderButton={renderMemberButton}
|
||||
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
renderMemberButton,
|
||||
renderEmptyState,
|
||||
list.list,
|
||||
list.isOwner,
|
||||
list.error,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
onPressShareList,
|
||||
onPressReportList,
|
||||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
isMobile,
|
||||
],
|
||||
)
|
||||
|
||||
const Footer = React.useCallback(
|
||||
() => (
|
||||
<View style={{paddingTop: 20, paddingBottom: 200}}>
|
||||
{list.isLoading && <ActivityIndicator />}
|
||||
</View>
|
||||
),
|
||||
[list.isLoading],
|
||||
() =>
|
||||
list.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[list],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={(item: any) => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={renderHeader}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onScroll={onScroll}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
scrollEventThrottle={scrollEventThrottle}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight={desktopFixedHeightOffset || true}
|
||||
/>
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const ListHeader = observer(function ListHeaderImpl({
|
||||
list,
|
||||
isOwner,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
onPressShareList,
|
||||
onPressReportList,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
isOwner: boolean
|
||||
onToggleSubscribed: () => void
|
||||
onPressEditList: () => void
|
||||
onPressDeleteList: () => void
|
||||
onPressShareList: () => void
|
||||
onPressReportList: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const descriptionRT = React.useMemo(
|
||||
() =>
|
||||
list?.description &&
|
||||
new RichText({
|
||||
text: list.description,
|
||||
facets: (list.descriptionFacets || [])?.slice(),
|
||||
}),
|
||||
[list],
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.header, pal.border]}>
|
||||
<View style={s.flex1}>
|
||||
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
|
||||
{list.name}
|
||||
</Text>
|
||||
{list && (
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
|
||||
by{' '}
|
||||
{list.creator.did === store.me.did ? (
|
||||
'you'
|
||||
) : (
|
||||
<TextLink
|
||||
text={sanitizeHandle(list.creator.handle, '@')}
|
||||
href={makeProfileLink(list.creator)}
|
||||
style={pal.textLight}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{descriptionRT && (
|
||||
<RichTextCom
|
||||
testID="listDescription"
|
||||
style={[pal.text, styles.headerDescription]}
|
||||
richText={descriptionRT}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && (
|
||||
<ListActions
|
||||
isOwner={isOwner}
|
||||
muted={list.viewer?.muted}
|
||||
onPressDeleteList={onPressDeleteList}
|
||||
onPressEditList={onPressEditList}
|
||||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressShareList={onPressShareList}
|
||||
onPressReportList={onPressReportList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View>
|
||||
<UserAvatar type="list" avatar={list.avatar} size={64} />
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
|
||||
<View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
|
||||
<Text type="md-medium" style={[pal.text]}>
|
||||
Muted users
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
headerDescription: {
|
||||
flex: 1,
|
||||
marginTop: 8,
|
||||
},
|
||||
headerBtns: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
fakeSelectorItem: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 3,
|
||||
},
|
||||
feedFooter: {paddingTop: 20},
|
||||
})
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
import React from 'react'
|
||||
import React, {MutableRefObject} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList as RNFlatList,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
FlatList,
|
||||
} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {ListCard} from './ListCard'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FlatList} from '../util/Views.web'
|
||||
import {s} from 'lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const LOADING = {_reactKey: '__loading__'}
|
||||
const EMPTY = {_reactKey: '__empty__'}
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const CREATENEW_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
|
||||
export const ListsList = observer(function ListsListImpl({
|
||||
listsList,
|
||||
inline,
|
||||
showAddBtns,
|
||||
style,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onPressCreateNew,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
}: {
|
||||
listsList: ListsListModel
|
||||
inline?: boolean
|
||||
showAddBtns?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressCreateNew: () => void
|
||||
onPressTryAgain?: () => void
|
||||
renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
|
||||
renderItem?: (list: GraphDefs.ListView) => JSX.Element
|
||||
renderEmptyState?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
@@ -47,27 +59,33 @@ export const ListsList = observer(function ListsListImpl({
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let items: any[] = []
|
||||
if (listsList.hasError) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (!listsList.hasLoaded && listsList.isLoading) {
|
||||
items = items.concat([LOADING])
|
||||
} else if (listsList.isEmpty) {
|
||||
items = items.concat([EMPTY])
|
||||
} else {
|
||||
items = items.concat(listsList.lists)
|
||||
}
|
||||
if (listsList.loadMoreError) {
|
||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||
if (listsList.hasLoaded) {
|
||||
if (listsList.hasError) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (listsList.isEmpty) {
|
||||
items = items.concat([EMPTY_ITEM])
|
||||
} else {
|
||||
if (showAddBtns) {
|
||||
items = items.concat([CREATENEW_ITEM])
|
||||
}
|
||||
items = items.concat(listsList.lists)
|
||||
}
|
||||
if (listsList.loadMoreError) {
|
||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
} else if (listsList.isLoading) {
|
||||
items = items.concat([LOADING_ITEM])
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
listsList.hasError,
|
||||
listsList.hasLoaded,
|
||||
listsList.isLoading,
|
||||
listsList.lists,
|
||||
listsList.isEmpty,
|
||||
listsList.lists,
|
||||
listsList.loadMoreError,
|
||||
showAddBtns,
|
||||
])
|
||||
|
||||
// events
|
||||
@@ -79,7 +97,7 @@ export const ListsList = observer(function ListsListImpl({
|
||||
try {
|
||||
await listsList.refresh()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh lists', {error: err})
|
||||
listsList.rootStore.log.error('Failed to refresh lists', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [listsList, track, setIsRefreshing])
|
||||
@@ -89,7 +107,7 @@ export const ListsList = observer(function ListsListImpl({
|
||||
try {
|
||||
await listsList.loadMore()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more lists', {error: err})
|
||||
listsList.rootStore.log.error('Failed to load more lists', err)
|
||||
}
|
||||
}, [listsList, track])
|
||||
|
||||
@@ -101,15 +119,14 @@ export const ListsList = observer(function ListsListImpl({
|
||||
// =
|
||||
|
||||
const renderItemInner = React.useCallback(
|
||||
({item, index}: {item: any; index: number}) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<View
|
||||
testID="listsEmpty"
|
||||
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
|
||||
<Text style={pal.textLight}>You have no lists.</Text>
|
||||
</View>
|
||||
)
|
||||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_ITEM) {
|
||||
if (renderEmptyState) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === CREATENEW_ITEM) {
|
||||
return <CreateNewItem onPress={onPressCreateNew} />
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
@@ -124,15 +141,11 @@ export const ListsList = observer(function ListsListImpl({
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING) {
|
||||
return (
|
||||
<View style={{padding: 20}}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
}
|
||||
return renderItem ? (
|
||||
renderItem(item, index)
|
||||
renderItem(item)
|
||||
) : (
|
||||
<ListCard
|
||||
list={item}
|
||||
@@ -141,17 +154,24 @@ export const ListsList = observer(function ListsListImpl({
|
||||
/>
|
||||
)
|
||||
},
|
||||
[listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
|
||||
[
|
||||
listsList,
|
||||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
onPressCreateNew,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
],
|
||||
)
|
||||
|
||||
const FlatListCom = inline ? RNFlatList : FlatList
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{data.length > 0 && (
|
||||
<FlatListCom
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={(item: any) => item._reactKey}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItemInner}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -159,12 +179,15 @@ export const ListsList = observer(function ListsListImpl({
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={[s.contentContainer]}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
@@ -173,9 +196,36 @@ export const ListsList = observer(function ListsListImpl({
|
||||
)
|
||||
})
|
||||
|
||||
function CreateNewItem({onPress}: {onPress: () => void}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[styles.createNewContainer]}>
|
||||
<Button type="default" onPress={onPress} style={styles.createNewButton}>
|
||||
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
|
||||
<Text type="button" style={pal.text}>
|
||||
New Mute List
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
createNewContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
createNewButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
feedFooter: {paddingTop: 20},
|
||||
item: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const snapPoints = ['70%']
|
||||
|
||||
@@ -96,7 +95,7 @@ export function Component({}: {}) {
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.show('Failed to create app password.')
|
||||
logger.error('Failed to create app password', {error: e})
|
||||
store.log.error('Failed to create app password', {e})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const snapPoints = ['100%']
|
||||
|
||||
@@ -66,11 +65,11 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
||||
return
|
||||
}
|
||||
setProcessing(false)
|
||||
logger.warn(
|
||||
store.log.warn(
|
||||
`Failed to fetch service description for ${String(
|
||||
store.agent.service,
|
||||
)}`,
|
||||
{error: err},
|
||||
err,
|
||||
)
|
||||
setError(
|
||||
'Unable to contact your service. Please check your Internet connection.',
|
||||
@@ -80,7 +79,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [store.agent.service, store.session, retryDescribeTrigger])
|
||||
}, [store.agent.service, store.session, store.log, retryDescribeTrigger])
|
||||
|
||||
// events
|
||||
// =
|
||||
@@ -106,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
||||
try {
|
||||
track('EditHandle:SetNewHandle')
|
||||
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
|
||||
logger.debug(`Updating handle to ${newHandle}`)
|
||||
store.log.debug(`Updating handle to ${newHandle}`)
|
||||
await store.agent.updateHandle({
|
||||
handle: newHandle,
|
||||
})
|
||||
@@ -114,7 +113,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
||||
onChanged()
|
||||
} catch (err: any) {
|
||||
setError(cleanError(err))
|
||||
logger.error('Failed to update handle', {handle, error: err})
|
||||
store.log.error('Failed to update handle', {handle, err})
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
@@ -344,7 +343,7 @@ function CustomHandleForm({
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(cleanError(err))
|
||||
logger.error('Failed to verify domain', {handle, error: err})
|
||||
store.log.error('Failed to verify domain', {handle, err})
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
@@ -356,6 +355,7 @@ function CustomHandleForm({
|
||||
setError,
|
||||
canSave,
|
||||
onPressSave,
|
||||
store.log,
|
||||
store.agent,
|
||||
])
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
@@ -104,7 +103,7 @@ const AdultContentEnabledPref = observer(
|
||||
Toast.show(
|
||||
'There was an issue syncing your preferences with the server',
|
||||
)
|
||||
logger.error('Failed to update preferences with server', {error: e})
|
||||
store.log.error('Failed to update preferences with server', {e})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +168,7 @@ const ContentLabelPref = observer(function ContentLabelPrefImpl({
|
||||
Toast.show(
|
||||
'There was an issue syncing your preferences with the server',
|
||||
)
|
||||
logger.error('Failed to update preferences with server', {error: e})
|
||||
store.log.error('Failed to update preferences with server', {e})
|
||||
}
|
||||
},
|
||||
[store, group],
|
||||
|
||||
+15
-43
@@ -1,4 +1,4 @@
|
||||
import React, {useState, useCallback, useMemo} from 'react'
|
||||
import React, {useState, useCallback} from 'react'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -31,11 +31,9 @@ const MAX_DESCRIPTION = 300 // todo
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
export function Component({
|
||||
purpose,
|
||||
onSave,
|
||||
list,
|
||||
}: {
|
||||
purpose?: string
|
||||
onSave?: (uri: string) => void
|
||||
list?: ListModel
|
||||
}) {
|
||||
@@ -46,24 +44,12 @@ export function Component({
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
|
||||
const activePurpose = useMemo(() => {
|
||||
if (list?.data?.purpose) {
|
||||
return list.data.purpose
|
||||
}
|
||||
if (purpose) {
|
||||
return purpose
|
||||
}
|
||||
return 'app.bsky.graph.defs#curatelist'
|
||||
}, [list, purpose])
|
||||
const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
|
||||
const purposeLabel = isCurateList ? 'User' : 'Moderation'
|
||||
|
||||
const [isProcessing, setProcessing] = useState<boolean>(false)
|
||||
const [name, setName] = useState<string>(list?.data?.name || '')
|
||||
const [name, setName] = useState<string>(list?.list?.name || '')
|
||||
const [description, setDescription] = useState<string>(
|
||||
list?.data?.description || '',
|
||||
list?.list?.description || '',
|
||||
)
|
||||
const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
|
||||
const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar)
|
||||
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
@@ -77,7 +63,7 @@ export function Component({
|
||||
setAvatar(undefined)
|
||||
return
|
||||
}
|
||||
track('CreateList:AvatarSelected')
|
||||
track('CreateMuteList:AvatarSelected')
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewAvatar(finalImg)
|
||||
@@ -90,11 +76,7 @@ export function Component({
|
||||
)
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
if (isCurateList) {
|
||||
track('CreateList:SaveCurateList')
|
||||
} else {
|
||||
track('CreateList:SaveModList')
|
||||
}
|
||||
track('CreateMuteList:Save')
|
||||
const nameTrimmed = name.trim()
|
||||
if (!nameTrimmed) {
|
||||
setError('Name is required')
|
||||
@@ -111,23 +93,22 @@ export function Component({
|
||||
description: description.trim(),
|
||||
avatar: newAvatar,
|
||||
})
|
||||
Toast.show(`${purposeLabel} list updated`)
|
||||
Toast.show('Mute list updated')
|
||||
onSave?.(list.uri)
|
||||
} else {
|
||||
const res = await ListModel.createList(store, {
|
||||
purpose: activePurpose,
|
||||
const res = await ListModel.createModList(store, {
|
||||
name,
|
||||
description,
|
||||
avatar: newAvatar,
|
||||
})
|
||||
Toast.show(`${purposeLabel} list created`)
|
||||
Toast.show('Mute list created')
|
||||
onSave?.(res.uri)
|
||||
}
|
||||
store.shell.closeModal()
|
||||
} catch (e: any) {
|
||||
if (isNetworkError(e)) {
|
||||
setError(
|
||||
'Failed to create the list. Check your internet connection and try again.',
|
||||
'Failed to create the mute list. Check your internet connection and try again.',
|
||||
)
|
||||
} else {
|
||||
setError(cleanError(e))
|
||||
@@ -141,9 +122,6 @@ export function Component({
|
||||
error,
|
||||
onSave,
|
||||
store,
|
||||
activePurpose,
|
||||
isCurateList,
|
||||
purposeLabel,
|
||||
name,
|
||||
description,
|
||||
newAvatar,
|
||||
@@ -159,9 +137,9 @@ export function Component({
|
||||
paddingHorizontal: isMobile ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
testID="createOrEditListModal">
|
||||
testID="createOrEditMuteListModal">
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
{list ? 'Edit' : 'New'} {purposeLabel} List
|
||||
{list ? 'Edit Mute List' : 'New Mute List'}
|
||||
</Text>
|
||||
{error !== '' && (
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -185,9 +163,7 @@ export function Component({
|
||||
<TextInput
|
||||
testID="editNameInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder={
|
||||
isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers'
|
||||
}
|
||||
placeholder="e.g. spammers"
|
||||
placeholderTextColor={colors.gray4}
|
||||
value={name}
|
||||
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
|
||||
@@ -204,11 +180,7 @@ export function Component({
|
||||
<TextInput
|
||||
testID="editDescriptionInput"
|
||||
style={[styles.textArea, pal.border, pal.text]}
|
||||
placeholder={
|
||||
isCurateList
|
||||
? 'e.g. The posters who never miss.'
|
||||
: 'e.g. Users that repeatedly reply with ads.'
|
||||
}
|
||||
placeholder="e.g. users that repeatedly reply with ads."
|
||||
placeholderTextColor={colors.gray4}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
multiline
|
||||
@@ -231,7 +203,7 @@ export function Component({
|
||||
onPress={onPressSave}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save"
|
||||
accessibilityHint="">
|
||||
accessibilityHint="Creates the mute list">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
+39
-33
@@ -1,6 +1,6 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
@@ -11,6 +11,7 @@ import {UserAvatar} from '../util/UserAvatar'
|
||||
import {ListsList} from '../lists/ListsList'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {ListMembershipModel} from 'state/models/content/list-membership'
|
||||
import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from 'state/index'
|
||||
@@ -20,20 +21,17 @@ import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
export const Component = observer(function UserAddRemoveListsImpl({
|
||||
export const Component = observer(function ListAddRemoveUserImpl({
|
||||
subject,
|
||||
displayName,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
}: {
|
||||
subject: string
|
||||
displayName: string
|
||||
onAdd?: (listUri: string) => void
|
||||
onRemove?: (listUri: string) => void
|
||||
onUpdate?: () => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
@@ -63,7 +61,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
setMembershipsLoaded(true)
|
||||
},
|
||||
err => {
|
||||
logger.error('Failed to fetch memberships', {error: err})
|
||||
store.log.error('Failed to fetch memberships', {err})
|
||||
},
|
||||
)
|
||||
}, [memberships, listsList, store, setSelected, setMembershipsLoaded])
|
||||
@@ -73,22 +71,25 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
}, [store])
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
let changes
|
||||
try {
|
||||
changes = await memberships.updateTo(selected)
|
||||
await memberships.updateTo(selected)
|
||||
} catch (err) {
|
||||
logger.error('Failed to update memberships', {error: err})
|
||||
store.log.error('Failed to update memberships', {err})
|
||||
return
|
||||
}
|
||||
Toast.show('Lists updated')
|
||||
for (const uri of changes.added) {
|
||||
onAdd?.(uri)
|
||||
}
|
||||
for (const uri of changes.removed) {
|
||||
onRemove?.(uri)
|
||||
}
|
||||
onUpdate?.()
|
||||
store.shell.closeModal()
|
||||
}, [store, selected, memberships, onAdd, onRemove])
|
||||
}, [store, selected, memberships, onUpdate])
|
||||
|
||||
const onPressNewMuteList = useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'create-or-edit-mute-list',
|
||||
onSave: (_uri: string) => {
|
||||
listsList.refresh()
|
||||
},
|
||||
})
|
||||
}, [store, listsList])
|
||||
|
||||
const onToggleSelected = useCallback(
|
||||
(uri: string) => {
|
||||
@@ -102,7 +103,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
)
|
||||
|
||||
const renderItem = useCallback(
|
||||
(list: GraphDefs.ListView, index: number) => {
|
||||
(list: GraphDefs.ListView) => {
|
||||
const isSelected = selected.includes(list.uri)
|
||||
return (
|
||||
<Pressable
|
||||
@@ -110,10 +111,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
style={[
|
||||
styles.listItem,
|
||||
pal.border,
|
||||
{
|
||||
opacity: membershipsLoaded ? 1 : 0.5,
|
||||
borderTopWidth: index === 0 ? 0 : 1,
|
||||
},
|
||||
{opacity: membershipsLoaded ? 1 : 0.5},
|
||||
]}
|
||||
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
||||
list.name
|
||||
@@ -133,11 +131,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
{sanitizeDisplayName(list.name)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
|
||||
'User list '}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||
'Moderation list '}
|
||||
by{' '}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: sanitizeHandle(list.creator.handle, '@')}
|
||||
@@ -172,19 +166,30 @@ export const Component = observer(function UserAddRemoveListsImpl({
|
||||
],
|
||||
)
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return (
|
||||
<EmptyStateWithButton
|
||||
icon="users-slash"
|
||||
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
|
||||
buttonLabel="New Mute List"
|
||||
onPress={onPressNewMuteList}
|
||||
/>
|
||||
)
|
||||
}, [onPressNewMuteList])
|
||||
|
||||
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection
|
||||
const canSaveChanges =
|
||||
!listsList.isEmpty && !isEqual(selected, originalSelections)
|
||||
|
||||
return (
|
||||
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
Update {displayName} in Lists
|
||||
</Text>
|
||||
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text>
|
||||
<ListsList
|
||||
listsList={listsList}
|
||||
inline
|
||||
showAddBtns
|
||||
onPressCreateNew={onPressNewMuteList}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
style={[styles.list, pal.border]}
|
||||
/>
|
||||
<View style={[styles.btns, pal.border]}>
|
||||
@@ -253,6 +258,7 @@ const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
@@ -1,286 +0,0 @@
|
||||
import React, {useEffect, useCallback, useState, useMemo} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
SafeAreaView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from 'state/index'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
export const Component = observer(function Component({
|
||||
list,
|
||||
onAdd,
|
||||
}: {
|
||||
list: ListModel
|
||||
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const [query, setQuery] = useState('')
|
||||
const autocompleteView = useMemo<UserAutocompleteModel>(
|
||||
() => new UserAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
const [isKeyboardVisible] = useIsKeyboardVisible()
|
||||
|
||||
// initial setup
|
||||
useEffect(() => {
|
||||
autocompleteView.setup().then(() => {
|
||||
autocompleteView.setPrefix('')
|
||||
})
|
||||
autocompleteView.setActive(true)
|
||||
list.loadAll()
|
||||
}, [autocompleteView, list])
|
||||
|
||||
const onChangeQuery = useCallback(
|
||||
(text: string) => {
|
||||
setQuery(text)
|
||||
autocompleteView.setPrefix(text)
|
||||
},
|
||||
[setQuery, autocompleteView],
|
||||
)
|
||||
|
||||
const onPressCancelSearch = useCallback(
|
||||
() => onChangeQuery(''),
|
||||
[onChangeQuery],
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
testID="listAddUserModal"
|
||||
style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}>
|
||||
<View style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||
<View style={[styles.searchContainer, pal.border]}>
|
||||
<FontAwesomeIcon icon="search" size={16} />
|
||||
<TextInput
|
||||
testID="searchInput"
|
||||
style={[styles.searchInput, pal.border, pal.text]}
|
||||
placeholder="Search for users"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={query}
|
||||
onChangeText={onChangeQuery}
|
||||
accessible={true}
|
||||
accessibilityLabel="Search"
|
||||
accessibilityHint=""
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
{query ? (
|
||||
<Pressable
|
||||
onPress={onPressCancelSearch}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel search"
|
||||
accessibilityHint="Exits inputting search query"
|
||||
onAccessibilityEscape={onPressCancelSearch}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
color={pal.colors.textLight}
|
||||
/>
|
||||
</Pressable>
|
||||
) : undefined}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={[s.flex1]}
|
||||
keyboardDismissMode="none"
|
||||
keyboardShouldPersistTaps="always">
|
||||
{autocompleteView.isLoading ? (
|
||||
<View style={{marginVertical: 20}}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : autocompleteView.suggestions.length ? (
|
||||
<>
|
||||
{autocompleteView.suggestions.slice(0, 40).map((item, i) => (
|
||||
<UserResult
|
||||
key={item.did}
|
||||
list={list}
|
||||
profile={item}
|
||||
noBorder={i === 0}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.textLight,
|
||||
{paddingHorizontal: 12, paddingVertical: 16},
|
||||
]}>
|
||||
No results found for {autocompleteView.prefix}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View
|
||||
style={[
|
||||
styles.btnContainer,
|
||||
{paddingBottom: isKeyboardVisible ? 10 : 20},
|
||||
]}>
|
||||
<Button
|
||||
testID="doneBtn"
|
||||
type="default"
|
||||
onPress={() => store.shell.closeModal()}
|
||||
accessibilityLabel="Done"
|
||||
accessibilityHint=""
|
||||
label="Done"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
function UserResult({
|
||||
profile,
|
||||
list,
|
||||
noBorder,
|
||||
onAdd,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
list: ListModel
|
||||
noBorder: boolean
|
||||
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
|
||||
|
||||
const onPressAdd = useCallback(async () => {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await list.addMember(profile)
|
||||
Toast.show('Added to list')
|
||||
setIsAdded(true)
|
||||
onAdd?.(profile)
|
||||
} catch (e) {
|
||||
Toast.show(cleanError(e))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [list, profile, setIsProcessing, setIsAdded, onAdd])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: noBorder ? 0 : 1,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={{
|
||||
width: 54,
|
||||
paddingLeft: 4,
|
||||
}}>
|
||||
<UserAvatar size={40} avatar={profile.avatar} />
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}}>
|
||||
<Text
|
||||
type="lg"
|
||||
style={[s.bold, pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
|
||||
</View>
|
||||
<View>
|
||||
{isAdded ? (
|
||||
<FontAwesomeIcon icon="check" />
|
||||
) : isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Button
|
||||
testID={`user-${profile.handle}-addBtn`}
|
||||
type="default"
|
||||
label="Add"
|
||||
onPress={onPressAdd}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fixedHeight: {
|
||||
// @ts-ignore web only -prf
|
||||
height: '80vh',
|
||||
},
|
||||
titleSection: {
|
||||
paddingTop: isWeb ? 0 : 4,
|
||||
paddingBottom: isWeb ? 14 : 10,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
marginBottom: 5,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
searchInput: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 32,
|
||||
padding: 14,
|
||||
backgroundColor: colors.blue3,
|
||||
},
|
||||
btnContainer: {
|
||||
paddingTop: 10,
|
||||
},
|
||||
})
|
||||
@@ -16,9 +16,8 @@ import * as ProfilePreviewModal from './ProfilePreview'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
|
||||
import * as ListAddUserModal from './ListAddUser'
|
||||
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||
import * as AltImageModal from './AltImage'
|
||||
import * as EditImageModal from './AltImage'
|
||||
import * as ReportModal from './report/Modal'
|
||||
@@ -102,15 +101,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'report') {
|
||||
snapPoints = ReportModal.snapPoints
|
||||
element = <ReportModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'create-or-edit-list') {
|
||||
snapPoints = CreateOrEditListModal.snapPoints
|
||||
element = <CreateOrEditListModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'user-add-remove-lists') {
|
||||
snapPoints = UserAddRemoveListsModal.snapPoints
|
||||
element = <UserAddRemoveListsModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'list-add-user') {
|
||||
snapPoints = ListAddUserModal.snapPoints
|
||||
element = <ListAddUserModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'create-or-edit-mute-list') {
|
||||
snapPoints = CreateOrEditMuteListModal.snapPoints
|
||||
element = <CreateOrEditMuteListModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'list-add-remove-user') {
|
||||
snapPoints = ListAddRemoveUserModal.snapPoints
|
||||
element = <ListAddRemoveUserModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'delete-account') {
|
||||
snapPoints = DeleteAccountModal.snapPoints
|
||||
element = <DeleteAccountModal.Component />
|
||||
|
||||
@@ -11,9 +11,8 @@ import * as EditProfileModal from './EditProfile'
|
||||
import * as ProfilePreviewModal from './ProfilePreview'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportModal from './report/Modal'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as UserAddRemoveLists from './UserAddRemoveLists'
|
||||
import * as ListAddUserModal from './ListAddUser'
|
||||
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
@@ -80,12 +79,10 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ServerInputModal.Component {...modal} />
|
||||
} else if (modal.name === 'report') {
|
||||
element = <ReportModal.Component {...modal} />
|
||||
} else if (modal.name === 'create-or-edit-list') {
|
||||
element = <CreateOrEditListModal.Component {...modal} />
|
||||
} else if (modal.name === 'user-add-remove-lists') {
|
||||
element = <UserAddRemoveLists.Component {...modal} />
|
||||
} else if (modal.name === 'list-add-user') {
|
||||
element = <ListAddUserModal.Component {...modal} />
|
||||
} else if (modal.name === 'create-or-edit-mute-list') {
|
||||
element = <CreateOrEditMuteListModal.Component {...modal} />
|
||||
} else if (modal.name === 'list-add-remove-user') {
|
||||
element = <ListAddRemoveUserModal.Component {...modal} />
|
||||
} else if (modal.name === 'crop-image') {
|
||||
element = <CropImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'delete-account') {
|
||||
|
||||
@@ -31,25 +31,8 @@ export function Component({
|
||||
description =
|
||||
'Moderator has chosen to set a general warning on the content.'
|
||||
} else if (moderation.cause.type === 'blocking') {
|
||||
if (moderation.cause.source.type === 'list') {
|
||||
const list = moderation.cause.source.list
|
||||
name = 'User Blocked by List'
|
||||
description = (
|
||||
<>
|
||||
This user is included in the{' '}
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href={listUriToHref(list.uri)}
|
||||
text={list.name}
|
||||
style={pal.link}
|
||||
/>{' '}
|
||||
list which you have blocked.
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
name = 'User Blocked'
|
||||
description = 'You have blocked this user. You cannot view their content.'
|
||||
}
|
||||
name = 'User Blocked'
|
||||
description = 'You have blocked this user. You cannot view their content.'
|
||||
} else if (moderation.cause.type === 'blocked-by') {
|
||||
name = 'User Blocks You'
|
||||
description = 'This user has blocked you. You cannot view their content.'
|
||||
|
||||
@@ -15,7 +15,7 @@ import {InputIssueDetails} from './InputIssueDetails'
|
||||
import {ReportReasonOptions} from './ReasonOptions'
|
||||
import {CollectionId} from './types'
|
||||
|
||||
const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
|
||||
const DMCA_LINK = 'https://bsky.app/support/copyright'
|
||||
|
||||
export const snapPoints = [575]
|
||||
|
||||
@@ -60,7 +60,6 @@ export function Component(content: ReportComponentProps) {
|
||||
try {
|
||||
if (issue === '__copyright__') {
|
||||
Linking.openURL(DMCA_LINK)
|
||||
store.shell.closeModal()
|
||||
return
|
||||
}
|
||||
const $type = !isAccountReport
|
||||
|
||||
@@ -11,7 +11,6 @@ import {EmptyState} from '../util/EmptyState'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
@@ -62,9 +61,7 @@ export const Feed = observer(function Feed({
|
||||
setIsPTRing(true)
|
||||
await view.refresh()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh notifications feed', {
|
||||
error: err,
|
||||
})
|
||||
view.rootStore.log.error('Failed to refresh notifications feed', err)
|
||||
} finally {
|
||||
setIsPTRing(false)
|
||||
}
|
||||
@@ -74,9 +71,7 @@ export const Feed = observer(function Feed({
|
||||
try {
|
||||
await view.loadMore()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more notifications', {
|
||||
error: err,
|
||||
})
|
||||
view.rootStore.log.error('Failed to load more notifications', err)
|
||||
}
|
||||
}, [view])
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {TabBar} from 'view/com/pager/TabBar'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {useStores} from 'state/index'
|
||||
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
|
||||
@@ -28,7 +27,10 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const store = useStores()
|
||||
const items = useHomeTabs(store.preferences.pinnedFeeds)
|
||||
const items = useMemo(
|
||||
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
||||
[store.me.savedFeeds.pinnedFeedNames],
|
||||
)
|
||||
const pal = usePalette('default')
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {TabBar} from 'view/com/pager/TabBar'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {useStores} from 'state/index'
|
||||
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {Link} from '../util/Link'
|
||||
@@ -19,9 +18,9 @@ import Animated from 'react-native-reanimated'
|
||||
export const FeedsTabBar = observer(function FeedsTabBarImpl(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const items = useHomeTabs(store.preferences.pinnedFeeds)
|
||||
const pal = usePalette('default')
|
||||
|
||||
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
|
||||
@@ -29,6 +28,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
|
||||
store.shell.openDrawer()
|
||||
}, [store])
|
||||
|
||||
const items = useMemo(
|
||||
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
||||
[store.me.savedFeeds.pinnedFeedNames],
|
||||
)
|
||||
|
||||
const tabBarKey = useMemo(() => {
|
||||
return items.join(',')
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -73,7 +81,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
|
||||
</View>
|
||||
</View>
|
||||
<TabBar
|
||||
key={items.join(',')}
|
||||
key={tabBarKey}
|
||||
onPressSelected={props.onPressSelected}
|
||||
selectedPage={props.selectedPage}
|
||||
onSelect={props.onSelect}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React, {forwardRef} from 'react'
|
||||
import {Animated, View} from 'react-native'
|
||||
import PagerView, {
|
||||
PagerViewOnPageSelectedEvent,
|
||||
PagerViewOnPageScrollEvent,
|
||||
PageScrollStateChangedNativeEvent,
|
||||
} from 'react-native-pager-view'
|
||||
import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export type PageSelectedEvent = PagerViewOnPageSelectedEvent
|
||||
@@ -25,7 +21,6 @@ interface Props {
|
||||
initialPage?: number
|
||||
renderTabBar: RenderTabBarFn
|
||||
onPageSelected?: (index: number) => void
|
||||
onPageSelecting?: (index: number) => void
|
||||
testID?: string
|
||||
}
|
||||
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
@@ -36,15 +31,11 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
initialPage = 0,
|
||||
renderTabBar,
|
||||
onPageSelected,
|
||||
onPageSelecting,
|
||||
testID,
|
||||
}: React.PropsWithChildren<Props>,
|
||||
ref,
|
||||
) {
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
const lastOffset = React.useRef(0)
|
||||
const lastDirection = React.useRef(0)
|
||||
const scrollState = React.useRef('')
|
||||
const pagerView = React.useRef<PagerView>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
@@ -59,61 +50,15 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
[setSelectedPage, onPageSelected],
|
||||
)
|
||||
|
||||
const onPageScroll = React.useCallback(
|
||||
(e: PagerViewOnPageScrollEvent) => {
|
||||
const {position, offset} = e.nativeEvent
|
||||
if (offset === 0) {
|
||||
// offset hits 0 in some awkward spots so we ignore it
|
||||
return
|
||||
}
|
||||
// NOTE
|
||||
// we want to call `onPageSelecting` as soon as the scroll-gesture
|
||||
// enters the "settling" phase, which means the user has released it
|
||||
// we can't infer directionality from the scroll information, so we
|
||||
// track the offset changes. if the offset delta is consistent with
|
||||
// the existing direction during the settling phase, we can say for
|
||||
// certain where it's going and can fire
|
||||
// -prf
|
||||
if (scrollState.current === 'settling') {
|
||||
if (lastDirection.current === -1 && offset < lastOffset.current) {
|
||||
onPageSelecting?.(position)
|
||||
lastDirection.current = 0
|
||||
} else if (
|
||||
lastDirection.current === 1 &&
|
||||
offset > lastOffset.current
|
||||
) {
|
||||
onPageSelecting?.(position + 1)
|
||||
lastDirection.current = 0
|
||||
}
|
||||
} else {
|
||||
if (offset < lastOffset.current) {
|
||||
lastDirection.current = -1
|
||||
} else if (offset > lastOffset.current) {
|
||||
lastDirection.current = 1
|
||||
}
|
||||
}
|
||||
lastOffset.current = offset
|
||||
},
|
||||
[lastOffset, lastDirection, onPageSelecting],
|
||||
)
|
||||
|
||||
const onPageScrollStateChanged = React.useCallback(
|
||||
(e: PageScrollStateChangedNativeEvent) => {
|
||||
scrollState.current = e.nativeEvent.pageScrollState
|
||||
},
|
||||
[scrollState],
|
||||
)
|
||||
|
||||
const onTabBarSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
pagerView.current?.setPage(index)
|
||||
onPageSelecting?.(index)
|
||||
},
|
||||
[pagerView, onPageSelecting],
|
||||
[pagerView],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={s.flex1}>
|
||||
<View testID={testID}>
|
||||
{tabBarPosition === 'top' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
@@ -121,11 +66,9 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
})}
|
||||
<AnimatedPagerView
|
||||
ref={pagerView}
|
||||
style={s.flex1}
|
||||
style={s.h100pct}
|
||||
initialPage={initialPage}
|
||||
onPageScrollStateChanged={onPageScrollStateChanged}
|
||||
onPageSelected={onPageSelectedInner}
|
||||
onPageScroll={onPageScroll}>
|
||||
onPageSelected={onPageSelectedInner}>
|
||||
{children}
|
||||
</AnimatedPagerView>
|
||||
{tabBarPosition === 'bottom' &&
|
||||
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
initialPage?: number
|
||||
renderTabBar: RenderTabBarFn
|
||||
onPageSelected?: (index: number) => void
|
||||
onPageSelecting?: (index: number) => void
|
||||
}
|
||||
export const Pager = React.forwardRef(function PagerImpl(
|
||||
{
|
||||
@@ -22,7 +21,6 @@ export const Pager = React.forwardRef(function PagerImpl(
|
||||
initialPage = 0,
|
||||
renderTabBar,
|
||||
onPageSelected,
|
||||
onPageSelecting,
|
||||
}: React.PropsWithChildren<Props>,
|
||||
ref,
|
||||
) {
|
||||
@@ -36,20 +34,21 @@ export const Pager = React.forwardRef(function PagerImpl(
|
||||
(index: number) => {
|
||||
setSelectedPage(index)
|
||||
onPageSelected?.(index)
|
||||
onPageSelecting?.(index)
|
||||
},
|
||||
[setSelectedPage, onPageSelected, onPageSelecting],
|
||||
[setSelectedPage, onPageSelected],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<View>
|
||||
{tabBarPosition === 'top' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
onSelect: onTabBarSelect,
|
||||
})}
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
|
||||
<View
|
||||
style={selectedPage === i ? undefined : s.hidden}
|
||||
key={`page-${i}`}>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import {LayoutChangeEvent, StyleSheet} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedReaction,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated'
|
||||
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {TabBar} from './TabBar'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
|
||||
const SCROLLED_DOWN_LIMIT = 200
|
||||
|
||||
interface PagerWithHeaderChildParams {
|
||||
headerHeight: number
|
||||
onScroll: OnScrollCb
|
||||
isScrolledDown: boolean
|
||||
}
|
||||
|
||||
export interface PagerWithHeaderProps {
|
||||
testID?: string
|
||||
children:
|
||||
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
|
||||
| ((props: PagerWithHeaderChildParams) => JSX.Element)
|
||||
items: string[]
|
||||
renderHeader?: () => JSX.Element
|
||||
initialPage?: number
|
||||
onPageSelected?: (index: number) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
}
|
||||
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
function PageWithHeaderImpl(
|
||||
{
|
||||
children,
|
||||
testID,
|
||||
items,
|
||||
renderHeader,
|
||||
initialPage,
|
||||
onPageSelected,
|
||||
onCurrentPageSelected,
|
||||
}: PagerWithHeaderProps,
|
||||
ref,
|
||||
) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const [currentPage, setCurrentPage] = React.useState(0)
|
||||
const scrollYs = React.useRef<Record<number, number>>({})
|
||||
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
|
||||
const [tabBarHeight, setTabBarHeight] = React.useState(0)
|
||||
const [headerHeight, setHeaderHeight] = React.useState(0)
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(
|
||||
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
|
||||
)
|
||||
|
||||
// react to scroll updates
|
||||
function onScrollUpdate(v: number) {
|
||||
// track each page's current scroll position
|
||||
scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight)
|
||||
// update the 'is scrolled down' value
|
||||
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
|
||||
}
|
||||
useAnimatedReaction(
|
||||
() => scrollY.value,
|
||||
v => runOnJS(onScrollUpdate)(v),
|
||||
)
|
||||
|
||||
// capture the header bar sizing
|
||||
const onTabBarLayout = React.useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
setTabBarHeight(evt.nativeEvent.layout.height)
|
||||
},
|
||||
[setTabBarHeight],
|
||||
)
|
||||
const onHeaderLayout = React.useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
setHeaderHeight(evt.nativeEvent.layout.height)
|
||||
},
|
||||
[setHeaderHeight],
|
||||
)
|
||||
|
||||
// render the the header and tab bar
|
||||
const headerTransform = useAnimatedStyle(
|
||||
() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: Math.min(
|
||||
Math.min(scrollY.value, headerHeight - tabBarHeight) * -1,
|
||||
0,
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[scrollY, headerHeight, tabBarHeight],
|
||||
)
|
||||
const renderTabBar = React.useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<Animated.View
|
||||
onLayout={onHeaderLayout}
|
||||
style={[
|
||||
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
|
||||
headerTransform,
|
||||
]}>
|
||||
{renderHeader?.()}
|
||||
<TabBar
|
||||
items={items}
|
||||
selectedPage={currentPage}
|
||||
onSelect={props.onSelect}
|
||||
onPressSelected={onCurrentPageSelected}
|
||||
onLayout={onTabBarLayout}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
},
|
||||
[
|
||||
items,
|
||||
renderHeader,
|
||||
headerTransform,
|
||||
currentPage,
|
||||
onCurrentPageSelected,
|
||||
isMobile,
|
||||
onTabBarLayout,
|
||||
onHeaderLayout,
|
||||
],
|
||||
)
|
||||
|
||||
// props to pass into children render functions
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
scrollY.value = e.contentOffset.y
|
||||
},
|
||||
})
|
||||
const childProps = React.useMemo<PagerWithHeaderChildParams>(() => {
|
||||
return {
|
||||
headerHeight,
|
||||
onScroll,
|
||||
isScrolledDown,
|
||||
}
|
||||
}, [headerHeight, onScroll, isScrolledDown])
|
||||
|
||||
const onPageSelectedInner = React.useCallback(
|
||||
(index: number) => {
|
||||
setCurrentPage(index)
|
||||
onPageSelected?.(index)
|
||||
},
|
||||
[onPageSelected, setCurrentPage],
|
||||
)
|
||||
|
||||
const onPageSelecting = React.useCallback(
|
||||
(index: number) => {
|
||||
setCurrentPage(index)
|
||||
if (scrollY.value > headerHeight) {
|
||||
scrollY.value = headerHeight
|
||||
}
|
||||
scrollY.value = withTiming(scrollYs.current[index] || 0, {
|
||||
duration: 170,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
})
|
||||
},
|
||||
[scrollY, setCurrentPage, scrollYs, headerHeight],
|
||||
)
|
||||
|
||||
return (
|
||||
<Pager
|
||||
ref={ref}
|
||||
testID={testID}
|
||||
initialPage={initialPage}
|
||||
onPageSelected={onPageSelectedInner}
|
||||
onPageSelecting={onPageSelecting}
|
||||
renderTabBar={renderTabBar}
|
||||
tabBarPosition="top">
|
||||
{toArray(children)
|
||||
.filter(Boolean)
|
||||
.map(child => {
|
||||
if (child) {
|
||||
return child(childProps)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Pager>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBarMobile: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
},
|
||||
tabBarDesktop: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
// @ts-ignore Web only -prf
|
||||
left: 'calc(50% - 299px)',
|
||||
width: 598,
|
||||
},
|
||||
})
|
||||
|
||||
function toArray<T>(v: T | T[]): T[] {
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
}
|
||||
return [v]
|
||||
}
|
||||
@@ -13,8 +13,7 @@ export interface TabBarProps {
|
||||
items: string[]
|
||||
indicatorColor?: string
|
||||
onSelect?: (index: number) => void
|
||||
onPressSelected?: (index: number) => void
|
||||
onLayout?: (evt: LayoutChangeEvent) => void
|
||||
onPressSelected?: () => void
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
@@ -24,7 +23,6 @@ export function TabBar({
|
||||
indicatorColor,
|
||||
onSelect,
|
||||
onPressSelected,
|
||||
onLayout,
|
||||
}: TabBarProps) {
|
||||
const pal = usePalette('default')
|
||||
const scrollElRef = useRef<ScrollView>(null)
|
||||
@@ -46,7 +44,7 @@ export function TabBar({
|
||||
(index: number) => {
|
||||
onSelect?.(index)
|
||||
if (index === selectedPage) {
|
||||
onPressSelected?.(index)
|
||||
onPressSelected?.()
|
||||
}
|
||||
},
|
||||
[onSelect, selectedPage, onPressSelected],
|
||||
@@ -68,7 +66,7 @@ export function TabBar({
|
||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||
|
||||
return (
|
||||
<View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}>
|
||||
<View testID={testID} style={[pal.view, styles.outer]}>
|
||||
<DraggableScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -120,7 +118,10 @@ const desktopStyles = StyleSheet.create({
|
||||
|
||||
const mobileStyles = StyleSheet.create({
|
||||
outer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'transparent',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
contentContainer: {
|
||||
columnGap: isWeb ? 0 : 20,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user