Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df5ff1b72e | |||
| 54bf784faf |
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
@@ -102,8 +102,4 @@ ios/
|
||||
google-services.json
|
||||
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/*.js
|
||||
.perf/
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
|
||||
@@ -1,2 +0,0 @@
|
||||
// @ts-ignore
|
||||
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
|
||||
@@ -1,2 +1 @@
|
||||
export const LOGIN_INCLUDE_DEV_SERVERS = true
|
||||
export const PWI_ENABLED = false
|
||||
|
||||
@@ -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')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
+401
-1162
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+401
-1162
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+401
-1162
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+401
-1162
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+821
-1574
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+61
-61
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+59
-61
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-7
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user