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(
|
||||
@@ -1,3 +1,10 @@
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
Native: {
|
||||
ReactNativeTracing: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
})),
|
||||
ReactNavigationInstrumentation: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
+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',
|
||||
@@ -45,7 +44,6 @@ module.exports = function (api) {
|
||||
},
|
||||
},
|
||||
],
|
||||
'macros',
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -176,8 +176,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
<meta name="application-name" name="Bluesky">
|
||||
<meta name="generator" name="bskyweb">
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
|
||||
+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/)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Internationalization
|
||||
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app)
|
||||
|
||||
## Tools
|
||||
We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
### Adding new strings
|
||||
When adding a new string, do it as follows:
|
||||
```jsx
|
||||
// Before
|
||||
import { Text } from "react-native";
|
||||
|
||||
<Text>Hello World</Text>
|
||||
```
|
||||
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
|
||||
The `<Trans>` macro will extract the string and add it to the catalog. It is not really a component, but a macro. Further reading [here](https://lingui.dev/ref/macro.html)
|
||||
|
||||
However sometimes you will run into this case:
|
||||
```jsx
|
||||
// Before
|
||||
import { Text } from "react-native";
|
||||
|
||||
const text = "Hello World";
|
||||
<Text accessibilityLabel="Label is here">{text}</Text>
|
||||
```
|
||||
In this case, you cannot use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
const text = t`Hello World`;
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
|
||||
The configuration for translations is defined in `lingui.config.js`
|
||||
|
||||
So the workflow is as follows:
|
||||
1. Wrap messages in Trans macro
|
||||
2. Run `yarn intl:extract` command to generate message catalogs
|
||||
3. Translate message catalogs (send them to translators usually)
|
||||
4. Run `yarn intl:compile` to create runtime catalogs
|
||||
5. Load runtime catalog
|
||||
6. Enjoy translated app!
|
||||
|
||||
### Common pitfalls
|
||||
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
|
||||
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
// ❌ Bad! This code won't work
|
||||
export function Welcome() {
|
||||
const buggyWelcome = useMemo(() => {
|
||||
return i18n._(welcomeMessage);
|
||||
}, []);
|
||||
|
||||
return <div>{buggyWelcome}</div>;
|
||||
}
|
||||
|
||||
// ❌ Bad! This code won't work either because the reference to i18n does not change
|
||||
export function Welcome() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const buggyWelcome = useMemo(() => {
|
||||
return i18n._(welcomeMessage);
|
||||
}, [i18n]);
|
||||
|
||||
return <div>{buggyWelcome}</div>;
|
||||
}
|
||||
|
||||
// ✅ Good! `useMemo` has i18n context in the dependency
|
||||
export function Welcome() {
|
||||
const linguiCtx = useLingui();
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
return linguiCtx.i18n._(welcomeMessage);
|
||||
}, [linguiCtx]);
|
||||
|
||||
return <div>{welcome}</div>;
|
||||
}
|
||||
|
||||
// 🤩 Better! `useMemo` consumes the `_` function from the Lingui context
|
||||
export function Welcome() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
return _(welcomeMessage);
|
||||
}, [_]);
|
||||
|
||||
return <div>{welcome}</div>;
|
||||
}
|
||||
```
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
locales: ['en', 'cs', 'fr', 'hi', 'es'],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locale/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: 'po',
|
||||
}
|
||||
+16
-27
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.56.0",
|
||||
"version": "1.55.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
@@ -13,27 +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",
|
||||
"build:apk": "eas build -p android --profile dev-android-apk",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:compile": "lingui compile"
|
||||
"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",
|
||||
@@ -44,7 +42,6 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^4.5.1",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@react-native-async-storage/async-storage": "1.18.2",
|
||||
@@ -63,7 +60,7 @@
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "5.10.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tiptap/core": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-hard-break": "^2.0.3",
|
||||
@@ -83,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",
|
||||
@@ -122,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",
|
||||
@@ -133,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",
|
||||
@@ -161,18 +157,15 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.5",
|
||||
"@babel/core": "^7.23.2",
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@did-plc/server": "^0.0.1",
|
||||
"@lingui/cli": "^4.5.0",
|
||||
"@lingui/macro": "^4.5.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
|
||||
"@react-native-community/eslint-config": "^3.0.0",
|
||||
"@testing-library/jest-native": "^5.4.1",
|
||||
@@ -197,7 +190,6 @@
|
||||
"@typescript-eslint/parser": "^5.48.2",
|
||||
"babel-jest": "^29.4.2",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"babel-plugin-react-native-web": "^0.18.12",
|
||||
"detox": "^20.13.0",
|
||||
@@ -244,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__",
|
||||
|
||||
+26
-91
@@ -5,126 +5,61 @@ import React, {useState, useEffect} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {enableFreeze} from 'react-native-screens'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {init as initReminders} from '#/state/shell/reminders'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
import {useColorMode} from 'state/shell'
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {i18n} from '@lingui/core'
|
||||
import {I18nProvider} from '@lingui/react'
|
||||
import {messages} from './locale/locales/en/messages'
|
||||
i18n.load('en', messages)
|
||||
i18n.activate('en')
|
||||
|
||||
enableFreeze(true)
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
function InnerApp() {
|
||||
const colorMode = useColorMode()
|
||||
const {isInitialLoad} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
initReminders()
|
||||
analytics.init()
|
||||
notifications.init(queryClient)
|
||||
listenSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
notifications.init(store)
|
||||
store.onSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
})
|
||||
})
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
if (isInitialLoad) {
|
||||
// TODO add a loading state
|
||||
if (!rootStore) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* Session and initial state should be loaded prior to rendering below.
|
||||
*/
|
||||
|
||||
return (
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={colorMode}>
|
||||
<analytics.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</I18nProvider>
|
||||
</analytics.Provider>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: only nothing here can depend on other data or session state, since
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<InnerApp />
|
||||
</LightboxStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default App
|
||||
export default withSentry(App)
|
||||
|
||||
+22
-86
@@ -1,118 +1,54 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {enableFreeze} from 'react-native-screens'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {init as initReminders} from '#/state/shell/reminders'
|
||||
import {useColorMode} from 'state/shell'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {i18n} from '@lingui/core'
|
||||
import {I18nProvider} from '@lingui/react'
|
||||
import {defaultLocale, dynamicActivate} from './locale/i18n'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
enableFreeze(true)
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const colorMode = useColorMode()
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
initReminders()
|
||||
analytics.init()
|
||||
dynamicActivate(defaultLocale) // async import of locale data
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
if (isInitialLoad) {
|
||||
// TODO add a loading state
|
||||
if (!rootStore) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* Session and initial state should be loaded prior to rendering below.
|
||||
*/
|
||||
|
||||
return (
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={colorMode}>
|
||||
<analytics.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
</I18nProvider>
|
||||
<ToastContainer />
|
||||
</analytics.Provider>
|
||||
<ToastContainer />
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: only nothing here can depend on other data or session state, since
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<InnerApp />
|
||||
</LightboxStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default App
|
||||
|
||||
+30
-26
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
@@ -32,26 +33,26 @@ import {isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from './state'
|
||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
|
||||
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'
|
||||
@@ -94,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"
|
||||
@@ -154,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"
|
||||
@@ -172,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
|
||||
@@ -345,8 +341,9 @@ function NotificationsTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function MyProfileTabNavigator() {
|
||||
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
return (
|
||||
<MyProfileTab.Navigator
|
||||
screenOptions={{
|
||||
@@ -361,23 +358,24 @@ function MyProfileTabNavigator() {
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
getComponent={() => ProfileScreen}
|
||||
initialParams={{
|
||||
name: 'me',
|
||||
name: store.me.did,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
</MyProfileTab.Navigator>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = () => {
|
||||
const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||
const pal = usePalette('default')
|
||||
const numUnread = useUnreadNotifications()
|
||||
const store = useStores()
|
||||
const unreadCountLabel = store.me.notifications.unreadCountLabel
|
||||
|
||||
const title = (page: string) => bskyTitle(page, numUnread)
|
||||
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
||||
return (
|
||||
<Flat.Navigator
|
||||
screenOptions={{
|
||||
@@ -407,10 +405,10 @@ const FlatNavigator = () => {
|
||||
getComponent={() => NotificationsScreen}
|
||||
options={{title: title('Notifications')}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The RoutesContainer should wrap all components which need access
|
||||
@@ -475,6 +473,12 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logModuleInitTrace()
|
||||
|
||||
// Register the navigation container with the Sentry instrumentation (only works on native)
|
||||
if (isNative) {
|
||||
const routingInstrumentation = getRoutingInstrumentation()
|
||||
routingInstrumentation.registerNavigationContainer(navigationRef)
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
@@ -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'
|
||||
@@ -1,26 +1,15 @@
|
||||
import React from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
createClient,
|
||||
AnalyticsProvider,
|
||||
useAnalytics as useAnalyticsOrig,
|
||||
ClientMethods,
|
||||
} from '@segment/analytics-react-native'
|
||||
import {z} from 'zod'
|
||||
import {useSession} from '#/state/session'
|
||||
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'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
export const appInfo = z.object({
|
||||
build: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
export type AppInfo = z.infer<typeof appInfo>
|
||||
|
||||
const segmentClient = createClient({
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
@@ -31,10 +20,10 @@ const segmentClient = createClient({
|
||||
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
const store = useStores()
|
||||
const methods: ClientMethods = useAnalyticsOrig()
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
if (store.session.hasSession) {
|
||||
return {
|
||||
screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
|
||||
track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
|
||||
@@ -55,18 +44,21 @@ export function useAnalytics() {
|
||||
alias: () => Promise<void>,
|
||||
reset: () => Promise<void>,
|
||||
}
|
||||
}, [hasSession, methods])
|
||||
}, [store, methods])
|
||||
}
|
||||
|
||||
export function init() {
|
||||
listenSessionLoaded(account => {
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
store.log.debug('Ping w/hash')
|
||||
} else {
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,40 +66,46 @@ export function init() {
|
||||
// this is a copy of segment's own lifecycle event tracking
|
||||
// we handle it manually to ensure that it never fires while the app is backgrounded
|
||||
// -prf
|
||||
segmentClient.isReady.onChange(async () => {
|
||||
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 = await readAppInfo()
|
||||
const oldAppInfo = store.appInfo
|
||||
const newAppInfo = context.app as AppInfo
|
||||
writeAppInfo(newAppInfo)
|
||||
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
store.setAppInfo(newAppInfo)
|
||||
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
|
||||
if (typeof oldAppInfo === 'undefined') {
|
||||
segmentClient.track('Application Installed', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Installed', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
}
|
||||
} else if (newAppInfo.version !== oldAppInfo.version) {
|
||||
segmentClient.track('Application Updated', {
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Updated', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
previous_version: oldAppInfo.version,
|
||||
previous_build: oldAppInfo.build,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Opened', {
|
||||
from_background: false,
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
previous_version: oldAppInfo.version,
|
||||
previous_build: oldAppInfo.build,
|
||||
})
|
||||
}
|
||||
segmentClient.track('Application Opened', {
|
||||
from_background: false,
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
})
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
@@ -131,16 +129,3 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function writeAppInfo(value: AppInfo) {
|
||||
await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function readAppInfo(): Promise<AppInfo | undefined> {
|
||||
const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
|
||||
const obj = rawData ? JSON.parse(rawData) : undefined
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
AnalyticsProvider,
|
||||
useAnalytics as useAnalyticsOrig,
|
||||
} from '@segment/analytics-react'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {useStores} from 'state/models/root-store'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {logger} from '#/logger'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
const segmentClient = createClient(
|
||||
{
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
@@ -25,10 +23,10 @@ const segmentClient = createClient(
|
||||
export const track = segmentClient?.track?.bind?.(segmentClient)
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
const store = useStores()
|
||||
const methods = useAnalyticsOrig()
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
if (store.session.hasSession) {
|
||||
return methods
|
||||
}
|
||||
// dont send analytics pings for anonymous users
|
||||
@@ -41,18 +39,19 @@ export function useAnalytics() {
|
||||
alias: () => {},
|
||||
reset: () => {},
|
||||
}
|
||||
}, [hasSession, methods])
|
||||
}, [store, methods])
|
||||
}
|
||||
|
||||
export function init() {
|
||||
listenSessionLoaded(account => {
|
||||
if (account.did) {
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
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': {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
import {FeedSourceInfo} from './feed/types'
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
|
||||
)
|
||||
}
|
||||
|
||||
get source(): ReasonFeedSource | undefined {
|
||||
get source(): FeedSourceInfo | undefined {
|
||||
return this.items.find(item => '__source' in item && !!item.__source)
|
||||
?.__source as ReasonFeedSource
|
||||
?.__source as FeedSourceInfo
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
@@ -116,17 +116,6 @@ export class FeedViewPostsSlice {
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopFeedTuner {
|
||||
reset() {}
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
_tunerFns: FeedTunerFn[] = [],
|
||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
||||
): FeedViewPostsSlice[] {
|
||||
return feed.map(item => new FeedViewPostsSlice([item]))
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenUris: Set<string> = new Set()
|
||||
|
||||
|
||||
+13
-12
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetAuthorFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: this._filter(res.data.feed),
|
||||
|
||||
+13
-12
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetCustomFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
constructor(public agent: BskyAgent) {}
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getTimeline({
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getTimeline({
|
||||
cursor,
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
||||
+13
-12
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetActorLikes.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getActorLikes({
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getActorLikes({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public params: GetListFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-82
@@ -1,12 +1,11 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {feedUriToHref} from 'lib/strings/url-helpers'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedTunerFn} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
|
||||
|
||||
const REQUEST_WAIT_MS = 500 // 500ms
|
||||
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
@@ -18,49 +17,28 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
itemCursor = 0
|
||||
sampleCursor = 0
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public params: FeedParams,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
) {
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
|
||||
this.feedCursor = 0
|
||||
this.itemCursor = 0
|
||||
this.sampleCursor = 0
|
||||
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
|
||||
this.customFeeds = shuffle(
|
||||
this.params.mergeFeedSources.map(
|
||||
feedUri =>
|
||||
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.customFeeds = []
|
||||
}
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getTimeline({
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
if (!cursor) {
|
||||
this.reset()
|
||||
}
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
// we capture here to ensure the data has loaded
|
||||
this._captureFeedsIfNeeded()
|
||||
|
||||
const promises = []
|
||||
|
||||
@@ -98,7 +76,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: posts.length ? String(this.itemCursor) : undefined,
|
||||
cursor: posts.length ? 'fake' : undefined,
|
||||
feed: posts,
|
||||
}
|
||||
}
|
||||
@@ -129,15 +107,33 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
// provide follow
|
||||
return this.following.take(1)
|
||||
}
|
||||
|
||||
_captureFeedsIfNeeded() {
|
||||
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
|
||||
return
|
||||
}
|
||||
if (this.customFeeds.length === 0) {
|
||||
this.customFeeds = shuffle(
|
||||
this.rootStore.me.savedFeeds.all.map(
|
||||
feed =>
|
||||
new MergeFeedSource_Custom(
|
||||
this.rootStore,
|
||||
feed.uri,
|
||||
feed.displayName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
sourceInfo: FeedSourceInfo | undefined
|
||||
cursor: string | undefined = undefined
|
||||
queue: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
get numReady() {
|
||||
return this.queue.length
|
||||
@@ -199,12 +195,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await this.agent.getTimeline({cursor, limit})
|
||||
const res = await this.rootStore.agent.getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
const slices = this.tuner.tune(
|
||||
res.data.feed,
|
||||
this.rootStore.preferences.getFeedTuners('home'),
|
||||
{
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
},
|
||||
)
|
||||
res.data.feed = slices.map(slice => slice.rootItem)
|
||||
return res
|
||||
}
|
||||
@@ -214,60 +214,42 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
minDate: Date
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public feedUri: string,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
public feedDisplayName: string,
|
||||
) {
|
||||
super(agent, feedTuners)
|
||||
super(rootStore)
|
||||
this.sourceInfo = {
|
||||
$type: 'reasonFeedSource',
|
||||
displayName: feedUri.split('/').pop() || '',
|
||||
displayName: feedDisplayName,
|
||||
uri: feedUriToHref(feedUri),
|
||||
}
|
||||
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
|
||||
this.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.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,12 @@ export interface FeedAPIResponse {
|
||||
}
|
||||
|
||||
export interface FeedAPI {
|
||||
reset(): void
|
||||
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
|
||||
fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse>
|
||||
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
|
||||
}
|
||||
|
||||
export interface ReasonFeedSource {
|
||||
$type: 'reasonFeedSource'
|
||||
export interface FeedSourceInfo {
|
||||
uri: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === 'object' &&
|
||||
'$type' in v &&
|
||||
v.$type === 'reasonFeedSource'
|
||||
)
|
||||
}
|
||||
|
||||
+42
-14
@@ -4,18 +4,17 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyRichtextFacet,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
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
|
||||
@@ -25,19 +24,46 @@ export interface ExternalEmbedDraft {
|
||||
localThumb?: ImageModel
|
||||
}
|
||||
|
||||
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
||||
if (!didOrHandle) {
|
||||
throw new Error('Invalid handle: ""')
|
||||
}
|
||||
if (didOrHandle.startsWith('did:')) {
|
||||
return didOrHandle
|
||||
}
|
||||
|
||||
// we run the resolution always to ensure freshness
|
||||
const promise = store.agent
|
||||
.resolveHandle({
|
||||
handle: didOrHandle,
|
||||
})
|
||||
.then(res => {
|
||||
store.handleResolutions.cache.set(didOrHandle, res.data.did)
|
||||
return res.data.did
|
||||
})
|
||||
|
||||
// but we can return immediately if it's cached
|
||||
const cached = store.handleResolutions.cache.get(didOrHandle)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
blob: string,
|
||||
encoding: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (isWeb) {
|
||||
// `blob` should be a data uri
|
||||
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
encoding,
|
||||
})
|
||||
} else {
|
||||
// `blob` should be a path to a file in the local FS
|
||||
return agent.uploadBlob(
|
||||
return store.agent.uploadBlob(
|
||||
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
)
|
||||
@@ -54,11 +80,12 @@ interface PostOpts {
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
knownHandles?: Set<string>
|
||||
onStateChange?: (state: string) => void
|
||||
langs?: string[]
|
||||
}
|
||||
|
||||
export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
let embed:
|
||||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
@@ -74,7 +101,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
)
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
await rt.detectFacets(agent)
|
||||
await rt.detectFacets(store.agent)
|
||||
rt = shortenLinks(rt)
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
@@ -107,7 +134,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
@@ -151,13 +178,14 @@ export async function post(agent: BskyAgent, 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(
|
||||
agent,
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
@@ -196,7 +224,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
// add replyTo if post is a reply to another post
|
||||
if (opts.replyTo) {
|
||||
const replyToUrip = new AtUri(opts.replyTo)
|
||||
const parentPost = await agent.getPost({
|
||||
const parentPost = await store.agent.getPost({
|
||||
repo: replyToUrip.host,
|
||||
rkey: replyToUrip.rkey,
|
||||
})
|
||||
@@ -229,7 +257,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
|
||||
try {
|
||||
opts.onStateChange?.('Posting...')
|
||||
return await agent.post({
|
||||
return await store.agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
reply,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {runInAction} from 'mobx'
|
||||
import {deepObserve} from 'mobx-utils'
|
||||
import set from 'lodash.set'
|
||||
|
||||
const ongoingActions = new Set<any>()
|
||||
|
||||
/**
|
||||
* This is a TypeScript function that optimistically updates data on the client-side before sending a
|
||||
* request to the server and rolling back changes if the request fails.
|
||||
* @param {T} model - The object or record that needs to be updated optimistically.
|
||||
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
|
||||
* can be used to perform any necessary actions or updates on the model or UI before the server update
|
||||
* is initiated.
|
||||
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
|
||||
* update operation. This function is called after the previous state of the model has been recorded
|
||||
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
|
||||
* function is called with the result
|
||||
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
|
||||
* server update is successful. It takes in the response from the server update as its parameter. If
|
||||
* this parameter is not provided, nothing will happen after the server update.
|
||||
* @returns A Promise that resolves to `void`.
|
||||
*/
|
||||
export const updateDataOptimistically = async <
|
||||
T extends Record<string, any>,
|
||||
U,
|
||||
>(
|
||||
model: T,
|
||||
preUpdate: () => void,
|
||||
serverUpdate: () => Promise<U>,
|
||||
postUpdate?: (res: U) => void,
|
||||
): Promise<void> => {
|
||||
if (ongoingActions.has(model)) {
|
||||
return
|
||||
}
|
||||
ongoingActions.add(model)
|
||||
|
||||
const prevState: Map<string, any> = new Map<string, any>()
|
||||
const dispose = deepObserve(model, (change, path) => {
|
||||
if (change.observableKind === 'object') {
|
||||
if (change.type === 'update') {
|
||||
prevState.set(
|
||||
[path, change.name].filter(Boolean).join('.'),
|
||||
change.oldValue,
|
||||
)
|
||||
} else if (change.type === 'add') {
|
||||
prevState.set([path, change.name].filter(Boolean).join('.'), undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
preUpdate()
|
||||
dispose()
|
||||
|
||||
try {
|
||||
const res = await serverUpdate()
|
||||
runInAction(() => {
|
||||
postUpdate?.(res)
|
||||
})
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
prevState.forEach((value, path) => {
|
||||
set(model, path, value)
|
||||
})
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
ongoingActions.delete(model)
|
||||
}
|
||||
}
|
||||
@@ -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,11 +0,0 @@
|
||||
export default class BroadcastChannel {
|
||||
constructor(public name: string) {}
|
||||
postMessage(_data: any) {}
|
||||
close() {}
|
||||
onmessage: (event: MessageEvent) => void = () => {}
|
||||
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
|
||||
removeEventListener(
|
||||
_type: string,
|
||||
_listener: (event: MessageEvent) => void,
|
||||
) {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default BroadcastChannel
|
||||
+2
-11
@@ -1,10 +1,4 @@
|
||||
import {Insets, Platform} from 'react-native'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
import {Insets} from 'react-native'
|
||||
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
@@ -49,10 +43,7 @@ export function IS_PROD(url: string) {
|
||||
// until open federation, "production" is defined as the main server
|
||||
// this definition will not work once federation is enabled!
|
||||
// -prf
|
||||
return (
|
||||
url.startsWith('https://bsky.social') ||
|
||||
url.startsWith('https://api.bsky.app')
|
||||
)
|
||||
return url.startsWith('https://bsky.social')
|
||||
}
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {AccountData} from 'state/models/session'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
export function useAccountSwitcher(): [
|
||||
boolean,
|
||||
(v: boolean) => void,
|
||||
(acct: AccountData) => Promise<void>,
|
||||
] {
|
||||
const {track} = useAnalytics()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const store = useStores()
|
||||
const [isSwitching, setIsSwitching] = useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (acct: SessionAccount) => {
|
||||
async (acct: AccountData) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
|
||||
try {
|
||||
await selectAccount(acct)
|
||||
closeAllActiveElements()
|
||||
Toast.show(`Signed in as ${acct.handle}`)
|
||||
} catch (e) {
|
||||
setIsSwitching(true)
|
||||
const success = await store.session.resumeSession(acct)
|
||||
store.shell.closeAllActiveElements()
|
||||
if (success) {
|
||||
resetNavigation()
|
||||
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
|
||||
} else {
|
||||
Toast.show('Sorry! We need you to enter your password.')
|
||||
clearCurrentAccount() // back user out to login
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
store.session.clear()
|
||||
}
|
||||
},
|
||||
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
|
||||
[track, setIsSwitching, navigation, store],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount}
|
||||
return [isSwitching, setIsSwitching, onPressSwitchAccount]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Be warned. This Hook is very buggy unless used in a very constrained way.
|
||||
// To use it safely:
|
||||
//
|
||||
// - DO NOT pass its return value as a prop to any user-defined component.
|
||||
// - DO NOT pass its return value to more than a single component.
|
||||
//
|
||||
// In other words, the only safe way to use it is next to the leaf Reanimated View.
|
||||
//
|
||||
// Relevant bug reports:
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5345
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5360
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5364
|
||||
//
|
||||
// It's great when it works though.
|
||||
export {useAnimatedScrollHandler} from 'react-native-reanimated'
|
||||
@@ -1,44 +0,0 @@
|
||||
import {useRef, useEffect} from 'react'
|
||||
import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
|
||||
|
||||
export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
|
||||
config,
|
||||
deps,
|
||||
) => {
|
||||
const ref = useRef(config)
|
||||
useEffect(() => {
|
||||
ref.current = config
|
||||
})
|
||||
return useAnimatedScrollHandler_BUGGY(
|
||||
{
|
||||
onBeginDrag(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
|
||||
ref.current.onBeginDrag(e)
|
||||
}
|
||||
},
|
||||
onEndDrag(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
|
||||
ref.current.onEndDrag(e)
|
||||
}
|
||||
},
|
||||
onMomentumBegin(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
|
||||
ref.current.onMomentumBegin(e)
|
||||
}
|
||||
},
|
||||
onMomentumEnd(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
|
||||
ref.current.onMomentumEnd(e)
|
||||
}
|
||||
},
|
||||
onScroll(e) {
|
||||
if (typeof ref.current === 'function') {
|
||||
ref.current(e)
|
||||
} else if (ref.current.onScroll) {
|
||||
ref.current.onScroll(e)
|
||||
}
|
||||
},
|
||||
},
|
||||
deps,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
|
||||
const store = useStores()
|
||||
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 view = await fetchView()
|
||||
if (view) {
|
||||
const temp = new CustomFeedModel(store, view)
|
||||
setItem(temp)
|
||||
}
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
|
||||
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
const store = useStores()
|
||||
const state = store.me.follows.getFollowState(profile.did)
|
||||
|
||||
return {
|
||||
state,
|
||||
following: state === FollowState.Following,
|
||||
toggle: React.useCallback(async () => {
|
||||
if (state === FollowState.Following) {
|
||||
try {
|
||||
await store.agent.deleteFollow(
|
||||
store.me.follows.getFollowUri(profile.did),
|
||||
)
|
||||
store.me.follows.removeFollow(profile.did)
|
||||
return {
|
||||
state: FollowState.NotFollowing,
|
||||
following: false,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to delete follow', e)
|
||||
throw e
|
||||
}
|
||||
} else if (state === FollowState.NotFollowing) {
|
||||
try {
|
||||
const res = await store.agent.follow(profile.did)
|
||||
store.me.follows.addFollow(profile.did, {
|
||||
followRecordUri: res.uri,
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
})
|
||||
return {
|
||||
state: FollowState.Following,
|
||||
following: true,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to create follow', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: FollowState.Unknown,
|
||||
following: false,
|
||||
}
|
||||
}, [store, profile, state]),
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import React from 'react'
|
||||
import {autorun} from 'mobx'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function useMinimalShellMode() {
|
||||
const mode = useMinimalShellModeState()
|
||||
const {footerHeight, headerHeight} = useShellLayout()
|
||||
|
||||
const store = useStores()
|
||||
const minimalShellInterp = useSharedValue(0)
|
||||
const footerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
|
||||
},
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const headerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
||||
},
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const fabMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
|
||||
},
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
return autorun(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
minimalShellInterp.value = withTiming(1, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
} else {
|
||||
minimalShellInterp.value = withTiming(0, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [minimalShellInterp, store.shell.minimalShellMode])
|
||||
|
||||
return {
|
||||
footerMinimalShellTransform,
|
||||
headerMinimalShellTransform,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||
|
||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
// change, the function itself will remain the same. This means that if you
|
||||
// use this at a higher level of your tree, and then some state you read in it
|
||||
// changes, there is no mechanism for anything below in the tree to "react"
|
||||
// to this change (e.g. by knowing to call your function again).
|
||||
//
|
||||
// Also, you should avoid calling the returned function during rendering
|
||||
// since the values captured by it are going to lag behind.
|
||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
||||
const ref = useRef(fn)
|
||||
useInsertionEffect(() => {
|
||||
ref.current = fn
|
||||
}, [fn])
|
||||
return useCallback(
|
||||
(...args: any) => {
|
||||
const latestFn = ref.current
|
||||
return latestFn(...args)
|
||||
},
|
||||
[ref],
|
||||
) as unknown as T
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import * as Updates from 'expo-updates'
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export function useOTAUpdate() {
|
||||
const {openModal} = useModalControls()
|
||||
const store = useStores()
|
||||
|
||||
// HELPER FUNCTIONS
|
||||
const showUpdatePopup = useCallback(() => {
|
||||
openModal({
|
||||
store.shell.openModal({
|
||||
name: 'confirm',
|
||||
title: 'Update Available',
|
||||
message:
|
||||
@@ -20,9 +19,9 @@ export function useOTAUpdate() {
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [openModal])
|
||||
}, [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(() => {
|
||||
|
||||
@@ -1,125 +1,66 @@
|
||||
import {useState, useCallback, useMemo} from 'react'
|
||||
import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {
|
||||
useSharedValue,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
ScrollHandlers,
|
||||
} from 'react-native-reanimated'
|
||||
import {useWebMediaQueries} from './useWebMediaQueries'
|
||||
|
||||
function clamp(num: number, min: number, max: number) {
|
||||
'worklet'
|
||||
return Math.min(Math.max(num, min), max)
|
||||
const Y_LIMIT = 10
|
||||
|
||||
const useDeviceLimits = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return {
|
||||
dyLimitUp: isDesktop ? 30 : 10,
|
||||
dyLimitDown: isDesktop ? 150 : 10,
|
||||
}
|
||||
}
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
export type OnScrollHandler = ScrollHandlers<any>
|
||||
export type ResetCb = () => void
|
||||
|
||||
export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const mode = useMinimalShellMode()
|
||||
const setMode = useSetMinimalShellMode()
|
||||
const startDragOffset = useSharedValue<number | null>(null)
|
||||
const startMode = useSharedValue<number | null>(null)
|
||||
|
||||
const onBeginDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
},
|
||||
[mode, startDragOffset, startMode],
|
||||
)
|
||||
|
||||
const onEndDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (e.contentOffset.y < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
}
|
||||
},
|
||||
[startDragOffset, startMode, setMode, mode, headerHeight],
|
||||
)
|
||||
|
||||
const onScroll = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
// Keep track of whether we want to show "scroll to top".
|
||||
if (!isScrolledDown && e.contentOffset.y > s.window.height) {
|
||||
runOnJS(setIsScrolledDown)(true)
|
||||
} else if (isScrolledDown && e.contentOffset.y < s.window.height) {
|
||||
runOnJS(setIsScrolledDown)(false)
|
||||
}
|
||||
|
||||
if (startDragOffset.value === null || startMode.value === null) {
|
||||
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
|
||||
// If we're close enough to the top, always show the shell.
|
||||
// Even if we're not dragging.
|
||||
setMode(false)
|
||||
return
|
||||
}
|
||||
if (isWeb) {
|
||||
// On the web, there is no concept of "starting" the drag.
|
||||
// When we get the first scroll event, we consider that the start.
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The "mode" value is always between 0 and 1.
|
||||
// Figure out how much to move it based on the current dragged distance.
|
||||
const dy = e.contentOffset.y - startDragOffset.value
|
||||
const dProgress = interpolate(
|
||||
dy,
|
||||
[-headerHeight.value, headerHeight.value],
|
||||
[-1, 1],
|
||||
)
|
||||
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
||||
if (newValue !== mode.value) {
|
||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||
mode.value = newValue
|
||||
}
|
||||
if (isWeb) {
|
||||
// On the web, there is no concept of "starting" the drag,
|
||||
// so we don't have any specific anchor point to calculate the distance.
|
||||
// Instead, update it continuosly along the way and diff with the last event.
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
}
|
||||
},
|
||||
[headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
|
||||
)
|
||||
|
||||
const scrollHandler: ScrollHandlers<any> = useMemo(
|
||||
() => ({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
}),
|
||||
[onBeginDrag, onEndDrag, onScroll],
|
||||
)
|
||||
export function useOnMainScroll(
|
||||
store: RootStoreModel,
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
|
||||
|
||||
return [
|
||||
scrollHandler,
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y > s.window.height
|
||||
) {
|
||||
setIsScrolledDown(true)
|
||||
} else if (
|
||||
isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y < s.window.height
|
||||
) {
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
setIsScrolledDown(false)
|
||||
setMode(false)
|
||||
}, [setMode]),
|
||||
store.shell.setMinimalShellMode(false)
|
||||
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
|
||||
}, [store, setIsScrolledDown]),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
/**
|
||||
* Requires consuming component to be wrapped in `observer`:
|
||||
* https://stackoverflow.com/a/71488009
|
||||
*/
|
||||
export function useSetTitle(title?: string) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const numUnread = useUnreadNotifications()
|
||||
const {unreadCountLabel} = useStores().me.notifications
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
navigation.setOptions({title: bskyTitle(title, numUnread)})
|
||||
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
|
||||
}
|
||||
}, [title, navigation, numUnread])
|
||||
}, [title, navigation, unreadCountLabel])
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import {useState, useRef, useEffect, useCallback} from 'react'
|
||||
|
||||
type Task<TServerState> = {
|
||||
isOn: boolean
|
||||
resolve: (serverState: TServerState) => void
|
||||
reject: (e: unknown) => void
|
||||
}
|
||||
|
||||
type TaskQueue<TServerState> = {
|
||||
activeTask: Task<TServerState> | null
|
||||
queuedTask: Task<TServerState> | null
|
||||
}
|
||||
|
||||
function AbortError() {
|
||||
const e = new Error()
|
||||
e.name = 'AbortError'
|
||||
return e
|
||||
}
|
||||
|
||||
export function useToggleMutationQueue<TServerState>({
|
||||
initialState,
|
||||
runMutation,
|
||||
onSuccess,
|
||||
}: {
|
||||
initialState: TServerState
|
||||
runMutation: (
|
||||
prevState: TServerState,
|
||||
nextIsOn: boolean,
|
||||
) => Promise<TServerState>
|
||||
onSuccess: (finalState: TServerState) => void
|
||||
}) {
|
||||
// We use the queue as a mutable object.
|
||||
// This is safe becuase it is not used for rendering.
|
||||
const [queue] = useState<TaskQueue<TServerState>>({
|
||||
activeTask: null,
|
||||
queuedTask: null,
|
||||
})
|
||||
|
||||
async function processQueue() {
|
||||
if (queue.activeTask) {
|
||||
// There is another active processQueue call iterating over tasks.
|
||||
// It will handle any newly added tasks, so we should exit early.
|
||||
return
|
||||
}
|
||||
// To avoid relying on the rendered state, capture it once at the start.
|
||||
// From that point on, and until the queue is drained, we'll use the real server state.
|
||||
let confirmedState: TServerState = initialState
|
||||
try {
|
||||
while (queue.queuedTask) {
|
||||
const prevTask = queue.activeTask
|
||||
const nextTask = queue.queuedTask
|
||||
queue.activeTask = nextTask
|
||||
queue.queuedTask = null
|
||||
if (prevTask?.isOn === nextTask.isOn) {
|
||||
// Skip multiple requests to update to the same value in a row.
|
||||
prevTask.reject(new (AbortError as any)())
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// The state received from the server feeds into the next task.
|
||||
// This lets us queue deletions of not-yet-created resources.
|
||||
confirmedState = await runMutation(confirmedState, nextTask.isOn)
|
||||
nextTask.resolve(confirmedState)
|
||||
} catch (e) {
|
||||
nextTask.reject(e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
onSuccess(confirmedState)
|
||||
queue.activeTask = null
|
||||
queue.queuedTask = null
|
||||
}
|
||||
}
|
||||
|
||||
function queueToggle(isOn: boolean): Promise<TServerState> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// This is a toggle, so the next queued value can safely replace the queued one.
|
||||
if (queue.queuedTask) {
|
||||
queue.queuedTask.reject(new (AbortError as any)())
|
||||
}
|
||||
queue.queuedTask = {isOn, resolve, reject}
|
||||
processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
const queueToggleRef = useRef(queueToggle)
|
||||
useEffect(() => {
|
||||
queueToggleRef.current = queueToggle
|
||||
})
|
||||
const queueToggleStable = useCallback(
|
||||
(isOn: boolean): Promise<TServerState> => {
|
||||
const queueToggleLatest = queueToggleRef.current
|
||||
return queueToggleLatest(isOn)
|
||||
},
|
||||
[],
|
||||
)
|
||||
return queueToggleStable
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
import {LabelValGroup} from './types'
|
||||
|
||||
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'illegal',
|
||||
title: 'Illegal Content',
|
||||
warning: 'Illegal Content',
|
||||
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
|
||||
}
|
||||
|
||||
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-filter',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!filter'],
|
||||
}
|
||||
|
||||
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-warn',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!warn', 'account-security'],
|
||||
}
|
||||
|
||||
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'unknown',
|
||||
title: 'Unknown Label',
|
||||
warning: 'Content Warning',
|
||||
values: [],
|
||||
}
|
||||
|
||||
export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
keyof LabelPreferencesModel,
|
||||
LabelValGroup
|
||||
> = {
|
||||
nsfw: {
|
||||
id: 'nsfw',
|
||||
title: 'Explicit Sexual Images',
|
||||
subtitle: 'i.e. pornography',
|
||||
warning: 'Sexually Explicit',
|
||||
values: ['porn', 'nsfl'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
nudity: {
|
||||
id: 'nudity',
|
||||
title: 'Other Nudity',
|
||||
subtitle: 'Including non-sexual and artistic',
|
||||
warning: 'Nudity',
|
||||
values: ['nudity'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
suggestive: {
|
||||
id: 'suggestive',
|
||||
title: 'Sexually Suggestive',
|
||||
subtitle: 'Does not include nudity',
|
||||
warning: 'Sexually Suggestive',
|
||||
values: ['sexual'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
gore: {
|
||||
id: 'gore',
|
||||
title: 'Violent / Bloody',
|
||||
subtitle: 'Gore, self-harm, torture',
|
||||
warning: 'Violence',
|
||||
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
hate: {
|
||||
id: 'hate',
|
||||
title: 'Hate Group Iconography',
|
||||
subtitle: 'Images of terror groups, articles covering events, etc.',
|
||||
warning: 'Hate Groups',
|
||||
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
|
||||
},
|
||||
spam: {
|
||||
id: 'spam',
|
||||
title: 'Spam',
|
||||
subtitle: 'Excessive unwanted interactions',
|
||||
warning: 'Spam',
|
||||
values: ['spam'],
|
||||
},
|
||||
impersonation: {
|
||||
id: 'impersonation',
|
||||
title: 'Impersonation',
|
||||
subtitle: 'Accounts falsely claiming to be people or orgs',
|
||||
warning: 'Impersonation',
|
||||
values: ['impersonation'],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export type Label = ComAtprotoLabelDefs.Label
|
||||
|
||||
export interface LabelValGroup {
|
||||
id:
|
||||
| keyof LabelPreferencesModel
|
||||
| 'illegal'
|
||||
| 'always-filter'
|
||||
| 'always-warn'
|
||||
| 'unknown'
|
||||
title: string
|
||||
isAdultImagery?: boolean
|
||||
subtitle?: string
|
||||
warning: string
|
||||
values: string[]
|
||||
}
|
||||
+24
-16
@@ -1,10 +1,10 @@
|
||||
import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
// import {match as matchRoute} from 'view/routes'
|
||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
||||
import {ComposerOptsQuote} from 'state/models/ui/shell'
|
||||
|
||||
// TODO
|
||||
// import {Home} from 'view/screens/Home'
|
||||
@@ -22,7 +22,7 @@ import {useGetPost} from '#/state/queries/post'
|
||||
// remove once that's implemented
|
||||
// -prf
|
||||
export async function extractBskyMeta(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<LinkMeta> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
@@ -102,30 +102,38 @@ export async function extractBskyMeta(
|
||||
}
|
||||
|
||||
export async function getPostAsQuote(
|
||||
getPost: ReturnType<typeof useGetPost>,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<ComposerOptsQuote> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
const post = await getPost({uri: uri})
|
||||
const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
const threadView = new PostThreadModel(store, {
|
||||
uri: threadUri,
|
||||
depth: 0,
|
||||
})
|
||||
await threadView.setup()
|
||||
if (!threadView.thread || threadView.notFound) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
return {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
|
||||
indexedAt: post.indexedAt,
|
||||
author: post.author,
|
||||
uri: threadView.thread.post.uri,
|
||||
cid: threadView.thread.post.cid,
|
||||
text: threadView.thread.postRecord?.text || '',
|
||||
indexedAt: threadView.thread.post.indexedAt,
|
||||
author: threadView.thread.post.author,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedAsEmbed(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
|
||||
const res = await agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: feed,
|
||||
@@ -145,13 +153,13 @@ export async function getFeedAsEmbed(
|
||||
}
|
||||
|
||||
export async function getListAsEmbed(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
|
||||
const res = await agent.app.bsky.graph.getList({list})
|
||||
const res = await store.agent.app.bsky.graph.getList({list})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: list,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
|
||||
@@ -23,12 +23,12 @@ export interface LinkMeta {
|
||||
}
|
||||
|
||||
export async function getLinkMeta(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
timeout = 5e3,
|
||||
): Promise<LinkMeta> {
|
||||
if (isBskyAppUrl(url)) {
|
||||
return extractBskyMeta(agent, url)
|
||||
return extractBskyMeta(store, url)
|
||||
}
|
||||
|
||||
let urlp
|
||||
@@ -55,9 +55,9 @@ export async function getLinkMeta(
|
||||
const to = setTimeout(() => controller.abort(), timeout || 5e3)
|
||||
|
||||
const response = await fetch(
|
||||
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
|
||||
url,
|
||||
)}`,
|
||||
`${LINK_META_PROXY(
|
||||
store.session.currentSession?.service || '',
|
||||
)}${encodeURIComponent(url)}`,
|
||||
{signal: controller.signal},
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
|
||||
export async function openAltTextModal(
|
||||
store: RootStoreModel,
|
||||
image: ImageModel,
|
||||
) {
|
||||
store.shell.openModal({
|
||||
name: 'alt-text-image',
|
||||
image,
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {Image} from 'react-native'
|
||||
import type {Dimensions} from 'lib/media/types'
|
||||
|
||||
const sizes: Map<string, Dimensions> = new Map()
|
||||
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
|
||||
|
||||
export function get(uri: string): Dimensions | undefined {
|
||||
return sizes.get(uri)
|
||||
}
|
||||
|
||||
export async function fetch(uri: string): Promise<Dimensions> {
|
||||
const Dimensions = sizes.get(uri)
|
||||
if (Dimensions) {
|
||||
return Dimensions
|
||||
}
|
||||
|
||||
const prom =
|
||||
activeRequests.get(uri) ||
|
||||
new Promise<Dimensions>(resolve => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width: number, height: number) => resolve({width, height}),
|
||||
(err: any) => {
|
||||
console.error('Failed to fetch image dimensions for', uri, err)
|
||||
resolve({width: 0, height: 0})
|
||||
},
|
||||
)
|
||||
})
|
||||
activeRequests.set(uri, prom)
|
||||
const res = await prom
|
||||
activeRequests.delete(uri)
|
||||
sizes.set(uri, res)
|
||||
return res
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
@@ -21,15 +22,18 @@ async function getFile() {
|
||||
})
|
||||
}
|
||||
|
||||
export async function openPicker(): Promise<RNImage[]> {
|
||||
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
|
||||
return [await getFile()]
|
||||
}
|
||||
|
||||
export async function openCamera(): Promise<RNImage> {
|
||||
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
|
||||
return await getFile()
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
return {
|
||||
path: opts.path,
|
||||
mime: 'image/jpeg',
|
||||
|
||||
@@ -3,10 +3,23 @@ import {
|
||||
openCropper as openCropperFn,
|
||||
Image as RNImage,
|
||||
} from 'react-native-image-crop-picker'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
export {openPicker} from './picker.shared'
|
||||
|
||||
export async function openCamera(opts: CameraOpts): Promise<RNImage> {
|
||||
/**
|
||||
* NOTE
|
||||
* These methods all include the RootStoreModel as the first param
|
||||
* because the web versions require it. The signatures have to remain
|
||||
* equivalent between the different forms, but the store param is not
|
||||
* used here.
|
||||
* -prf
|
||||
*/
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
): Promise<RNImage> {
|
||||
const item = await openCameraFn({
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
@@ -26,7 +39,10 @@ export async function openCamera(opts: CameraOpts): Promise<RNImage> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions) {
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
) {
|
||||
const item = await openCropperFn({
|
||||
...opts,
|
||||
forceJpg: true, // ios only
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
export {openPicker} from './picker.shared'
|
||||
import {unstable__openModal} from '#/state/modals'
|
||||
|
||||
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
_opts: CameraOpts,
|
||||
): Promise<RNImage> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
export async function openCropper(
|
||||
store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
// TODO handle more opts
|
||||
return new Promise((resolve, reject) => {
|
||||
unstable__openModal({
|
||||
store.shell.openModal({
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
onSelect: (img?: RNImage) => {
|
||||
|
||||
+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') {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
import {RootStoreModel} from '../../state'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
? 'did:web:api.staging.bsky.dev'
|
||||
: 'did:web:api.bsky.app'
|
||||
|
||||
export function init(queryClient: QueryClient) {
|
||||
listenSessionLoaded(async (account, agent) => {
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
|
||||
|
||||
store.onSessionLoaded(async () => {
|
||||
// request notifications permission once the user has logged in
|
||||
const perms = await Notifications.getPermissionsAsync()
|
||||
if (!perms.granted) {
|
||||
@@ -24,51 +23,39 @@ export function init(queryClient: QueryClient) {
|
||||
const token = await getPushToken()
|
||||
if (token) {
|
||||
try {
|
||||
await agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(account.service),
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
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 agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(account.service),
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
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,14 +63,10 @@ export function init(queryClient: QueryClient) {
|
||||
|
||||
// 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
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
store.me.notifications.syncQueue()
|
||||
// handle payload-based deeplinks
|
||||
let payload
|
||||
if (isIOS) {
|
||||
@@ -92,11 +75,7 @@ export function init(queryClient: QueryClient) {
|
||||
// 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,23 +84,18 @@ export function init(queryClient: QueryClient) {
|
||||
// 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')
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
store.me.notifications.refresh() // refresh notifications
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
|
||||
+1
-11
@@ -1,13 +1,3 @@
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we NEVER want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export const queryClient = new QueryClient()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {BackHandler} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
// only register back handler on android, otherwise it throws an error
|
||||
if (isAndroid) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
backHandler.remove()
|
||||
}
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+46
-2
@@ -1,8 +1,52 @@
|
||||
import {init} from 'sentry-expo'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {FC} from 'react'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
|
||||
init({
|
||||
// Sentry Initialization
|
||||
|
||||
export const getRoutingInstrumentation = () => {
|
||||
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
|
||||
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
|
||||
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
|
||||
environment: __DEV__ ? 'development' : 'production', // Set the environment
|
||||
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
|
||||
enableAutoPerformanceTracking: true, // Enable auto performance tracking
|
||||
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
|
||||
_experiments: {
|
||||
// The sampling rate for profiling is relative to TracesSampleRate.
|
||||
// In this case, we'll capture profiles for 50% of transactions.
|
||||
profilesSampleRate: 0.5,
|
||||
},
|
||||
integrations: isNative
|
||||
? [
|
||||
new Sentry.Native.ReactNativeTracing({
|
||||
shouldCreateSpanForRequest: url => {
|
||||
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
|
||||
return !url.match(/\/logs$/)
|
||||
},
|
||||
routingInstrumentation: getRoutingInstrumentation(),
|
||||
}),
|
||||
]
|
||||
: [], // no integrations for web, yet
|
||||
})
|
||||
|
||||
// if web, use Browser client, otherwise use Native client
|
||||
export function getSentryClient() {
|
||||
if (isWeb) {
|
||||
return Sentry.Browser
|
||||
}
|
||||
return Sentry.Native
|
||||
}
|
||||
|
||||
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
|
||||
export function withSentry(Component: FC) {
|
||||
if (isWeb) {
|
||||
return Component // .wrap is not required or available for web
|
||||
}
|
||||
const sentryClient = getSentryClient()
|
||||
return sentryClient.wrap(Component)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {PROD_SERVICE} from 'lib/constants'
|
||||
import {PROD_SERVICE} from 'state/index'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ export const s = StyleSheet.create({
|
||||
flexRow: {flexDirection: 'row'},
|
||||
flexCol: {flexDirection: 'column'},
|
||||
flex1: {flex: 1},
|
||||
flexGrow1: {flexGrow: 1},
|
||||
alignCenter: {alignItems: 'center'},
|
||||
alignBaseline: {alignItems: 'baseline'},
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
export const locales = {
|
||||
en: 'English',
|
||||
cs: 'Česky',
|
||||
fr: 'Français',
|
||||
hi: 'हिंदी',
|
||||
es: 'Español',
|
||||
}
|
||||
export const defaultLocale = 'en'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
* @param locale any locale string
|
||||
*/
|
||||
export async function dynamicActivate(locale: string) {
|
||||
const {messages} = await import(`./locales/${locale}/messages`)
|
||||
i18n.load(locale, messages)
|
||||
i18n.activate(locale)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
import {useEffect, useState, useCallback, useRef} from 'react'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Shadow} from './types'
|
||||
export type {Shadow} from './types'
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
export interface PostShadow {
|
||||
likeUri: string | undefined
|
||||
likeCount: number | undefined
|
||||
repostUri: string | undefined
|
||||
repostCount: number | undefined
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
|
||||
interface CacheEntry {
|
||||
ts: number
|
||||
value: PostShadow
|
||||
}
|
||||
|
||||
export function usePostShadow(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
ifAfterTS: number,
|
||||
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
|
||||
const [state, setState] = useState<CacheEntry>({
|
||||
ts: Date.now(),
|
||||
value: fromPost(post),
|
||||
})
|
||||
const firstRun = useRef(true)
|
||||
|
||||
const onUpdate = useCallback(
|
||||
(value: Partial<PostShadow>) => {
|
||||
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
// react to shadow updates
|
||||
useEffect(() => {
|
||||
emitter.addListener(post.uri, onUpdate)
|
||||
return () => {
|
||||
emitter.removeListener(post.uri, onUpdate)
|
||||
}
|
||||
}, [post.uri, onUpdate])
|
||||
|
||||
// react to post updates
|
||||
useEffect(() => {
|
||||
// dont fire on first run to avoid needless re-renders
|
||||
if (!firstRun.current) {
|
||||
setState({ts: Date.now(), value: fromPost(post)})
|
||||
}
|
||||
firstRun.current = false
|
||||
}, [post])
|
||||
|
||||
return state.ts > ifAfterTS
|
||||
? mergeShadow(post, state.value)
|
||||
: {...post, isShadowed: true}
|
||||
}
|
||||
|
||||
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
|
||||
emitter.emit(uri, value)
|
||||
}
|
||||
|
||||
export function isPostShadowed(
|
||||
v: AppBskyFeedDefs.PostView | Shadow<AppBskyFeedDefs.PostView>,
|
||||
): v is Shadow<AppBskyFeedDefs.PostView> {
|
||||
return 'isShadowed' in v && !!v.isShadowed
|
||||
}
|
||||
|
||||
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
|
||||
return {
|
||||
likeUri: post.viewer?.like,
|
||||
likeCount: post.likeCount,
|
||||
repostUri: post.viewer?.repost,
|
||||
repostCount: post.repostCount,
|
||||
isDeleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeShadow(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
shadow: PostShadow,
|
||||
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
|
||||
if (shadow.isDeleted) {
|
||||
return POST_TOMBSTONE
|
||||
}
|
||||
return {
|
||||
...post,
|
||||
likeCount: shadow.likeCount,
|
||||
repostCount: shadow.repostCount,
|
||||
viewer: {
|
||||
...(post.viewer || {}),
|
||||
like: shadow.likeUri,
|
||||
repost: shadow.repostUri,
|
||||
},
|
||||
isShadowed: true,
|
||||
}
|
||||
}
|
||||
Vendored
-99
@@ -1,99 +0,0 @@
|
||||
import {useEffect, useState, useCallback, useRef} from 'react'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Shadow} from './types'
|
||||
export type {Shadow} from './types'
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
export interface ProfileShadow {
|
||||
followingUri: string | undefined
|
||||
muted: boolean | undefined
|
||||
blockingUri: string | undefined
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
ts: number
|
||||
value: ProfileShadow
|
||||
}
|
||||
|
||||
type ProfileView =
|
||||
| AppBskyActorDefs.ProfileView
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileViewDetailed
|
||||
|
||||
export function useProfileShadow(
|
||||
profile: ProfileView,
|
||||
ifAfterTS: number,
|
||||
): Shadow<ProfileView> {
|
||||
const [state, setState] = useState<CacheEntry>({
|
||||
ts: Date.now(),
|
||||
value: fromProfile(profile),
|
||||
})
|
||||
const firstRun = useRef(true)
|
||||
|
||||
const onUpdate = useCallback(
|
||||
(value: Partial<ProfileShadow>) => {
|
||||
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
// react to shadow updates
|
||||
useEffect(() => {
|
||||
emitter.addListener(profile.did, onUpdate)
|
||||
return () => {
|
||||
emitter.removeListener(profile.did, onUpdate)
|
||||
}
|
||||
}, [profile.did, onUpdate])
|
||||
|
||||
// react to profile updates
|
||||
useEffect(() => {
|
||||
// dont fire on first run to avoid needless re-renders
|
||||
if (!firstRun.current) {
|
||||
setState({ts: Date.now(), value: fromProfile(profile)})
|
||||
}
|
||||
firstRun.current = false
|
||||
}, [profile])
|
||||
|
||||
return state.ts > ifAfterTS
|
||||
? mergeShadow(profile, state.value)
|
||||
: {...profile, isShadowed: true}
|
||||
}
|
||||
|
||||
export function updateProfileShadow(
|
||||
uri: string,
|
||||
value: Partial<ProfileShadow>,
|
||||
) {
|
||||
emitter.emit(uri, value)
|
||||
}
|
||||
|
||||
export function isProfileShadowed<T extends ProfileView>(
|
||||
v: T | Shadow<T>,
|
||||
): v is Shadow<T> {
|
||||
return 'isShadowed' in v && !!v.isShadowed
|
||||
}
|
||||
|
||||
function fromProfile(profile: ProfileView): ProfileShadow {
|
||||
return {
|
||||
followingUri: profile.viewer?.following,
|
||||
muted: profile.viewer?.muted,
|
||||
blockingUri: profile.viewer?.blocking,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeShadow(
|
||||
profile: ProfileView,
|
||||
shadow: ProfileShadow,
|
||||
): Shadow<ProfileView> {
|
||||
return {
|
||||
...profile,
|
||||
viewer: {
|
||||
...(profile.viewer || {}),
|
||||
following: shadow.followingUri,
|
||||
muted: shadow.muted,
|
||||
blocking: shadow.blockingUri,
|
||||
},
|
||||
isShadowed: true,
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
export type Shadow<T> = T & {isShadowed: true}
|
||||
@@ -1,38 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {SessionAccount} from './session'
|
||||
|
||||
type UnlistenFn = () => void
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
// a "soft reset" typically means scrolling to top and loading latest
|
||||
// but it can depend on the screen
|
||||
export function emitSoftReset() {
|
||||
emitter.emit('soft-reset')
|
||||
}
|
||||
export function listenSoftReset(fn: () => void): UnlistenFn {
|
||||
emitter.on('soft-reset', fn)
|
||||
return () => emitter.off('soft-reset', fn)
|
||||
}
|
||||
|
||||
export function emitSessionLoaded(
|
||||
sessionAccount: SessionAccount,
|
||||
agent: BskyAgent,
|
||||
) {
|
||||
emitter.emit('session-loaded', sessionAccount, agent)
|
||||
}
|
||||
export function listenSessionLoaded(
|
||||
fn: (sessionAccount: SessionAccount, agent: BskyAgent) => void,
|
||||
): UnlistenFn {
|
||||
emitter.on('session-loaded', fn)
|
||||
return () => emitter.off('session-loaded', fn)
|
||||
}
|
||||
|
||||
export function emitSessionDropped() {
|
||||
emitter.emit('session-dropped')
|
||||
}
|
||||
export function listenSessionDropped(fn: () => void): UnlistenFn {
|
||||
emitter.on('session-dropped', fn)
|
||||
return () => emitter.off('session-dropped', fn)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {autorun} from 'mobx'
|
||||
import {AppState, Platform} from 'react-native'
|
||||
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'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
const ROOT_STATE_STORAGE_KEY = 'root'
|
||||
const STATE_FETCH_INTERVAL = 15e3
|
||||
|
||||
export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
||||
let rootStore: RootStoreModel
|
||||
let data: any
|
||||
|
||||
apiPolyfill.doPolyfill()
|
||||
|
||||
rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
|
||||
try {
|
||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
|
||||
rootStore.hydrate(data)
|
||||
} catch (e: any) {
|
||||
rootStore.log.error('Failed to load state from storage', e)
|
||||
}
|
||||
rootStore.attemptSessionResumption()
|
||||
|
||||
// track changes & save to storage
|
||||
autorun(() => {
|
||||
const snapshot = rootStore.serialize()
|
||||
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
|
||||
})
|
||||
|
||||
// periodic state fetch
|
||||
setInterval(() => {
|
||||
// NOTE
|
||||
// this must ONLY occur when the app is active, as the bg-fetch handler
|
||||
// will wake up the thread and cause this interval to fire, which in
|
||||
// turn schedules a bunch of work at a poor time
|
||||
// -prf
|
||||
if (AppState.currentState === 'active') {
|
||||
rootStore.updateSessionState()
|
||||
}
|
||||
}, STATE_FETCH_INTERVAL)
|
||||
|
||||
return rootStore
|
||||
}
|
||||
|
||||
export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store'
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = persisted.Schema['invites']
|
||||
type ApiContext = {
|
||||
setInviteCopied: (code: string) => void
|
||||
}
|
||||
|
||||
const stateContext = React.createContext<StateContext>(
|
||||
persisted.defaults.invites,
|
||||
)
|
||||
const apiContext = React.createContext<ApiContext>({
|
||||
setInviteCopied(_: string) {},
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [state, setState] = React.useState(persisted.get('invites'))
|
||||
|
||||
const api = React.useMemo(
|
||||
() => ({
|
||||
setInviteCopied(code: string) {
|
||||
setState(state => {
|
||||
state = {
|
||||
...state,
|
||||
copiedInvites: state.copiedInvites.includes(code)
|
||||
? state.copiedInvites
|
||||
: state.copiedInvites.concat([code]),
|
||||
}
|
||||
persisted.write('invites', state)
|
||||
return state
|
||||
})
|
||||
},
|
||||
}),
|
||||
[setState],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('invites'))
|
||||
})
|
||||
}, [setState])
|
||||
|
||||
return (
|
||||
<stateContext.Provider value={state}>
|
||||
<apiContext.Provider value={api}>{children}</apiContext.Provider>
|
||||
</stateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInvitesState() {
|
||||
return React.useContext(stateContext)
|
||||
}
|
||||
|
||||
export function useInvitesAPI() {
|
||||
return React.useContext(apiContext)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
interface Lightbox {
|
||||
name: string
|
||||
}
|
||||
|
||||
export class ProfileImageLightbox implements Lightbox {
|
||||
name = 'profile-image'
|
||||
constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {}
|
||||
}
|
||||
|
||||
interface ImagesLightboxItem {
|
||||
uri: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export class ImagesLightbox implements Lightbox {
|
||||
name = 'images'
|
||||
constructor(public images: ImagesLightboxItem[], public index: number) {}
|
||||
setIndex(index: number) {
|
||||
this.index = index
|
||||
}
|
||||
}
|
||||
|
||||
const LightboxContext = React.createContext<{
|
||||
activeLightbox: Lightbox | null
|
||||
}>({
|
||||
activeLightbox: null,
|
||||
})
|
||||
|
||||
const LightboxControlContext = React.createContext<{
|
||||
openLightbox: (lightbox: Lightbox) => void
|
||||
closeLightbox: () => boolean
|
||||
}>({
|
||||
openLightbox: () => {},
|
||||
closeLightbox: () => false,
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [activeLightbox, setActiveLightbox] = React.useState<Lightbox | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => {
|
||||
setActiveLightbox(lightbox)
|
||||
})
|
||||
|
||||
const closeLightbox = useNonReactiveCallback(() => {
|
||||
let wasActive = !!activeLightbox
|
||||
setActiveLightbox(null)
|
||||
return wasActive
|
||||
})
|
||||
|
||||
const state = React.useMemo(
|
||||
() => ({
|
||||
activeLightbox,
|
||||
}),
|
||||
[activeLightbox],
|
||||
)
|
||||
|
||||
const methods = React.useMemo(
|
||||
() => ({
|
||||
openLightbox,
|
||||
closeLightbox,
|
||||
}),
|
||||
[openLightbox, closeLightbox],
|
||||
)
|
||||
|
||||
return (
|
||||
<LightboxContext.Provider value={state}>
|
||||
<LightboxControlContext.Provider value={methods}>
|
||||
{children}
|
||||
</LightboxControlContext.Provider>
|
||||
</LightboxContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLightbox() {
|
||||
return React.useContext(LightboxContext)
|
||||
}
|
||||
|
||||
export function useLightboxControls() {
|
||||
return React.useContext(LightboxControlContext)
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export interface ConfirmModal {
|
||||
name: 'confirm'
|
||||
title: string
|
||||
message: string | (() => JSX.Element)
|
||||
onPressConfirm: () => void | Promise<void>
|
||||
onPressCancel?: () => void | Promise<void>
|
||||
confirmBtnText?: string
|
||||
confirmBtnStyle?: StyleProp<ViewStyle>
|
||||
cancelBtnText?: string
|
||||
}
|
||||
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export interface ProfilePreviewModal {
|
||||
name: 'profile-preview'
|
||||
did: string
|
||||
}
|
||||
|
||||
export interface ServerInputModal {
|
||||
name: 'server-input'
|
||||
initialService: string
|
||||
onSelect: (url: string) => void
|
||||
}
|
||||
|
||||
export interface ModerationDetailsModal {
|
||||
name: 'moderation-details'
|
||||
context: 'account' | 'content'
|
||||
moderation: ModerationUI
|
||||
}
|
||||
|
||||
export type ReportModal = {
|
||||
name: 'report'
|
||||
} & (
|
||||
| {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {did: string}
|
||||
)
|
||||
|
||||
export interface CreateOrEditListModal {
|
||||
name: 'create-or-edit-list'
|
||||
purpose?: string
|
||||
list?: AppBskyGraphDefs.ListView
|
||||
onSave?: (uri: string) => void
|
||||
}
|
||||
|
||||
export interface UserAddRemoveListsModal {
|
||||
name: 'user-add-remove-lists'
|
||||
subject: string
|
||||
displayName: string
|
||||
onAdd?: (listUri: string) => void
|
||||
onRemove?: (listUri: string) => void
|
||||
}
|
||||
|
||||
export interface ListAddRemoveUsersModal {
|
||||
name: 'list-add-remove-users'
|
||||
list: AppBskyGraphDefs.ListView
|
||||
onChange?: (
|
||||
type: 'add' | 'remove',
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
) => void
|
||||
}
|
||||
|
||||
export interface EditImageModal {
|
||||
name: 'edit-image'
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
export interface CropImageModal {
|
||||
name: 'crop-image'
|
||||
uri: string
|
||||
onSelect: (img?: RNImage) => void
|
||||
}
|
||||
|
||||
export interface AltTextImageModal {
|
||||
name: 'alt-text-image'
|
||||
image: ImageModel
|
||||
}
|
||||
|
||||
export interface DeleteAccountModal {
|
||||
name: 'delete-account'
|
||||
}
|
||||
|
||||
export interface RepostModal {
|
||||
name: 'repost'
|
||||
onRepost: () => void
|
||||
onQuote: () => void
|
||||
isReposted: boolean
|
||||
}
|
||||
|
||||
export interface SelfLabelModal {
|
||||
name: 'self-label'
|
||||
labels: string[]
|
||||
hasMedia: boolean
|
||||
onChange: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export interface ChangeHandleModal {
|
||||
name: 'change-handle'
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
export interface WaitlistModal {
|
||||
name: 'waitlist'
|
||||
}
|
||||
|
||||
export interface InviteCodesModal {
|
||||
name: 'invite-codes'
|
||||
}
|
||||
|
||||
export interface AddAppPasswordModal {
|
||||
name: 'add-app-password'
|
||||
}
|
||||
|
||||
export interface ContentFilteringSettingsModal {
|
||||
name: 'content-filtering-settings'
|
||||
}
|
||||
|
||||
export interface ContentLanguagesSettingsModal {
|
||||
name: 'content-languages-settings'
|
||||
}
|
||||
|
||||
export interface PostLanguagesSettingsModal {
|
||||
name: 'post-languages-settings'
|
||||
}
|
||||
|
||||
export interface BirthDateSettingsModal {
|
||||
name: 'birth-date-settings'
|
||||
}
|
||||
|
||||
export interface VerifyEmailModal {
|
||||
name: 'verify-email'
|
||||
showReminder?: boolean
|
||||
}
|
||||
|
||||
export interface ChangeEmailModal {
|
||||
name: 'change-email'
|
||||
}
|
||||
|
||||
export interface SwitchAccountModal {
|
||||
name: 'switch-account'
|
||||
}
|
||||
|
||||
export interface LinkWarningModal {
|
||||
name: 'link-warning'
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type Modal =
|
||||
// Account
|
||||
| AddAppPasswordModal
|
||||
| ChangeHandleModal
|
||||
| DeleteAccountModal
|
||||
| EditProfileModal
|
||||
| ProfilePreviewModal
|
||||
| BirthDateSettingsModal
|
||||
| VerifyEmailModal
|
||||
| ChangeEmailModal
|
||||
| SwitchAccountModal
|
||||
|
||||
// Curation
|
||||
| ContentFilteringSettingsModal
|
||||
| ContentLanguagesSettingsModal
|
||||
| PostLanguagesSettingsModal
|
||||
|
||||
// Moderation
|
||||
| ModerationDetailsModal
|
||||
| ReportModal
|
||||
|
||||
// Lists
|
||||
| CreateOrEditListModal
|
||||
| UserAddRemoveListsModal
|
||||
| ListAddRemoveUsersModal
|
||||
|
||||
// Posts
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| EditImageModal
|
||||
| ServerInputModal
|
||||
| RepostModal
|
||||
| SelfLabelModal
|
||||
|
||||
// Bluesky access
|
||||
| WaitlistModal
|
||||
| InviteCodesModal
|
||||
|
||||
// Generic
|
||||
| ConfirmModal
|
||||
| LinkWarningModal
|
||||
|
||||
const ModalContext = React.createContext<{
|
||||
isModalActive: boolean
|
||||
activeModals: Modal[]
|
||||
}>({
|
||||
isModalActive: false,
|
||||
activeModals: [],
|
||||
})
|
||||
|
||||
const ModalControlContext = React.createContext<{
|
||||
openModal: (modal: Modal) => void
|
||||
closeModal: () => boolean
|
||||
closeAllModals: () => void
|
||||
}>({
|
||||
openModal: () => {},
|
||||
closeModal: () => false,
|
||||
closeAllModals: () => {},
|
||||
})
|
||||
|
||||
/**
|
||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
||||
*/
|
||||
export let unstable__openModal: (modal: Modal) => void = () => {
|
||||
throw new Error(`ModalContext is not initialized`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
||||
*/
|
||||
export let unstable__closeModal: () => boolean = () => {
|
||||
throw new Error(`ModalContext is not initialized`)
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
||||
|
||||
const openModal = useNonReactiveCallback((modal: Modal) => {
|
||||
setActiveModals(modals => [...modals, modal])
|
||||
})
|
||||
|
||||
const closeModal = useNonReactiveCallback(() => {
|
||||
let wasActive = activeModals.length > 0
|
||||
setActiveModals(modals => {
|
||||
return modals.slice(0, -1)
|
||||
})
|
||||
return wasActive
|
||||
})
|
||||
|
||||
const closeAllModals = useNonReactiveCallback(() => {
|
||||
setActiveModals([])
|
||||
})
|
||||
|
||||
unstable__openModal = openModal
|
||||
unstable__closeModal = closeModal
|
||||
|
||||
const state = React.useMemo(
|
||||
() => ({
|
||||
isModalActive: activeModals.length > 0,
|
||||
activeModals,
|
||||
}),
|
||||
[activeModals],
|
||||
)
|
||||
|
||||
const methods = React.useMemo(
|
||||
() => ({
|
||||
openModal,
|
||||
closeModal,
|
||||
closeAllModals,
|
||||
}),
|
||||
[openModal, closeModal, closeAllModals],
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={state}>
|
||||
<ModalControlContext.Provider value={methods}>
|
||||
{children}
|
||||
</ModalControlContext.Provider>
|
||||
</ModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useModals() {
|
||||
return React.useContext(ModalContext)
|
||||
}
|
||||
|
||||
export function useModalControls() {
|
||||
return React.useContext(ModalControlContext)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {LRUMap} from 'lru_map'
|
||||
|
||||
export class HandleResolutionsCache {
|
||||
cache: LRUMap<string, string> = new LRUMap(500)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {Image} from 'react-native'
|
||||
import type {Dimensions} from 'lib/media/types'
|
||||
|
||||
export class ImageSizesCache {
|
||||
sizes: Map<string, Dimensions> = new Map()
|
||||
activeRequests: Map<string, Promise<Dimensions>> = new Map()
|
||||
|
||||
constructor() {}
|
||||
|
||||
get(uri: string): Dimensions | undefined {
|
||||
return this.sizes.get(uri)
|
||||
}
|
||||
|
||||
async fetch(uri: string): Promise<Dimensions> {
|
||||
const Dimensions = this.sizes.get(uri)
|
||||
if (Dimensions) {
|
||||
return Dimensions
|
||||
}
|
||||
|
||||
const prom =
|
||||
this.activeRequests.get(uri) ||
|
||||
new Promise<Dimensions>(resolve => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width: number, height: number) => resolve({width, height}),
|
||||
(err: any) => {
|
||||
console.error('Failed to fetch image dimensions for', uri, err)
|
||||
resolve({width: 0, height: 0})
|
||||
},
|
||||
)
|
||||
})
|
||||
this.activeRequests.set(uri, prom)
|
||||
const res = await prom
|
||||
this.activeRequests.delete(uri)
|
||||
this.sizes.set(uri, res)
|
||||
return res
|
||||
}
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {LRUMap} from 'lru_map'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
|
||||
|
||||
type CacheValue = Promise<LinkMeta> | LinkMeta
|
||||
export class LinkMetasCache {
|
||||
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
cache: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async getLinkMeta(url: string) {
|
||||
const cached = this.cache.get(url)
|
||||
if (cached) {
|
||||
try {
|
||||
return await cached
|
||||
} catch (e) {
|
||||
// ignore, we'll try again
|
||||
}
|
||||
}
|
||||
try {
|
||||
const promise = getLinkMeta(this.rootStore, url)
|
||||
this.cache.set(url, promise)
|
||||
const res = await promise
|
||||
this.cache.set(url, res)
|
||||
return res
|
||||
} catch (e) {
|
||||
this.cache.delete(url)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+137
@@ -0,0 +1,137 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const MAX_SYNC_PAGES = 10
|
||||
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||
|
||||
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
||||
|
||||
export enum FollowState {
|
||||
Following,
|
||||
NotFollowing,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface FollowInfo {
|
||||
did: string
|
||||
followRecordUri: string | undefined
|
||||
handle: string
|
||||
displayName: string | undefined
|
||||
avatar: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* This model is used to maintain a synced local cache of the user's
|
||||
* follows. It should be periodically refreshed and updated any time
|
||||
* the user makes a change to their follows.
|
||||
*/
|
||||
export class MyFollowsCache {
|
||||
// data
|
||||
byDid: Record<string, FollowInfo> = {}
|
||||
lastSync = 0
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
clear() {
|
||||
this.byDid = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs a subset of the user's follows
|
||||
* for performance reasons, caps out at 1000 follows
|
||||
*/
|
||||
syncIfNeeded = bundleAsync(async () => {
|
||||
if (this.lastSync > Date.now() - SYNC_TTL) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursor
|
||||
for (let i = 0; i < MAX_SYNC_PAGES; i++) {
|
||||
const res: GetFollows.Response = await this.rootStore.agent.getFollows({
|
||||
actor: this.rootStore.me.did,
|
||||
cursor,
|
||||
limit: 100,
|
||||
})
|
||||
res.data.follows = res.data.follows.filter(
|
||||
profile =>
|
||||
!moderateProfile(profile, this.rootStore.preferences.moderationOpts)
|
||||
.account.filter,
|
||||
)
|
||||
this.hydrateMany(res.data.follows)
|
||||
if (!res.data.cursor) {
|
||||
break
|
||||
}
|
||||
cursor = res.data.cursor
|
||||
}
|
||||
|
||||
this.lastSync = Date.now()
|
||||
})
|
||||
|
||||
getFollowState(did: string): FollowState {
|
||||
if (typeof this.byDid[did] === 'undefined') {
|
||||
return FollowState.Unknown
|
||||
}
|
||||
if (typeof this.byDid[did].followRecordUri === 'string') {
|
||||
return FollowState.Following
|
||||
}
|
||||
return FollowState.NotFollowing
|
||||
}
|
||||
|
||||
async fetchFollowState(did: string): Promise<FollowState> {
|
||||
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
|
||||
const res = await this.rootStore.agent.getProfile({actor: did})
|
||||
this.hydrate(did, res.data)
|
||||
return this.getFollowState(did)
|
||||
}
|
||||
|
||||
getFollowUri(did: string): string {
|
||||
const v = this.byDid[did]
|
||||
if (v && typeof v.followRecordUri === 'string') {
|
||||
return v.followRecordUri
|
||||
}
|
||||
throw new Error('Not a followed user')
|
||||
}
|
||||
|
||||
addFollow(did: string, info: FollowInfo) {
|
||||
this.byDid[did] = info
|
||||
}
|
||||
|
||||
removeFollow(did: string) {
|
||||
if (this.byDid[did]) {
|
||||
this.byDid[did].followRecordUri = undefined
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(did: string, profile: Profile) {
|
||||
this.byDid[did] = {
|
||||
did,
|
||||
followRecordUri: profile.viewer?.following,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
hydrateMany(profiles: Profile[]) {
|
||||
for (const profile of profiles) {
|
||||
this.hydrate(profile.did, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+70
@@ -0,0 +1,70 @@
|
||||
import {LRUMap} from 'lru_map'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedPost,
|
||||
} from '@atproto/api'
|
||||
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
|
||||
export class PostsCache {
|
||||
cache: LRUMap<string, PostView> = new LRUMap(500)
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
set(uri: string, postView: PostView) {
|
||||
this.cache.set(uri, postView)
|
||||
if (postView.author.handle) {
|
||||
this.rootStore.handleResolutions.cache.set(
|
||||
postView.author.handle,
|
||||
postView.author.did,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) {
|
||||
this.set(feedItem.post.uri, feedItem.post)
|
||||
if (
|
||||
feedItem.reply?.parent &&
|
||||
AppBskyFeedDefs.isPostView(feedItem.reply?.parent)
|
||||
) {
|
||||
this.set(feedItem.reply.parent.uri, feedItem.reply.parent)
|
||||
}
|
||||
const embed = feedItem.post.embed
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record) &&
|
||||
AppBskyFeedPost.isRecord(embed.record.value) &&
|
||||
AppBskyFeedPost.validateRecord(embed.record.value).success
|
||||
) {
|
||||
this.set(embed.record.uri, embedViewToPostView(embed.record))
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record?.record) &&
|
||||
AppBskyFeedPost.isRecord(embed.record.record.value) &&
|
||||
AppBskyFeedPost.validateRecord(embed.record.record.value).success
|
||||
) {
|
||||
this.set(
|
||||
embed.record.record.uri,
|
||||
embedViewToPostView(embed.record.record),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function embedViewToPostView(
|
||||
embedView: AppBskyEmbedRecord.ViewRecord,
|
||||
): PostView {
|
||||
return {
|
||||
$type: 'app.bsky.feed.post#view',
|
||||
uri: embedView.uri,
|
||||
cid: embedView.cid,
|
||||
author: embedView.author,
|
||||
record: embedView.value,
|
||||
indexedAt: embedView.indexedAt,
|
||||
labels: embedView.labels,
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {LRUMap} from 'lru_map'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
|
||||
|
||||
type CacheValue = Promise<GetProfile.Response> | GetProfile.Response
|
||||
export class ProfilesCache {
|
||||
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
cache: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async getProfile(did: string) {
|
||||
const cached = this.cache.get(did)
|
||||
if (cached) {
|
||||
try {
|
||||
return await cached
|
||||
} catch (e) {
|
||||
// ignore, we'll try again
|
||||
}
|
||||
}
|
||||
try {
|
||||
const promise = this.rootStore.agent.getProfile({
|
||||
actor: did,
|
||||
})
|
||||
this.cache.set(did, promise)
|
||||
const res = await promise
|
||||
this.cache.set(did, res)
|
||||
return res
|
||||
} catch (e) {
|
||||
this.cache.delete(did)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
overwrite(did: string, res: GetProfile.Response) {
|
||||
this.cache.set(did, res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {AtUri, AppBskyGraphListitem} from '@atproto/api'
|
||||
import {runInAction} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
interface Membership {
|
||||
uri: string
|
||||
value: AppBskyGraphListitem.Record
|
||||
}
|
||||
|
||||
interface ListitemRecord {
|
||||
uri: string
|
||||
value: AppBskyGraphListitem.Record
|
||||
}
|
||||
|
||||
interface ListitemListResponse {
|
||||
cursor?: string
|
||||
records: ListitemRecord[]
|
||||
}
|
||||
|
||||
export class ListMembershipModel {
|
||||
// data
|
||||
memberships: Membership[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel, public subject: string) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async fetch() {
|
||||
// NOTE
|
||||
// this approach to determining list membership is too inefficient to work at any scale
|
||||
// it needs to be replaced with server side list membership queries
|
||||
// -prf
|
||||
let cursor
|
||||
let records: ListitemRecord[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res: ListitemListResponse =
|
||||
await this.rootStore.agent.app.bsky.graph.listitem.list({
|
||||
repo: this.rootStore.me.did,
|
||||
cursor,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
records = records.concat(
|
||||
res.records.filter(record => record.value.subject === this.subject),
|
||||
)
|
||||
cursor = res.cursor
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.memberships = records
|
||||
})
|
||||
}
|
||||
|
||||
getMembership(listUri: string) {
|
||||
return this.memberships.find(m => m.value.list === listUri)
|
||||
}
|
||||
|
||||
isMember(listUri: string) {
|
||||
return !!this.getMembership(listUri)
|
||||
}
|
||||
|
||||
async add(listUri: string) {
|
||||
if (this.isMember(listUri)) {
|
||||
return
|
||||
}
|
||||
const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
|
||||
{
|
||||
repo: this.rootStore.me.did,
|
||||
},
|
||||
{
|
||||
subject: this.subject,
|
||||
list: listUri,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
const {rkey} = new AtUri(res.uri)
|
||||
const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
|
||||
repo: this.rootStore.me.did,
|
||||
rkey,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.memberships = this.memberships.concat([record])
|
||||
})
|
||||
}
|
||||
|
||||
async remove(listUri: string) {
|
||||
const membership = this.getMembership(listUri)
|
||||
if (!membership) {
|
||||
return
|
||||
}
|
||||
const {rkey} = new AtUri(membership.uri)
|
||||
await this.rootStore.agent.app.bsky.graph.listitem.delete({
|
||||
repo: this.rootStore.me.did,
|
||||
rkey,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.memberships = this.memberships.filter(m => m.value.list !== listUri)
|
||||
})
|
||||
}
|
||||
|
||||
async updateTo(uris: string[]) {
|
||||
for (const uri of uris) {
|
||||
await this.add(uri)
|
||||
}
|
||||
for (const membership of this.memberships) {
|
||||
if (!uris.includes(membership.value.list)) {
|
||||
await this.remove(membership.value.list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user