Compare commits

..

2 Commits

Author SHA1 Message Date
Eric Bailey df5ff1b72e Override scrollRef types on profile 2023-11-17 10:46:59 -06:00
Eric Bailey 54bf784faf Fix some low-hanging type errors 2023-11-17 10:42:14 -06:00
216 changed files with 6329 additions and 14137 deletions
-3
View File
@@ -1,6 +1,3 @@
# Copy this to `.env` and `.env.test` files
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+1 -3
View File
@@ -7,7 +7,7 @@ module.exports = {
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'detox', 'react', 'lingui'],
plugins: ['@typescript-eslint', 'detox', 'react'],
rules: {
'react/no-unescaped-entities': 0,
'react-native/no-inline-styles': 0,
@@ -25,8 +25,6 @@ module.exports = {
'bskyweb',
'*.html',
'bskyweb',
'src/locale/locales/_build/',
'src/locale/locales/**/*.js',
],
settings: {
componentWrapperFunctions: ['observer'],
-4
View File
@@ -20,8 +20,6 @@ jobs:
run: yarn --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Check & compile i18n
run: yarn intl:build
- name: Type check
run: yarn typecheck
testing:
@@ -36,8 +34,6 @@ jobs:
uses: actions/checkout@v3
- name: Yarn install
run: yarn --frozen-lockfile
- name: Check & compile i18n
run: yarn intl:build
- name: Run tests
run: |
yarn test --forceExit
+1 -5
View File
@@ -102,8 +102,4 @@ ios/
google-services.json
# Performance results (Flashlight)
.perf/
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
.perf/
-1
View File
@@ -31,7 +31,6 @@ RUN \. "$NVM_DIR/nvm.sh" && \
nvm use $NODE_VERSION && \
npm install --global yarn && \
yarn && \
yarn intl:compile && \
yarn build-web
# DEBUG
-4
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Composer', () => {
@@ -47,8 +45,6 @@ describe('Composer', () => {
})
it('Reply text only', async () => {
await element(by.id('e2eRefreshHome')).tap()
const post = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply text only')
-10
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, createServer} from '../util'
describe('Create account', () => {
@@ -12,8 +10,6 @@ describe('Create account', () => {
})
it('I can create a new account', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
@@ -21,20 +17,14 @@ describe('Create account', () => {
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
+15 -18
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Curate lists', () => {
@@ -13,6 +11,7 @@ describe('Curate lists', () => {
})
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()
@@ -28,7 +27,7 @@ describe('Curate lists', () => {
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 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')
@@ -46,7 +45,7 @@ describe('Curate lists', () => {
it('Remove description via the edit curatelist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).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()
@@ -61,7 +60,7 @@ describe('Curate lists', () => {
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 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()
@@ -78,7 +77,7 @@ describe('Curate lists', () => {
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 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()
@@ -99,7 +98,6 @@ describe('Curate lists', () => {
})
it('Create a new curatelist', async () => {
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')
@@ -130,7 +128,6 @@ describe('Curate lists', () => {
})
it('Pins the list', async () => {
await expect(element(by.id('pinBtn'))).toBeVisible()
await element(by.id('pinBtn')).tap()
await element(by.id('e2eGotoHome')).tap()
await element(by.id('homeScreenFeedTabs-Good Ppl')).tap()
@@ -155,15 +152,15 @@ describe('Curate lists', () => {
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('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
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('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-5')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await element(by.id('list-Good Ppl')).tap()
})
@@ -176,15 +173,15 @@ describe('Curate lists', () => {
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('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
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('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await element(by.id('toggleBtn-Good Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
@@ -195,8 +192,8 @@ describe('Curate lists', () => {
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('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-3')).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()
+4 -23
View File
@@ -1,12 +1,10 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Home screen', () => {
beforeAll(async () => {
await createServer('?users&follows&posts&feeds')
await createServer('?users&follows&posts')
await openApp({permissions: {notifications: 'YES'}})
})
@@ -15,23 +13,6 @@ describe('Home screen', () => {
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Can go to feeds page using feeds button in tab bar', async () => {
await element(by.id('homeScreenFeedTabs-Feeds ✨')).tap()
await expect(element(by.text('Discover new feeds'))).toBeVisible()
})
it('Feeds button disappears after pinning a feed', async () => {
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-4')).tap()
await element(by.id('feed-alice-favs')).tap()
await element(by.id('pinBtn')).tap()
await element(by.id('bottomBarHomeBtn')).tap()
await expect(
element(by.id('homeScreenFeedTabs-Feeds ✨')),
).not.toBeVisible()
})
it('Can like posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
@@ -84,14 +65,14 @@ describe('Home screen', () => {
it('Can swipe between feeds', async () => {
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
await expect(element(by.id('customFeedPage'))).toBeVisible()
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
it('Can tap between feeds', async () => {
await element(by.id('homeScreenFeedTabs-alice-favs')).tap()
await expect(element(by.id('customFeedPage'))).toBeVisible()
await element(by.id("homeScreenFeedTabs-What's hot")).tap()
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await element(by.id('homeScreenFeedTabs-Following')).tap()
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
+16 -3
View File
@@ -5,8 +5,6 @@
* with the side drawer.
*/
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => {
@@ -18,6 +16,7 @@ describe('invite-codes', () => {
})
it('I can fetch invite codes', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
@@ -28,7 +27,6 @@ describe('invite-codes', () => {
})
it('I can create a new account with the invite code', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
@@ -53,4 +51,19 @@ describe('invite-codes', () => {
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
it('I get a notification for the new user', async () => {
await element(by.id('e2eSignOut')).tap()
await loginAsAlice()
await waitFor(element(by.id('homeScreen')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('bottomBarNotificationsBtn')).tap()
await expect(element(by.id('invitedUser'))).toBeVisible()
})
it('I can dismiss the new user notification', async () => {
await element(by.id('dismissBtn')).tap()
await expect(element(by.id('invitedUser'))).not.toBeVisible()
})
})
-4
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, login, createServer} from '../util'
describe('Login', () => {
@@ -12,8 +10,6 @@ describe('Login', () => {
})
it('As Alice, I can login', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2', {
takeScreenshots: true,
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Mergefeed', () => {
@@ -11,12 +9,8 @@ describe('Mergefeed', () => {
})
it('Login', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await loginAsAlice()
await element(by.id('e2eToggleMergefeed')).tap()
await element(by.id('bottomBarFeedsBtn')).tap()
await element(by.id('feed-alice-favs-toggleSave')).tap()
await element(by.id('e2eGotoHome')).tap()
})
it('Sees the expected mix of posts with default filters', async () => {
+15 -16
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mod lists', () => {
@@ -13,6 +11,7 @@ describe('Mod lists', () => {
})
it('Login and view my modlists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eGotoModeration')).tap()
await element(by.id('moderationlistsBtn')).tap()
@@ -32,7 +31,7 @@ describe('Mod lists', () => {
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 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')
@@ -50,7 +49,7 @@ describe('Mod lists', () => {
it('Remove description via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).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()
@@ -65,7 +64,7 @@ describe('Mod lists', () => {
it('Set avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).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()
@@ -82,7 +81,7 @@ describe('Mod lists', () => {
it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).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()
@@ -132,15 +131,15 @@ describe('Mod lists', () => {
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('user-warn-posts.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Shows the modlist on my profile', async () => {
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-5')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await element(by.id('list-Bad Ppl')).tap()
})
@@ -153,15 +152,15 @@ 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 element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await element(by.id('toggleBtn-Bad 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('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
@@ -172,8 +171,8 @@ describe('Mod lists', () => {
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('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-3')).tap()
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()
+5 -14
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Profile screen', () => {
@@ -13,16 +11,17 @@ describe('Profile screen', () => {
})
it('Login and navigate to my profile', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('bottomBarProfileBtn')).tap()
})
it('Can see feeds', async () => {
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-4')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await expect(element(by.id('feed-alice-favs'))).toBeVisible()
await element(by.id('profilePager-selector')).swipe('right')
await element(by.id('profilePager-selector-0')).tap()
await element(by.id('selector')).swipe('right')
await element(by.id('selector-0')).tap()
})
it('Open and close edit profile modal', async () => {
@@ -136,14 +135,6 @@ describe('Profile screen', () => {
})
it('Can like posts', async () => {
await element(by.id('postsFeed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
-2
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Search screen', () => {
-2
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Self-labeling', () => {
+1 -3
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
describe('Thread muting', () => {
@@ -50,7 +48,7 @@ describe('Thread muting', () => {
await loginAsBob()
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('profilePager-selector-1')).tap()
await element(by.id('selector-1')).tap()
const bobPosts = by.id('feedItem-by-bob.test')
await element(by.id('replyBtn').withAncestor(bobPosts)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply 2')
+6 -8
View File
@@ -1,7 +1,5 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Thread screen', () => {
@@ -33,15 +31,15 @@ describe('Thread screen', () => {
it('Can like the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('1 like')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
})
@@ -63,21 +61,21 @@ describe('Thread screen', () => {
it('Can repost the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('1 repost')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
})
@@ -1,5 +1,3 @@
import {it, describe, expect} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
@@ -8,7 +6,6 @@ import {
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
@@ -67,10 +64,6 @@ describe('linkRequiresWarning', () => {
['http://bsky.app/', 'https://google.com', true],
['https://bsky.app/', 'https://google.com', true],
// case insensitive
['https://Example.com', 'example.com', false],
['https://example.com', 'Example.com', false],
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
+5 -39
View File
@@ -1,41 +1,12 @@
const pkg = require('./package.json')
module.exports = function () {
/**
* App version number. Should be incremented as part of a release cycle.
*/
const VERSION = pkg.version
/**
* iOS build number. Must be incremented for each TestFlight version.
*/
const IOS_BUILD_NUMBER = '7'
/**
* Android build number. Must be incremented for each release.
*/
const ANDROID_VERSION_CODE = 46
/**
* Uses built-in Expo env vars
*
* @see https://docs.expo.dev/build-reference/variables/#built-in-environment-variables
*/
const PLATFORM = process.env.EAS_BUILD_PLATFORM
/**
* Additional granularity for the `dist` field
*/
const DIST_BUILD_NUMBER =
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
return {
expo: {
version: VERSION,
name: 'Bluesky',
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -48,7 +19,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: IOS_BUILD_NUMBER,
buildNumber: '3',
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -72,7 +43,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: ANDROID_VERSION_CODE,
versionCode: 46,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
@@ -103,7 +74,7 @@ module.exports = function () {
},
plugins: [
'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
hasSentryToken && 'sentry-expo',
[
'expo-build-properties',
{
@@ -129,16 +100,11 @@ module.exports = function () {
},
hooks: {
postPublish: [
/*
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration
*/
{
file: 'sentry-expo/upload-sourcemaps',
config: {
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`,
},
},
],
+1 -4
View File
@@ -1,8 +1,5 @@
# Testing instructions
Make sure you've copied `.env.example` to `.env.test` and provided any required
values.
### Using Maestro E2E tests
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
2. You can write Maestro tests in `__e2e__/maestro` directory by creating a new `.yaml` file or by modifying an existing one.
@@ -14,4 +11,4 @@ values.
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
+22 -13
View File
@@ -4,33 +4,42 @@
"promptToConfigurePushNotifications": false
},
"build": {
"base": {
"node": "18.18.2"
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"ios": {
"simulator": true,
"resourceClass": "large"
}
"resourceClass": "m-large"
},
"channel": "development"
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
},
"channel": "development"
},
"preview": {
"extends": "base",
"distribution": "internal",
"channel": "preview",
"ios": {
"resourceClass": "large"
}
"resourceClass": "m-large"
},
"channel": "preview"
},
"production": {
"extends": "base",
"ios": {
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "production"
},
"dev-android-apk": {
"developmentClient": true,
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
+4 -12
View File
@@ -1,20 +1,12 @@
import 'react-native-gesture-handler' // must be first
import {LogBox} from 'react-native'
LogBox.ignoreLogs(['Require cycle:']) // suppress require-cycle warnings, it's fine
import '#/platform/polyfills'
import {IS_TEST} from '#/env'
import 'platform/polyfills'
import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
doPolyfill()
if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests
} else {
LogBox.ignoreLogs(['Require cycle:']) // suppress require-cycle warnings, it's fine
}
import App from './src/App'
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
+2 -5
View File
@@ -1,7 +1,4 @@
import '#/platform/polyfills'
import 'platform/polyfills'
import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
doPolyfill()
import App from './src/App'
registerRootComponent(App)
-3
View File
@@ -2,9 +2,6 @@
import {configure} from '@testing-library/react-native'
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
configure({asyncUtilTimeout: 20000})
jest.mock('@react-native-async-storage/async-storage', () =>
+2 -6
View File
@@ -59,21 +59,17 @@ export async function createServer(
): Promise<TestPDS> {
const port = await getPort()
const port2 = await getPort(port + 1)
const port3 = await getPort(port2 + 1)
const pdsUrl = `http://localhost:${port}`
const id = ids.next()
const testNet = await TestNetwork.create({
pds: {
port,
hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
publicUrl: pdsUrl,
inviteRequired,
dbPostgresSchema: `pds_${id}`,
},
bsky: {
dbPostgresSchema: `bsky_${id}`,
port: port3,
publicUrl: 'http://localhost:2584',
},
plc: {port: port2},
})
+7 -10
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.57.0",
"version": "1.56.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -10,7 +10,6 @@
"ios": "expo run:ios",
"web": "expo start --web",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && eas build --platform all",
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@@ -21,16 +20,15 @@
"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": "NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build": "NODE_ENV=test detox build -c ios.sim.debug",
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
"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",
"intl:build": "yarn intl:check && yarn intl:compile",
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
@@ -147,7 +145,7 @@
"react-native-pager-view": "6.1.4",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.6.0",
"react-native-reanimated": "^3.4.2",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.0",
@@ -168,7 +166,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.2.16",
"@atproto/dev-env": "^0.2.5",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
@@ -206,7 +204,6 @@
"eslint": "^8.19.0",
"eslint-plugin-detox": "^1.0.0",
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-native-a11y": "^3.3.0",
"html-webpack-plugin": "^5.5.0",
+22 -25
View File
@@ -28,8 +28,6 @@ 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 LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -37,13 +35,18 @@ import {
} 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, currentAccount} = useSession()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
// init
@@ -70,25 +73,21 @@ function InnerApp() {
*/
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
@@ -116,9 +115,7 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+22 -25
View File
@@ -16,14 +16,15 @@ 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 LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -35,7 +36,7 @@ import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
@@ -43,6 +44,8 @@ function InnerApp() {
useEffect(() => {
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
@@ -58,25 +61,21 @@ function InnerApp() {
*/
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
@@ -104,9 +103,7 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+23 -25
View File
@@ -9,6 +9,7 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
@@ -68,18 +69,16 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigator<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
@@ -98,37 +97,37 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists'), requireAuth: true}}
options={{title: title('Lists')}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation'), requireAuth: true}}
options={{title: title('Moderation')}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists'), requireAuth: true}}
options={{title: title('Moderation Lists')}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts'), requireAuth: true}}
options={{title: title('Muted Accounts')}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts'), requireAuth: true}}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title('Settings'), requireAuth: true}}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings'), requireAuth: true}}
options={{title: title('Language Settings')}}
/>
<Stack.Screen
name="Profile"
@@ -155,7 +154,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('List'), requireAuth: true}}
options={{title: title('List')}}
/>
<Stack.Screen
name="PostThread"
@@ -185,12 +184,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title('Debug'), requireAuth: true}}
options={{title: title('Debug')}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title('Log'), requireAuth: true}}
options={{title: title('Log')}}
/>
<Stack.Screen
name="Support"
@@ -220,22 +219,22 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title('App Passwords'), requireAuth: true}}
options={{title: title('App Passwords')}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds'), requireAuth: true}}
options={{title: title('Edit My Feeds')}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences'), requireAuth: true}}
options={{title: title('Home Feed Preferences')}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences'), requireAuth: true}}
options={{title: title('Threads Preferences')}}
/>
</>
)
@@ -340,7 +339,6 @@ function NotificationsTabNavigator() {
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
@@ -359,8 +357,8 @@ function MyProfileTabNavigator() {
contentStyle,
}}>
<MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
@@ -407,7 +405,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications'), requireAuth: true}}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
+1 -1
View File
@@ -1,4 +1,4 @@
export const IS_TEST = process.env.EXPO_PUBLIC_ENV === 'test'
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 || ''
+15 -19
View File
@@ -16,7 +16,13 @@ export type FeedTunerFn = (
export class FeedViewPostsSlice {
isFlattenedReply = false
constructor(public items: FeedViewPost[], public _reactKey: string) {}
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
}`
}
get uri() {
if (this.isFlattenedReply) {
@@ -111,34 +117,28 @@ export class FeedViewPostsSlice {
}
export class NoopFeedTuner {
private keyCounter = 0
reset() {
this.keyCounter = 0
}
reset() {}
tune(
feed: FeedViewPost[],
_tunerFns: FeedTunerFn[] = [],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner {
private keyCounter = 0
seenUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
constructor() {}
reset() {
this.keyCounter = 0
this.seenUris.clear()
}
tune(
feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: false,
@@ -147,9 +147,7 @@ export class FeedTuner {
let slices: FeedViewPostsSlice[] = []
if (maintainOrder) {
slices = feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
slices = feed.map(item => new FeedViewPostsSlice([item]))
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
@@ -165,14 +163,12 @@ export class FeedTuner {
continue
}
}
slices.unshift(
new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
slices.unshift(new FeedViewPostsSlice([item]))
}
}
// run the custom tuners
for (const tunerFn of this.tunerFns) {
for (const tunerFn of tunerFns) {
slices = tunerFn(this, slices.slice())
}
+7 -4
View File
@@ -1,15 +1,18 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class AuthorFeedAPI implements FeedAPI {
constructor(public params: GetAuthorFeed.QueryParams) {}
constructor(
public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getAuthorFeed({
const res = await this.agent.getAuthorFeed({
...this.params,
limit: 1,
})
@@ -23,7 +26,7 @@ export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getAuthorFeed({
const res = await this.agent.getAuthorFeed({
...this.params,
cursor,
limit,
+7 -4
View File
@@ -1,15 +1,18 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class CustomFeedAPI implements FeedAPI {
constructor(public params: GetCustomFeed.QueryParams) {}
constructor(
public agent: BskyAgent,
public params: GetCustomFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
@@ -23,7 +26,7 @@ export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
cursor,
limit,
+4 -5
View File
@@ -1,12 +1,11 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class FollowingFeedAPI implements FeedAPI {
constructor() {}
constructor(public agent: BskyAgent) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -19,7 +18,7 @@ export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getTimeline({
const res = await this.agent.getTimeline({
cursor,
limit,
})
+7 -4
View File
@@ -1,15 +1,18 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class LikesFeedAPI implements FeedAPI {
constructor(public params: GetActorLikes.QueryParams) {}
constructor(
public agent: BskyAgent,
public params: GetActorLikes.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getActorLikes({
const res = await this.agent.getActorLikes({
...this.params,
limit: 1,
})
@@ -23,7 +26,7 @@ export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getActorLikes({
const res = await this.agent.getActorLikes({
...this.params,
cursor,
limit,
+7 -4
View File
@@ -1,15 +1,18 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class ListFeedAPI implements FeedAPI {
constructor(public params: GetListFeed.QueryParams) {}
constructor(
public agent: BskyAgent,
public params: GetListFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getListFeed({
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
@@ -23,7 +26,7 @@ export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getListFeed({
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
cursor,
limit,
+24 -16
View File
@@ -1,4 +1,4 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
@@ -7,7 +7,6 @@ import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -19,12 +18,16 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
this.following = new MergeFeedSource_Following(this.feedTuners)
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.feedTuners)
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
@@ -32,7 +35,8 @@ export class MergeFeedAPI implements FeedAPI {
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
feedUri =>
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
),
)
} else {
@@ -41,7 +45,7 @@ export class MergeFeedAPI implements FeedAPI {
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -133,7 +137,7 @@ class MergeFeedSource {
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public feedTuners: FeedTunerFn[]) {}
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
@@ -180,7 +184,7 @@ class MergeFeedSource {
}
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner(this.feedTuners)
tuner = new FeedTuner()
reset() {
super.reset()
@@ -195,9 +199,9 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await getAgent().getTimeline({cursor, limit})
const res = await this.agent.getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, {
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
dryRun: false,
maintainOrder: true,
})
@@ -209,16 +213,20 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
super(feedTuners)
constructor(
public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
getAgent()
.app.bsky.feed.getFeedGenerator({
this.agent.app.bsky.feed
.getFeedGenerator({
feed: feedUri,
})
.then(
@@ -236,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await getAgent().app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
-1
View File
@@ -1 +0,0 @@
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
-2
View File
@@ -1,2 +0,0 @@
// @ts-ignore
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
-1
View File
@@ -1,2 +1 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const PWI_ENABLED = false
+2 -2
View File
@@ -116,8 +116,8 @@ export async function DEFAULT_FEEDS(
} else {
// production
return {
pinned: [],
saved: [],
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
}
}
}
+5 -31
View File
@@ -1,54 +1,28 @@
import {useCallback} from 'react'
import {useNavigation} from '@react-navigation/native'
import {isWeb} from '#/platform/detection'
import {NavigationProp} from '#/lib/routes/types'
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 {useLoggedOutViewControls} from '#/state/shell/logged-out'
export function useAccountSwitcher() {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const navigation = useNavigation<NavigationProp>()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
async (account: SessionAccount) => {
async (acct: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
try {
if (account.accessJwt) {
closeAllActiveElements()
navigation.navigate(isWeb ? 'Home' : 'HomeTab')
await selectAccount(account)
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
}, 100)
} else {
closeAllActiveElements()
setShowLoggedOut(true)
Toast.show(
`Please sign in as @${account.handle}`,
'circle-exclamation',
)
}
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
}
},
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
navigation,
setShowLoggedOut,
],
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
)
return {onPressSwitchAccount}
@@ -11,31 +11,31 @@ export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
})
return useAnimatedScrollHandler_BUGGY(
{
onBeginDrag(e, ctx) {
onBeginDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
ref.current.onBeginDrag(e, ctx)
ref.current.onBeginDrag(e)
}
},
onEndDrag(e, ctx) {
onEndDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
ref.current.onEndDrag(e, ctx)
ref.current.onEndDrag(e)
}
},
onMomentumBegin(e, ctx) {
onMomentumBegin(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
ref.current.onMomentumBegin(e, ctx)
ref.current.onMomentumBegin(e)
}
},
onMomentumEnd(e, ctx) {
onMomentumEnd(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
ref.current.onMomentumEnd(e, ctx)
ref.current.onMomentumEnd(e)
}
},
onScroll(e, ctx) {
onScroll(e) {
if (typeof ref.current === 'function') {
ref.current(e, ctx)
ref.current(e)
} else if (ref.current.onScroll) {
ref.current.onScroll(e, ctx)
ref.current.onScroll(e)
}
},
},
+3 -3
View File
@@ -3,7 +3,6 @@ import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() {
const {openModal} = useModalControls()
@@ -12,8 +11,9 @@ export function useOTAUpdate() {
const showUpdatePopup = useCallback(() => {
openModal({
name: 'confirm',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
title: 'Update Available',
message:
'A new version of the app is available. Please update to continue using the app.',
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
+2 -2
View File
@@ -3,8 +3,8 @@ import {isNative} from 'platform/detection'
export function useWebMediaQueries() {
const isDesktop = useMediaQuery({minWidth: 1300})
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
const isMobile = useMediaQuery({maxWidth: 800 - 1})
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300})
const isMobile = useMediaQuery({maxWidth: 800})
const isTabletOrMobile = isMobile || isTablet
const isTabletOrDesktop = isDesktop || isTablet
if (isNative) {
+1 -20
View File
@@ -1,4 +1,4 @@
import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
import {ModerationCause, ProfileModeration} from '@atproto/api'
export interface ModerationCauseDescription {
name: string
@@ -92,25 +92,6 @@ export function getProfileModerationCauses(
}) as ModerationCause[]
}
export function isPostMediaBlurred(
decisions: PostModeration['decisions'],
): boolean {
return decisions.post.blurMedia
}
export function isQuoteBlurred(
decisions: PostModeration['decisions'],
): boolean {
return (
decisions.quote?.blur ||
decisions.quote?.blurMedia ||
decisions.quote?.filter ||
decisions.quotedAccount?.blur ||
decisions.quotedAccount?.filter ||
false
)
}
export function isCauseALabelOnUri(
cause: ModerationCause | undefined,
uri: string,
+2 -3
View File
@@ -5,7 +5,6 @@ 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 {truncateAndInvalidate} from '#/state/queries/util'
import {listenSessionLoaded} from '#/state/events'
const SERVICE_DID = (serviceUrl?: string) =>
@@ -84,7 +83,7 @@ export function init(queryClient: QueryClient) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -122,7 +121,7 @@ export function init(queryClient: QueryClient) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},
-9
View File
@@ -8,15 +8,6 @@ export const queryClient = new QueryClient({
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on
// "first seen" timestamps on objects to determine if they're fresh.
// Disable this optimization so that we can rely on "first seen" timestamps.
structuralSharing: false,
// We don't want to retry queries by default, because in most cases we
// want to fail early and show a response to the user. There are
// exceptions, and those can be made on a per-query basis. For others, we
// should give users controls to retry.
retry: false,
},
},
})
+2 -40
View File
@@ -1,46 +1,8 @@
/**
* Importing these separately from `platform/detection` and `lib/app-info` to
* avoid future conflicts and/or circular deps
*/
import {Platform} from 'react-native'
import app from 'react-native-version-number'
import * as info from 'expo-updates'
import {init} from 'sentry-expo'
/**
* Matches the build profile `channel` props in `eas.json`
*/
const buildChannel = (info.channel || 'development') as
| 'development'
| 'preview'
| 'production'
/**
* Examples:
* - `dev`
* - `1.57.0`
*/
const release = app.appVersion ?? 'dev'
/**
* Examples:
* - `web.dev`
* - `ios.dev`
* - `android.dev`
* - `web.1.57.0`
* - `ios.1.57.0.3`
* - `android.1.57.0.46`
*/
const dist = `${Platform.OS}.${release}${
app.buildVersion ? `.${app.buildVersion}` : ''
}`
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
enableInExpoDevelopment: true,
environment: buildChannel,
dist,
release,
environment: __DEV__ ? 'development' : 'production', // Set the environment
})
+5 -20
View File
@@ -168,15 +168,8 @@ export function getYoutubeVideoId(link: string): string | undefined {
return videoId
}
/**
* Checks if the label in the post text matches the host of the link facet.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
let urip
try {
urip = new URL(uri)
@@ -184,9 +177,7 @@ export function linkRequiresWarning(uri: string, label: string) {
return true
}
const host = urip.hostname.toLowerCase()
if (host === 'bsky.app') {
if (urip.hostname === 'bsky.app') {
// if this is a link to internal content,
// warn if it represents itself as a URL to another app
if (
@@ -203,26 +194,20 @@ export function linkRequiresWarning(uri: string, label: string) {
if (!labelDomain) {
return true
}
return labelDomain !== host
return labelDomain !== urip.hostname
}
}
/**
* Returns a lowercase domain hostname if the label is a valid URL.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function labelToDomain(label: string): string | undefined {
function labelToDomain(label: string): string | undefined {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname.toLowerCase()
return new URL(label).hostname
} catch {}
try {
return new URL('https://' + label).hostname.toLowerCase()
return new URL('https://' + label).hostname
} catch {}
return undefined
}
+3 -21
View File
@@ -1,8 +1,4 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
import {messages as messagesEn} from './locales/en/messages'
import {messages as messagesHi} from './locales/hi/messages'
export const locales = {
en: 'English',
@@ -18,21 +14,7 @@ export const defaultLocale = 'en'
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
if (locale === 'en') {
i18n.loadAndActivate({locale, messages: messagesEn})
return
} else if (locale === 'hi') {
i18n.loadAndActivate({locale, messages: messagesHi})
return
} else {
i18n.loadAndActivate({locale, messages: messagesEn})
return
}
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
const {messages} = await import(`./locales/${locale}/messages`)
i18n.load(locale, messages)
i18n.activate(locale)
}
-29
View File
@@ -1,29 +0,0 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
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)
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
}
-9
View File
@@ -1,9 +0,0 @@
import React from 'react'
import {I18nProvider as DefaultI18nProvider} from '@lingui/react'
import {i18n} from '@lingui/core'
import {useLocaleLanguage} from './i18n'
export default function I18nProvider({children}: {children: React.ReactNode}) {
useLocaleLanguage()
return <DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider>
}
-10
View File
@@ -4,16 +4,6 @@ interface Language {
name: string
}
interface AppLanguage {
code2: string
name: string
}
export const APP_LANGUAGES: AppLanguage[] = [
{code2: 'en', name: 'English'},
{code2: 'hi', name: 'हिंदी'},
]
export const LANGUAGES: Language[] = [
{code3: 'aar', code2: 'aa', name: 'Afar'},
{code3: 'abk', code2: 'ab', name: 'Abkhazian'},
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
-20
View File
@@ -222,26 +222,6 @@ describe('general functionality', () => {
})
})
test('sentryTransport serializes errors', () => {
const message = 'message'
const timestamp = Date.now()
const sentryTimestamp = timestamp / 1000
sentryTransport(
LogLevel.Debug,
message,
{error: new Error('foo')},
timestamp,
)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {error: 'Error: foo'},
type: 'default',
level: LogLevel.Debug,
timestamp: sentryTimestamp,
})
})
test('add/remove transport', () => {
const timestamp = Date.now()
const logger = new Logger({enabled: true})
+10 -20
View File
@@ -90,16 +90,6 @@ const enabledLogLevels: {
[LogLevel.Error]: [LogLevel.Error],
}
export function prepareMetadata(metadata: Metadata): Metadata {
return Object.keys(metadata).reduce((acc, key) => {
let value = metadata[key]
if (value instanceof Error) {
value = value.toString()
}
return {...acc, [key]: value}
}, {})
}
/**
* Used in dev mode to nicely log to the console
*/
@@ -110,8 +100,7 @@ export const consoleTransport: Transport = (
timestamp,
) => {
const extra = Object.keys(metadata).length
? // don't prepareMetadata here, in dev we want the stack trace
' ' + JSON.stringify(metadata, null, ' ')
? ' ' + JSON.stringify(metadata, null, ' ')
: ''
const log = {
[LogLevel.Debug]: console.debug,
@@ -130,8 +119,6 @@ export const sentryTransport: Transport = (
{type, tags, ...metadata},
timestamp,
) => {
const meta = prepareMetadata(metadata)
/**
* If a string, report a breadcrumb
*/
@@ -148,7 +135,7 @@ export const sentryTransport: Transport = (
Sentry.addBreadcrumb({
message,
data: meta,
data: metadata,
type: type || 'default',
level: severity,
timestamp: timestamp / 1000, // Sentry expects seconds
@@ -168,7 +155,7 @@ export const sentryTransport: Transport = (
Sentry.captureMessage(message, {
level: messageLevel,
tags,
extra: meta,
extra: metadata,
})
}
} else {
@@ -177,7 +164,7 @@ export const sentryTransport: Transport = (
*/
Sentry.captureException(message, {
tags,
extra: meta,
extra: metadata,
})
}
}
@@ -288,13 +275,16 @@ export class Logger {
*/
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)
/**
* Comment this out to disable Sentry transport in dev
* Uncomment this to test Sentry in dev
*/
logger.addTransport(sentryTransport)
// logger.addTransport(sentryTransport);
} else if (env.IS_PROD) {
logger.addTransport(sentryTransport)
// logger.addTransport(sentryTransport)
}
+61 -61
View File
@@ -1,14 +1,11 @@
import {useEffect, useState, useMemo} from 'react'
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed'
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread'
import {queryClient} from 'lib/react-query'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
@@ -19,83 +16,86 @@ export interface PostShadow {
export const POST_TOMBSTONE = Symbol('PostTombstone')
const emitter = new EventEmitter()
const shadows: WeakMap<
AppBskyFeedDefs.PostView,
Partial<PostShadow>
> = new WeakMap()
interface CacheEntry {
ts: number
value: PostShadow
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
ifAfterTS: number,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
const [shadow, setShadow] = useState(() => shadows.get(post))
const [prevPost, setPrevPost] = useState(post)
if (post !== prevPost) {
setPrevPost(post)
setShadow(shadows.get(post))
}
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(() => {
function onUpdate() {
setShadow(shadows.get(post))
}
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post, setShadow])
}, [post.uri, onUpdate])
return useMemo(() => {
if (shadow) {
return mergeShadow(post, shadow)
} else {
return castAsShadow(post)
// 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)})
}
}, [post, shadow])
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: Partial<PostShadow>,
shadow: PostShadow,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return castAsShadow({
return {
...post,
likeCount: 'likeCount' in shadow ? shadow.likeCount : post.likeCount,
repostCount:
'repostCount' in shadow ? shadow.repostCount : post.repostCount,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
viewer: {
...(post.viewer || {}),
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
like: shadow.likeUri,
repost: shadow.repostUri,
},
})
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
const cachedPosts = findPostsInCache(uri)
for (let post of cachedPosts) {
shadows.set(post, {...shadows.get(post), ...value})
}
batchedUpdates(() => {
emitter.emit(uri)
})
}
function* findPostsInCache(
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield post
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield post
}
for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
if (node.type === 'post') {
yield node.post
}
isShadowed: true,
}
}
+59 -61
View File
@@ -1,101 +1,99 @@
import {useEffect, useState, useMemo} from 'react'
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows'
import {Shadow, castAsShadow} from './types'
import {queryClient} from 'lib/react-query'
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
const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
const emitter = new EventEmitter()
export function useProfileShadow(
profile: ProfileView,
ifAfterTS: number,
): Shadow<ProfileView> {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromProfile(profile),
})
const firstRun = useRef(true)
export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
const [shadow, setShadow] = useState(() => shadows.get(profile))
const [prevPost, setPrevPost] = useState(profile)
if (profile !== prevPost) {
setPrevPost(profile)
setShadow(shadows.get(profile))
}
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
function onUpdate() {
setShadow(shadows.get(profile))
}
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 useMemo(() => {
if (shadow) {
return mergeShadow(profile, shadow)
} else {
return castAsShadow(profile)
}
}, [profile, shadow])
return state.ts > ifAfterTS
? mergeShadow(profile, state.value)
: {...profile, isShadowed: true}
}
export function updateProfileShadow(
did: string,
uri: string,
value: Partial<ProfileShadow>,
) {
const cachedProfiles = findProfilesInCache(did)
for (let post of cachedProfiles) {
shadows.set(post, {...shadows.get(post), ...value})
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,
}
batchedUpdates(() => {
emitter.emit(did, value)
})
}
function mergeShadow(
profile: ProfileView,
shadow: Partial<ProfileShadow>,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return castAsShadow({
return {
...profile,
viewer: {
...(profile.viewer || {}),
following:
'followingUri' in shadow
? shadow.followingUri
: profile.viewer?.following,
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
blocking:
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
following: shadow.followingUri,
muted: shadow.muted,
blocking: shadow.blockingUri,
},
})
}
function* findProfilesInCache(did: string): Generator<ProfileView, void> {
yield* findAllProfilesInListMembersQueryData(queryClient, did)
yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
yield* findAllProfilesInPostLikedByQueryData(queryClient, did)
yield* findAllProfilesInPostRepostedByQueryData(queryClient, did)
yield* findAllProfilesInProfileQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
isShadowed: true,
}
}
+1 -7
View File
@@ -1,7 +1 @@
// This isn't a real property, but it prevents T being compatible with Shadow<T>.
declare const shadowTag: unique symbol
export type Shadow<T> = T & {[shadowTag]: true}
export function castAsShadow<T>(value: T): Shadow<T> {
return value as any as Shadow<T>
}
export type Shadow<T> = T & {isShadowed: true}
-8
View File
@@ -36,11 +36,3 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
emitter.on('session-dropped', fn)
return () => emitter.off('session-dropped', fn)
}
export function emitPostCreated() {
emitter.emit('post-created')
}
export function listenPostCreated(fn: () => void): UnlistenFn {
emitter.on('post-created', fn)
return () => emitter.off('post-created', fn)
}
-1
View File
@@ -61,7 +61,6 @@ export interface CreateOrEditListModal {
export interface UserAddRemoveListsModal {
name: 'user-add-remove-lists'
subject: string
handle: string
displayName: string
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
+2 -3
View File
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider.
*/
export async function init() {
logger.info('persisted state: initializing')
logger.debug('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
@@ -28,12 +28,11 @@ export async function init() {
const stored = await store.read() // check for new store
if (!stored) await store.write(defaults) // opt: init new store
_state = stored || defaults // return new store
logger.log('persisted state: initialized')
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
error: e,
})
// AsyncStorage failure, but we can still continue in memory
// AsyncStorage failured, but we can still continue in memory
return defaults
}
}
+4 -48
View File
@@ -94,8 +94,6 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
postLanguageHistory:
legacy.preferences?.postLanguageHistory ||
defaults.languagePrefs.postLanguageHistory,
appLanguage:
legacy.preferences?.postLanguage || defaults.languagePrefs.appLanguage,
},
requireAltTextEnabled:
legacy.preferences?.requireAltTextEnabled ||
@@ -116,52 +114,20 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists.
*/
export async function migrate() {
logger.info('persisted state: migrate')
logger.debug('persisted state: migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
DEPRECATED_ROOT_STATE_STORAGE_KEY,
)
const newData = await read()
const alreadyMigrated = Boolean(newData)
try {
if (rawLegacyData) {
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
logger.info(`persisted state: debug legacy data`, {
hasExistingLoggedInAccount: Boolean(legacy?.session?.data),
numberOfExistingAccounts: legacy?.session?.accounts?.length,
foundExistingCurrentAccount: Boolean(
legacy.session?.accounts?.find(
a => a.did === legacy.session?.data?.did,
),
),
})
logger.info(`persisted state: debug new data`, {
hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount),
numberOfExistingAccounts: newData?.session?.accounts?.length,
existingAccountMatchesLegacy: Boolean(
newData?.session?.currentAccount?.did ===
legacy?.session?.data?.did,
),
})
} else {
logger.info(`persisted state: no legacy to debug, fresh install`)
}
} catch (e) {
logger.error(`persisted state: legacy debugging failed`, {error: e})
}
const alreadyMigrated = Boolean(await read())
if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage')
logger.debug('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
await write(newData)
// track successful migrations
logger.log('persisted state: migrated legacy storage')
} else {
// track successful migrations
logger.log('persisted state: no migration needed')
logger.debug('persisted state: migrated legacy storage')
}
} catch (e) {
logger.error('persisted state: error migrating legacy storage', {
@@ -169,13 +135,3 @@ export async function migrate() {
})
}
}
export async function clearLegacyStorage() {
try {
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
} catch (e: any) {
logger.error(`persisted legacy store: failed to clear`, {
error: e.toString(),
})
}
}
-2
View File
@@ -30,7 +30,6 @@ export const schema = z.object({
contentLanguages: z.array(z.string()), // should move to server
postLanguage: z.string(), // should move to server
postLanguageHistory: z.array(z.string()),
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
mutedThreads: z.array(z.string()), // should move to server
@@ -59,7 +58,6 @@ export const defaults: Schema = {
postLanguageHistory: (deviceLocales || [])
.concat(['en', 'ja', 'pt', 'de'])
.slice(0, 6),
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
mutedThreads: [],
-9
View File
@@ -1,7 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {Schema, schema} from '#/state/persisted/schema'
import {logger} from '#/logger'
const BSKY_STORAGE = 'BSKY_STORAGE'
@@ -17,11 +16,3 @@ export async function read(): Promise<Schema | undefined> {
return objData
}
}
export async function clear() {
try {
await AsyncStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {error: e.toString()})
}
}
+9 -13
View File
@@ -2,13 +2,9 @@ import {useMemo} from 'react'
import {FeedTuner} from '#/lib/api/feed-manip'
import {FeedDescriptor} from '../queries/post-feed'
import {useLanguagePrefs} from './languages'
import {usePreferencesQuery} from '../queries/preferences'
import {useSession} from '../session'
export function useFeedTuners(feedDesc: FeedDescriptor) {
const langPrefs = useLanguagePrefs()
const {data: preferences} = usePreferencesQuery()
const {currentAccount} = useSession()
return useMemo(() => {
if (feedDesc.startsWith('feedgen')) {
@@ -23,30 +19,30 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc === 'home' || feedDesc === 'following') {
const feedTuners = []
if (preferences?.feedViewPrefs.hideReposts) {
if (false /*TODOthis.homeFeed.hideReposts*/) {
feedTuners.push(FeedTuner.removeReposts)
} else {
feedTuners.push(FeedTuner.dedupReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
if (true /*TODOthis.homeFeed.hideReplies*/) {
feedTuners.push(FeedTuner.removeReplies)
} else {
} /* TODO else {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: currentAccount?.did || '',
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
userDid: this.rootStore.session.data?.did || '',
minLikes: this.homeFeed.hideRepliesByLikeCount,
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
}),
)
}
}*/
if (preferences?.feedViewPrefs.hideQuotePosts) {
if (false /*TODOthis.homeFeed.hideQuotePosts*/) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}, [feedDesc, currentAccount, preferences, langPrefs])
}, [feedDesc, langPrefs])
}
-5
View File
@@ -11,7 +11,6 @@ type ApiContext = {
toggleContentLanguage: (code2: string) => void
togglePostLanguage: (code2: string) => void
savePostLanguageToHistory: () => void
setAppLanguage: (code2: string) => void
}
const stateContext = React.createContext<StateContext>(
@@ -23,7 +22,6 @@ const apiContext = React.createContext<ApiContext>({
toggleContentLanguage: (_: string) => {},
togglePostLanguage: (_: string) => {},
savePostLanguageToHistory: () => {},
setAppLanguage: (_: string) => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -106,9 +104,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.slice(0, 6),
}))
},
setAppLanguage(code2: string) {
setStateWrapped(s => ({...s, appLanguage: code2}))
},
}),
[state, setStateWrapped],
)
+10 -43
View File
@@ -160,39 +160,6 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
})
}
export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [
'isFeedPublic',
uri,
]
export function useIsFeedPublicQuery({uri}: {uri: string}) {
return useQuery({
queryKey: isFeedPublicQueryKey({uri}),
queryFn: async ({queryKey}) => {
const [, uri] = queryKey
try {
const res = await getAgent().app.bsky.feed.getFeed({
feed: uri,
limit: 1,
})
return Boolean(res.data.feed)
} catch (e: any) {
/**
* This should be an `XRPCError`, but I can't safely import from
* `@atproto/xrpc` due to a depdency on node's `crypto` module.
*
* @see https://github.com/bluesky-social/atproto/blob/c17971a2d8e424cc7f10c071d97c07c08aa319cf/packages/xrpc/src/client.ts#L126
*/
if (e?.status === 401) {
return false
}
return true
}
},
})
}
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() {
@@ -247,19 +214,13 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
likeUri: '',
}
export function usePinnedFeedsInfos(): {
feeds: FeedSourceInfo[]
hasPinnedCustom: boolean
} {
export function usePinnedFeedsInfos(): FeedSourceInfo[] {
const queryClient = useQueryClient()
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
FOLLOWING_FEED_STUB,
])
const {data: preferences} = usePreferencesQuery()
const hasPinnedCustom = React.useMemo<boolean>(() => {
return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
}, [tabs])
const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned)
React.useEffect(() => {
if (!preferences?.feeds?.pinned) return
@@ -306,7 +267,13 @@ export function usePinnedFeedsInfos(): {
}
fetchFeedInfo()
}, [queryClient, setTabs, preferences?.feeds?.pinned])
}, [
queryClient,
setTabs,
preferences?.feeds?.pinned,
// ensure we react to re-ordering
pinnedFeedsKey,
])
return {feeds: tabs, hasPinnedCustom}
return tabs
}
+1 -20
View File
@@ -3,7 +3,6 @@ import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
import {cleanError} from '#/lib/strings/errors'
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
@@ -18,24 +17,7 @@ export function useInviteCodesQuery() {
staleTime: STALE.HOURS.ONE,
queryKey: ['inviteCodes'],
queryFn: async () => {
const res = await getAgent()
.com.atproto.server.getAccountInviteCodes({})
.catch(e => {
if (cleanError(e) === 'Bad token scope') {
return null
} else {
throw e
}
})
if (res === null) {
return {
disabled: true,
all: [],
available: [],
used: [],
}
}
const res = await getAgent().com.atproto.server.getAccountInviteCodes({})
if (!res.data?.codes) {
throw new Error(`useInviteCodesQuery: no codes returned`)
@@ -45,7 +27,6 @@ export function useInviteCodesQuery() {
const used = res.data.codes.filter(code => !isInviteAvailable(code))
return {
disabled: false,
all: [...available, ...used],
available,
used,
+2 -38
View File
@@ -1,10 +1,5 @@
import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyGraphGetList} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
@@ -36,34 +31,3 @@ export function useListMembersQuery(uri: string) {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetList.OutputSchema>
>({
queryKey: ['list-members'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
if (page.list.creator.did === did) {
yield page.list.creator
}
for (const item of page.items) {
if (item.subject.did === did) {
yield item.subject
}
}
}
}
}
}
+27 -13
View File
@@ -3,6 +3,7 @@ import {
AppBskyGraphGetList,
AppBskyGraphList,
AppBskyGraphDefs,
BskyAgent,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
@@ -74,9 +75,13 @@ export function useListCreateMutation() {
)
// wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
})
await whenAppViewReady(
getAgent(),
res.uri,
(v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
},
)
return res
},
onSuccess() {
@@ -137,12 +142,16 @@ export function useListMetadataMutation() {
).data
// wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
})
await whenAppViewReady(
getAgent(),
res.uri,
(v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
},
)
return res
},
onSuccess(data, variables) {
@@ -207,9 +216,13 @@ export function useListDeleteMutation() {
}
// wait for the appview to update
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
return !v?.success
})
await whenAppViewReady(
getAgent(),
uri,
(v: AppBskyGraphGetList.Response) => {
return !v?.success
},
)
},
onSuccess() {
invalidateMyLists(queryClient)
@@ -258,6 +271,7 @@ export function useListBlockMutation() {
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyGraphGetList.Response) => boolean,
) {
@@ -266,7 +280,7 @@ async function whenAppViewReady(
1e3, // 1s delay between tries
fn,
() =>
getAgent().app.bsky.graph.getList({
agent.app.bsky.graph.getList({
list: uri,
limit: 1,
}),
+4 -30
View File
@@ -1,12 +1,8 @@
import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyGraphGetBlocks} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
export const RQKEY = () => ['my-blocked-accounts']
type RQPageParam = string | undefined
@@ -19,6 +15,7 @@ export function useMyBlockedAccountsQuery() {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getBlocks({
@@ -31,26 +28,3 @@ export function useMyBlockedAccountsQuery() {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
>({
queryKey: ['my-blocked-accounts'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const block of page.blocks) {
if (block.did === did) {
yield block
}
}
}
}
}
+4 -30
View File
@@ -1,12 +1,8 @@
import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyGraphGetMutes} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
export const RQKEY = () => ['my-muted-accounts']
type RQPageParam = string | undefined
@@ -19,6 +15,7 @@ export function useMyMutedAccountsQuery() {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getMutes({
@@ -31,26 +28,3 @@ export function useMyMutedAccountsQuery() {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetMutes.OutputSchema>
>({
queryKey: ['my-muted-accounts'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const mute of page.mutes) {
if (mute.did === did) {
yield mute
}
}
}
}
}
+170 -84
View File
@@ -1,51 +1,55 @@
/**
* NOTE
* The ./unread.ts API:
*
* - Provides a `checkUnread()` function to sync with the server,
* - Periodically calls `checkUnread()`, and
* - Caches the first page of notifications.
*
* IMPORTANT: This query uses ./unread.ts's cache as its first page,
* IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
*
* Follow these rules:
*
* 1. Call `checkUnread()` if you want to fetch latest in the background.
* 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/
import {AppBskyFeedDefs} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
useQueryClient,
QueryClient,
} from '@tanstack/react-query'
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyNotificationListNotifications,
BskyAgent,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {useModerationOpts} from '../preferences'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
import {FeedPage} from './types'
import {shouldFilterNotif} from './util'
import {useMutedThreads} from '#/state/muted-threads'
import {STALE} from '..'
export type {NotificationType, FeedNotification, FeedPage} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
type RQPageParam = string | undefined
type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export function RQKEY() {
return ['notification-feed']
}
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
return useInfiniteQuery<
@@ -55,30 +59,39 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
QueryKey,
RQPageParam
>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
let page
if (!pageParam) {
// for the first page, we check the cached page held by the unread-checker first
page = unreads.getCachedUnreadPage()
}
if (!page) {
page = await fetchPage({
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
moderationOpts,
threadMutes,
})
const res = await getAgent().listNotifications({
limit: PAGE_SIZE,
cursor: pageParam,
})
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(getAgent(), notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
}
}
// if the first page has an unread, mark all read
if (!pageParam && page.items[0] && !page.items[0].notification.isRead) {
unreads.markAllRead()
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return page
return {
cursor: res.data.cursor,
items: notifsGrouped,
}
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
@@ -86,40 +99,113 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
})
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: ['notification-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject?.uri === uri) {
yield item.subject
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
agent: BskyAgent,
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}
-34
View File
@@ -1,34 +0,0 @@
import {
AppBskyNotificationListNotifications,
AppBskyFeedDefs,
} from '@atproto/api'
export type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export interface CachedFeedPage {
sessDid: string // used to invalidate on session changes
syncedAt: Date
data: FeedPage | undefined
}
+32 -96
View File
@@ -1,20 +1,10 @@
/**
* A kind of companion API to ./feed.ts. See that file for more info.
*/
import React from 'react'
import * as Notifications from 'expo-notifications'
import {useQueryClient} from '@tanstack/react-query'
import BroadcastChannel from '#/lib/broadcast'
import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '../preferences'
import {fetchPage} from './util'
import {CachedFeedPage, FeedPage} from './types'
import {shouldFilterNotif} from './util'
import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {logger} from '#/logger'
import {truncateAndInvalidate} from '../util'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -24,8 +14,7 @@ type StateContext = string
interface ApiContext {
markAllRead: () => Promise<void>
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined
checkUnread: () => Promise<void>
}
const stateContext = React.createContext<StateContext>('')
@@ -33,23 +22,16 @@ const stateContext = React.createContext<StateContext>('')
const apiContext = React.createContext<ApiContext>({
async markAllRead() {},
async checkUnread() {},
getCachedUnreadPage: () => undefined,
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession, currentAccount} = useSession()
const queryClient = useQueryClient()
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('')
const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
const cacheRef = React.useRef<CachedFeedPage>({
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
})
const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
const lastSyncRef = React.useRef<Date>(new Date())
// periodic sync
React.useEffect(() => {
@@ -64,18 +46,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// listen for broadcasts
React.useEffect(() => {
const listener = ({data}: MessageEvent) => {
cacheRef.current = {
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
}
lastSyncRef.current = new Date()
setNumUnread(data.event)
}
broadcast.addEventListener('message', listener)
return () => {
broadcast.removeEventListener('message', listener)
}
}, [setNumUnread, currentAccount])
}, [setNumUnread])
// create API
const api = React.useMemo<ApiContext>(() => {
@@ -83,7 +61,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
async markAllRead() {
// update server
await getAgent().updateSeenNotifications(
cacheRef.current.syncedAt.toISOString(),
lastSyncRef.current.toISOString(),
)
// update & broadcast
@@ -91,59 +69,34 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
broadcast.postMessage({event: ''})
},
async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
try {
if (!getAgent().session) return
// count
const page = await fetchPage({
cursor: undefined,
limit: 40,
queryClient,
moderationOpts,
threadMutes,
})
const unreadCount = countUnread(page)
const unreadCountStr =
unreadCount >= 30
? '30+'
: unreadCount === 0
? ''
: String(unreadCount)
if (isNative) {
Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
}
// track last sync
const now = new Date()
const lastIndexed =
page.items[0] && new Date(page.items[0].notification.indexedAt)
cacheRef.current = {
sessDid: currentAccount?.did || '',
data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
}
// update & broadcast
setNumUnread(unreadCountStr)
if (invalidate) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.error('Failed to check unread notifications', {error: e})
async checkUnread() {
// count
const res = await getAgent().listNotifications({limit: 40})
const filtered = res.data.notifications.filter(
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
)
const num =
filtered.length >= 30
? '30+'
: filtered.length === 0
? ''
: String(filtered.length)
if (isNative) {
Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
}
},
getCachedUnreadPage() {
// return cached page if was for the current user
// (protects against session changes serving data from the past session)
if (cacheRef.current.sessDid === currentAccount?.did) {
return cacheRef.current.data
}
// track last sync
const now = new Date()
const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
lastSyncRef.current =
!lastIndexed || now > lastIndexed ? now : lastIndexed
// update & broadcast
setNumUnread(num)
broadcast.postMessage({event: num})
},
}
}, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
}, [setNumUnread, moderationOpts])
checkUnreadRef.current = api.checkUnread
return (
@@ -160,20 +113,3 @@ export function useUnreadNotifications() {
export function useUnreadNotificationsApi() {
return React.useContext(apiContext)
}
function countUnread(page: FeedPage) {
let num = 0
for (const item of page.items) {
if (!item.notification.isRead) {
num++
}
if (item.additional) {
for (const item2 of item.additional) {
if (!item2.isRead) {
num++
}
}
}
}
return num
}
+1 -182
View File
@@ -3,78 +3,10 @@ import {
ModerationOpts,
moderateProfile,
moderatePost,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
import {NotificationType, FeedNotification, FeedPage} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
// exported api
// =
export async function fetchPage({
cursor,
limit,
queryClient,
moderationOpts,
threadMutes,
}: {
cursor: string | undefined
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
threadMutes: string[]
}): Promise<FeedPage> {
const res = await getAgent().listNotifications({
limit,
cursor,
})
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
if (notif.subject) {
precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
}
}
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return {
cursor: res.data.cursor,
items: notifsGrouped,
}
}
// internal methods
// =
// TODO this should be in the sdk as moderateNotification -prf
function shouldFilterNotif(
export function shouldFilterNotif(
notif: AppBskyNotificationListNotifications.Notification,
moderationOpts: ModerationOpts | undefined,
): boolean {
@@ -104,116 +36,3 @@ function shouldFilterNotif(
// (this requires fetching the post)
return false
}
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did &&
notif.isRead === groupedNotif.notification.isRead
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
getAgent()
.app.bsky.feed.getPosts({uris})
.then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}
+101 -222
View File
@@ -1,14 +1,9 @@
import {useCallback} from 'react'
import {useCallback, useMemo} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
QueryClient,
useQueryClient,
} from '@tanstack/react-query'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../session'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
@@ -16,13 +11,9 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {useModerationOpts} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
import {getAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
type ActorDid = string
type AuthorFilter =
@@ -44,7 +35,7 @@ export interface FeedParams {
mergeFeedSources?: string[]
}
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
type RQPageParam = string | undefined
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return ['post-feed', feedDesc, params || {}]
@@ -65,15 +56,7 @@ export interface FeedPostSlice {
items: FeedPostSliceItem[]
}
export interface FeedPageUnselected {
api: FeedAPI
cursor: string | undefined
feed: AppBskyFeedDefs.FeedViewPost[]
}
export interface FeedPage {
api: FeedAPI
tuner: FeedTuner | NoopFeedTuner
cursor: string | undefined
slices: FeedPostSlice[]
}
@@ -83,221 +66,117 @@ export function usePostFeedQuery(
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc)
const enabled = opts?.enabled !== false
const moderationOpts = useModerationOpts()
const agent = getAgent()
return useInfiniteQuery<
FeedPageUnselected,
const api: FeedAPI = useMemo(() => {
if (feedDesc === 'home') {
return new MergeFeedAPI(agent, params || {}, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI(agent)
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI(agent, {actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI(agent, {actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI(agent, {feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI(agent, {list})
} else {
// shouldnt happen
return new FollowingFeedAPI(agent)
}
}, [feedDesc, params, feedTuners, agent])
const disableTuner = !!params?.disableTuner
const tuner = useMemo(
() => (disableTuner ? new NoopFeedTuner() : new FeedTuner()),
[disableTuner],
)
const pollLatest = useCallback(async () => {
if (!enabled) {
return false
}
logger.debug('usePostFeedQuery: pollLatest')
const post = await api.peekLatest()
if (post && moderationOpts) {
const slices = tuner.tune([post], feedTuners, {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
if (
!moderatePost(slices[0].items[0].post, moderationOpts).content.filter
) {
return true
}
}
}
return false
}, [api, tuner, feedTuners, moderationOpts, enabled])
const out = useInfiniteQuery<
FeedPage,
Error,
InfiniteData<FeedPage>,
QueryKey,
RQPageParam
>({
enabled,
staleTime: STALE.INFINITY,
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
logger.debug('usePostFeedQuery', {feedDesc, pageParam})
const {api, cursor} = pageParam
? pageParam
: {
api: createApi(feedDesc, params || {}, feedTuners),
cursor: undefined,
}
const res = await api.fetch({cursor, limit: 30})
precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
console.log('fetch', feedDesc, pageParam)
if (!pageParam) {
tuner.reset()
}
const res = await api.fetch({cursor: pageParam, limit: 30})
const slices = tuner.tune(res.feed, feedTuners)
return {
api,
cursor: res.cursor,
feed: res.feed,
slices: slices.map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item => item.post.author.did === slice.items[0].post.author.did,
),
source: undefined, // TODO
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason: i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
}
},
initialPageParam: undefined,
getNextPageParam: lastPage =>
lastPage.cursor
? {
api: lastPage.api,
cursor: lastPage.cursor,
}
: undefined,
select: useCallback(
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
const tuner = params?.disableTuner
? new NoopFeedTuner()
: new FeedTuner(feedTuners)
return {
pageParams: data.pageParams,
pages: data.pages.map(page => ({
api: page.api,
tuner,
cursor: page.cursor,
slices: tuner.tune(page.feed).map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item =>
item.post.author.did === slice.items[0].post.author.did,
),
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason:
i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
})),
}
},
[feedTuners, params?.disableTuner],
),
getNextPageParam: lastPage => lastPage.cursor,
enabled,
})
}
export async function pollLatest(page: FeedPage | undefined) {
if (!page) {
return false
}
logger.debug('usePostFeedQuery: pollLatest')
const post = await page.api.peekLatest()
if (post) {
const slices = page.tuner.tune([post], {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
return true
}
}
return false
}
function createApi(
feedDesc: FeedDescriptor,
params: FeedParams,
feedTuners: FeedTunerFn[],
) {
if (feedDesc === 'home') {
return new MergeFeedAPI(params, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI()
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI({actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI({actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI({feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI({list})
} else {
// shouldnt happen
return new FollowingFeedAPI()
}
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, undefined> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
queryKey: ['post-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.feed) {
if (item.post.uri === uri) {
yield item.post
}
if (
AppBskyFeedDefs.isPostView(item.reply?.parent) &&
item.reply?.parent?.uri === uri
) {
yield item.reply.parent
}
if (
AppBskyFeedDefs.isPostView(item.reply?.root) &&
item.reply?.root?.uri === uri
) {
yield item.reply.root
}
}
}
}
}
function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
// assume false
let somePostsPassModeration = false
for (const item of feed) {
const moderationOpts = getModerationOpts({
userDid: '',
preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
})
const moderation = moderatePost(item.post, moderationOpts)
if (!moderation.content.filter) {
// we have a sfw post
somePostsPassModeration = true
}
}
if (!somePostsPassModeration) {
throw new Error(KnownError.FeedNSFPublic)
}
return {...out, pollLatest}
}
+4 -30
View File
@@ -1,12 +1,8 @@
import {AppBskyActorDefs, AppBskyFeedGetLikes} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyFeedGetLikes} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -22,6 +18,7 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(resolvedUri || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().getLikes({
@@ -36,26 +33,3 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
enabled: !!resolvedUri,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedGetLikes.OutputSchema>
>({
queryKey: ['post-liked-by'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const like of page.likes) {
if (like.actor.did === did) {
yield like.actor
}
}
}
}
}
+4 -30
View File
@@ -1,12 +1,8 @@
import {AppBskyActorDefs, AppBskyFeedGetRepostedBy} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyFeedGetRepostedBy} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -22,6 +18,7 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(resolvedUri || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().getRepostedBy({
@@ -36,26 +33,3 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
enabled: !!resolvedUri,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema>
>({
queryKey: ['post-reposted-by'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const repostedBy of page.repostedBy) {
if (repostedBy.did === did) {
yield repostedBy
}
}
}
}
}
+7 -135
View File
@@ -3,13 +3,11 @@ import {
AppBskyFeedPost,
AppBskyFeedGetPostThread,
} from '@atproto/api'
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
import {STALE} from '#/state/queries'
export const RQKEY = (uri: string) => ['post-thread', uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -20,8 +18,6 @@ export interface ThreadCtx {
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
isParentLoading?: boolean
isChildLoading?: boolean
}
export type ThreadPost = {
@@ -62,43 +58,17 @@ export type ThreadNode =
| ThreadUnknown
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
return useQuery<ThreadNode, Error>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await getAgent().getPostThread({uri: uri!})
if (res.success) {
const nodes = responseToThreadNodes(res.data.thread)
precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
return nodes
return responseToThreadNodes(res.data.thread)
}
return {type: 'unknown', uri: uri!}
},
enabled: !!uri,
placeholderData: () => {
if (!uri) {
return undefined
}
{
const item = findPostInQueryData(queryClient, uri)
if (item) {
return threadNodeToPlaceholderThread(item)
}
}
{
const item = findPostInFeedQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
{
const item = findPostInNotifsQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
return undefined
},
})
}
@@ -181,10 +151,9 @@ function responseToThreadNodes(
: undefined,
replies:
node.replies?.length && direction !== 'up'
? node.replies
.map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
// do not show blocked posts in replies
.filter(node => node.type !== 'blocked')
? node.replies.map(reply =>
responseToThreadNodes(reply, depth + 1, 'down'),
)
: undefined,
viewer: node.viewer,
ctx: {
@@ -208,100 +177,3 @@ function responseToThreadNodes(
return {type: 'unknown', uri: ''}
}
}
function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): ThreadNode | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<ThreadNode, void> {
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: ['post-thread'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
yield item
}
}
}
}
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
if (node.type === 'post') {
if (node.parent) {
yield* traverseThread(node.parent)
}
yield node
if (node.replies?.length) {
for (const reply of node.replies) {
yield* traverseThread(reply)
}
}
}
}
function threadNodeToPlaceholderThread(
node: ThreadNode,
): ThreadNode | undefined {
if (node.type !== 'post') {
return undefined
}
return {
type: node.type,
_reactKey: node._reactKey,
uri: node.uri,
post: node.post,
record: node.record,
parent: undefined,
replies: undefined,
viewer: node.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!node.record.reply,
isChildLoading: !!node.post.replyCount,
},
}
}
function postViewToPlaceholderThread(
post: AppBskyFeedDefs.PostView,
): ThreadNode {
return {
type: 'post',
_reactKey: post.uri,
uri: post.uri,
post: post,
record: post.record as AppBskyFeedPost.Record, // validated in notifs
parent: undefined,
replies: undefined,
viewer: post.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
isChildLoading: !!post.replyCount,
},
}
}
+3
View File
@@ -4,11 +4,13 @@ import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {STALE} from '#/state/queries'
export const RQKEY = (postUri: string) => ['post', postUri]
export function usePostQuery(uri: string | undefined) {
return useQuery<AppBskyFeedDefs.PostView>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await getAgent().getPosts({uris: [uri!]})
@@ -27,6 +29,7 @@ export function useGetPost() {
return React.useCallback(
async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const urip = new AtUri(uri)
-24
View File
@@ -2,7 +2,6 @@ import {
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
@@ -26,26 +25,3 @@ export const DEFAULT_PROD_FEEDS = {
pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
}
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
adultContentEnabled: false,
feeds: {
saved: [],
pinned: [],
unpinned: [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
},
feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
}
+77 -72
View File
@@ -1,6 +1,11 @@
import {useMemo} from 'react'
import {useEffect, useState} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
} from '@atproto/api'
import isEqual from 'lodash.isequal'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
@@ -15,7 +20,6 @@ import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
@@ -24,83 +28,84 @@ export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/const'
export const preferencesQueryKey = ['getPreferences']
export const usePreferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {hasSession} = useSession()
return useQuery({
enabled: hasSession,
staleTime: STALE.MINUTES.ONE,
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
queryFn: async () => {
const agent = getAgent()
if (agent.session?.did === undefined) {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
const res = await getAgent().getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
},
})
}
export function useModerationOpts() {
const {currentAccount} = useSession()
const [opts, setOpts] = useState<ModerationOpts | undefined>()
const prefs = usePreferencesQuery()
const opts = useMemo(() => {
useEffect(() => {
if (!prefs.data) {
return
}
return getModerationOpts({
// only update this hook when the moderation options change
const newOpts = getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
}, [currentAccount?.did, prefs.data])
if (!isEqual(opts, newOpts)) {
setOpts(newOpts)
}
}, [prefs.data, currentAccount, opts, setOpts])
return opts
}
@@ -112,7 +117,7 @@ export function useClearPreferencesMutation() {
await getAgent().app.bsky.actor.putPreferences({preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -130,7 +135,7 @@ export function usePreferencesSetContentLabelMutation() {
await getAgent().setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -144,7 +149,7 @@ export function usePreferencesSetAdultContentMutation() {
await getAgent().setAdultContentEnabled(enabled)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -158,7 +163,7 @@ export function usePreferencesSetBirthDateMutation() {
await getAgent().setPersonalDetails({birthDate})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -172,7 +177,7 @@ export function useSetFeedViewPreferencesMutation() {
await getAgent().setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -186,7 +191,7 @@ export function useSetThreadViewPreferencesMutation() {
await getAgent().setThreadViewPrefs(prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -204,7 +209,7 @@ export function useSetSaveFeedsMutation() {
await getAgent().setSavedFeeds(saved, pinned)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -219,7 +224,7 @@ export function useSaveFeedMutation() {
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -234,7 +239,7 @@ export function useRemoveFeedMutation() {
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -249,7 +254,7 @@ export function usePinFeedMutation() {
track('CustomFeed:Pin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -264,7 +269,7 @@ export function useUnpinFeedMutation() {
track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -34,24 +34,6 @@ export const DEFAULT_LABEL_PREFERENCES: Record<
impersonation: 'hide',
}
/**
* More strict than our default settings for logged in users.
*
* TODO(pwi)
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'hide',
suggestive: 'hide',
gore: 'hide',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
}
export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
id: 'illegal',
title: 'Illegal Content',
+1 -4
View File
@@ -43,10 +43,7 @@ export type UsePreferencesQueryResponse = Omit<
}
}
export type ThreadViewPreferences = Pick<
BskyThreadViewPreference,
'prioritizeFollowedUsers'
> & {
export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
lab_treeViewEnabled?: boolean
}
+2
View File
@@ -2,6 +2,7 @@ import {AppBskyFeedGetActorFeeds} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -21,6 +22,7 @@ export function useProfileFeedgensQuery(
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(did),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.feed.getActorFeeds({
+4 -30
View File
@@ -1,12 +1,8 @@
import {AppBskyActorDefs, AppBskyGraphGetFollowers} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyGraphGetFollowers} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -21,6 +17,7 @@ export function useProfileFollowersQuery(did: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(did || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getFollowers({
@@ -35,26 +32,3 @@ export function useProfileFollowersQuery(did: string | undefined) {
enabled: !!did,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetFollowers.OutputSchema>
>({
queryKey: ['profile-followers'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const follower of page.followers) {
if (follower.did === did) {
yield follower
}
}
}
}
}
+2 -30
View File
@@ -1,10 +1,5 @@
import {AppBskyActorDefs, AppBskyGraphGetFollows} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {AppBskyGraphGetFollows} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
@@ -38,26 +33,3 @@ export function useProfileFollowsQuery(did: string | undefined) {
enabled: !!did,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetFollows.OutputSchema>
>({
queryKey: ['profile-follows'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const follow of page.follows) {
if (follow.did === did) {
yield follow
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More