Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdf57803d9 | |||
| fec6fdae2b | |||
| 5d05e6dfe3 | |||
| 1e8d7c6517 | |||
| 0a80312e2d | |||
| 1234f17375 | |||
| 40fcf9a8d7 | |||
| 193629cbda | |||
| bc354bfedc | |||
| bcfc92800e | |||
| df839e9229 | |||
| a5218607de |
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
simulator: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 15 Pro',
|
||||
type: 'iPhone 15',
|
||||
},
|
||||
},
|
||||
attached: {
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
|
||||
+1
-4
@@ -99,7 +99,4 @@ ios/
|
||||
.env.*
|
||||
|
||||
# Firebase (Android) Google services
|
||||
google-services.json
|
||||
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
google-services.json
|
||||
@@ -1,33 +1,24 @@
|
||||
# Bluesky Social App
|
||||
|
||||
Welcome friends! This is the codebase for the Bluesky Social app.
|
||||
|
||||
Get the app itself:
|
||||
Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com).
|
||||
|
||||
- **Web: [bsky.app](https://bsky.app)**
|
||||
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
|
||||
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
|
||||
|
||||
## Development Resources
|
||||
Links:
|
||||
|
||||
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
|
||||
- [Build instructions](./docs/build.md)
|
||||
- [ATProto repo](https://github.com/bluesky-social/atproto)
|
||||
- [ATProto docs](https://atproto.com)
|
||||
|
||||
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
|
||||
## Rules & guidelines
|
||||
|
||||
The [Build Instructions](./docs/build.md) are a good place to get started with the app itself.
|
||||
---
|
||||
|
||||
The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at:
|
||||
ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
||||
|
||||
- [Overview and Guides](https://atproto.com/guides/overview)
|
||||
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
|
||||
- [Protocol Specifications](https://atproto.com/specs/atp)
|
||||
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
|
||||
|
||||
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
|
||||
|
||||
## Contributions
|
||||
|
||||
> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
||||
---
|
||||
|
||||
**Rules:**
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# flow.yaml
|
||||
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- launchApp
|
||||
# Login
|
||||
# - runFlow:
|
||||
# when:
|
||||
# - tapOn: "Sign In"
|
||||
# - tapOn: "Username or email address"
|
||||
# - inputText: "ansh.bsky.team"
|
||||
# - tapOn: "Password"
|
||||
# - inputText: "PASSWORd"
|
||||
# - tapOn: "Next"
|
||||
# Allow notifications if popup is visible
|
||||
# - runFlow:
|
||||
# when:
|
||||
# visible: "Notifications"
|
||||
# commands:
|
||||
# - tapOn: "Allow"
|
||||
# Scroll in main feed
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
# Swipe between feeds
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
# Go to Notifications
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn: "Notifications"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- swipe:
|
||||
direction: "DOWN" # Make header visible
|
||||
# Go to Feeds tab
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn: "Feeds"
|
||||
- scrollUntilVisible:
|
||||
element: "Discover"
|
||||
direction: UP
|
||||
- tapOn: "Discover"
|
||||
- waitForAnimationToEnd
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
# Click on post
|
||||
- tapOn:
|
||||
id: "postText"
|
||||
index: 0
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
|
||||
@@ -502,9 +502,6 @@ async function main() {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
describe('Curate lists', () => {
|
||||
beforeAll(async () => {
|
||||
await createServer('?users&follows&posts')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
})
|
||||
|
||||
it('Login and create a curatelists', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eGotoLists')).tap()
|
||||
await element(by.id('newUserListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Good Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They good')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They good')
|
||||
})
|
||||
|
||||
it('Edit display name and description via the edit curatelist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).clearText()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove description via the edit curatelist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listDescription'))).not.toBeVisible()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit curatelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove avi via the edit curatelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Delete the curatelist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Delete List')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
await expect(element(by.id('listsEmpty'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Create a new curatelist', async () => {
|
||||
await element(by.id('newUserListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Good Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They good')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They good')
|
||||
})
|
||||
|
||||
it('Adds users on curatelists from the list', async () => {
|
||||
await element(by.text('About')).tap()
|
||||
await element(by.id('addUserBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).toBeVisible()
|
||||
await waitFor(element(by.id('user-bob.test-addBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('user-bob.test-addBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('user-bob.test'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows posts by the users in the list', async () => {
|
||||
await element(by.text('Posts')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Pins the list', async () => {
|
||||
await element(by.id('pinBtn')).tap()
|
||||
await element(by.id('e2eGotoHome')).tap()
|
||||
await element(by.id('homeScreenFeedTabs-Good Ppl')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
|
||||
await element(by.id('bottomBarFeedsBtn')).tap()
|
||||
await element(by.id('saved-feed-Good Ppl')).tap()
|
||||
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
|
||||
|
||||
await element(by.id('unpinBtn')).tap()
|
||||
await element(by.id('bottomBarHomeBtn')).tap()
|
||||
await expect(
|
||||
element(by.id('homeScreenFeedTabs-Good Ppl')),
|
||||
).not.toBeVisible()
|
||||
|
||||
await element(by.id('e2eGotoLists')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Removes users on curatelists from the list', async () => {
|
||||
await element(by.text('About')).tap()
|
||||
await expect(element(by.id('user-bob.test'))).toBeVisible()
|
||||
await element(by.id('user-bob.test-editBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows the curatelist on my profile', async () => {
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Adds and removes users on curatelists from the profile', async () => {
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('bob')
|
||||
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
|
||||
await expect(element(by.id('profileView'))).toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Good Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can report a user list', async () => {
|
||||
await element(by.id('e2eGotoSettings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
await loginAsBob()
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('alice')
|
||||
await element(by.id('searchAutoCompleteResult-alice.test')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-3')).tap()
|
||||
await element(by.id('list-Good Ppl')).tap()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Report List')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await expect(element(by.text('Report List'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
describe('Mod lists', () => {
|
||||
describe('Mute lists', () => {
|
||||
beforeAll(async () => {
|
||||
await createServer('?users&follows&labels')
|
||||
await openApp({
|
||||
@@ -10,11 +10,11 @@ describe('Mod lists', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Login and view my modlists', async () => {
|
||||
it('Login and view my mutelists', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eGotoModeration')).tap()
|
||||
await element(by.id('moderationlistsBtn')).tap()
|
||||
await element(by.id('mutelistsBtn')).tap()
|
||||
await expect(element(by.id('list-Muted Users'))).toBeVisible()
|
||||
await element(by.id('list-Muted Users')).tap()
|
||||
await expect(
|
||||
@@ -22,128 +22,101 @@ describe('Mod lists', () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('Toggle mute subscription', async () => {
|
||||
await element(by.id('unmuteBtn')).tap()
|
||||
await element(by.id('subscribeBtn')).tap()
|
||||
await element(by.text('Mute accounts')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
it('Toggle subscription', async () => {
|
||||
await element(by.id('unsubscribeListBtn')).tap()
|
||||
await element(by.id('subscribeListBtn')).tap()
|
||||
})
|
||||
|
||||
it('Edit display name and description via the edit modlist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Edit display name and description via the edit mutelist modal', async () => {
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).clearText()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove description via the edit modlist modal', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Remove description via the edit mutelist modal', async () => {
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editDescriptionInput')).clearText()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listDescription'))).not.toBeVisible()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit modlist modal', async () => {
|
||||
it('Set avi via the edit mutelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Remove avi via the edit modlist modal', async () => {
|
||||
it('Remove avi via the edit mutelist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit List Details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('editListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Delete the modlist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Delete List')).tap()
|
||||
it('Delete the mutelist', async () => {
|
||||
await element(by.id('deleteListBtn')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
await expect(element(by.id('listsEmpty'))).toBeVisible()
|
||||
await expect(element(by.id('emptyMuteLists'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Create a new modlist', async () => {
|
||||
await element(by.id('newModListBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
it('Create a new mutelist', async () => {
|
||||
await element(by.id('emptyMuteLists-button')).tap()
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||
})
|
||||
|
||||
it('Adds and removes users on modlists from the list', async () => {
|
||||
await element(by.id('addUserBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).toBeVisible()
|
||||
await waitFor(element(by.id('user-warn-posts.test-addBtn')))
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('editListBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('user-warn-posts.test-addBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
|
||||
await element(by.id('listItems-flatlist')).swipe(
|
||||
'down',
|
||||
'slow',
|
||||
1,
|
||||
0.5,
|
||||
0.5,
|
||||
)
|
||||
await expect(element(by.id('user-warn-posts.test'))).toBeVisible()
|
||||
await element(by.id('user-warn-posts.test-editBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Shows the modlist on my profile', async () => {
|
||||
it('Shows the mutelist on my profile', async () => {
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await element(by.id('list-Bad Ppl')).tap()
|
||||
})
|
||||
|
||||
it('Adds and removes users on modlists from the profile', async () => {
|
||||
it('Adds and removes users on mutelists', async () => {
|
||||
await element(by.id('bottomBarSearchBtn')).tap()
|
||||
await element(by.id('searchTextInput')).typeText('bob')
|
||||
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
|
||||
@@ -151,17 +124,17 @@ describe('Mod lists', () => {
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Add to Lists')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can report a mute list', async () => {
|
||||
@@ -174,8 +147,7 @@ describe('Mod lists', () => {
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-3')).tap()
|
||||
await element(by.id('list-Bad Ppl')).tap()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Report List')).tap()
|
||||
await element(by.id('reportListBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await expect(element(by.text('Report List'))).toBeVisible()
|
||||
await element(
|
||||
@@ -1,3 +0,0 @@
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
}))
|
||||
@@ -27,42 +27,6 @@ describe('linkRequiresWarning', () => {
|
||||
['http://site.pages', 'http://site.pages.dev', true],
|
||||
['http://site.pages.dev', 'site.pages', true],
|
||||
['http://site.pages', 'site.pages.dev', true],
|
||||
['http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['http://bsky.app/', 'bluesky', false],
|
||||
['https://bsky.app/', 'bluesky', false],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
['http://bsky.app/', 'https://google.com', true],
|
||||
['https://bsky.app/', 'https://google.com', true],
|
||||
|
||||
// bad uri inputs, default to true
|
||||
['', '', true],
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ module.exports = function () {
|
||||
slug: 'bluesky',
|
||||
scheme: 'bluesky',
|
||||
owner: 'blueskysocial',
|
||||
version: '1.56.0',
|
||||
version: '1.51.0',
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
@@ -19,7 +19,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '3',
|
||||
buildNumber: '5',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -43,7 +43,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: 46,
|
||||
versionCode: 39,
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#ffffff',
|
||||
|
||||
+1
-24
@@ -1,23 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
const isTestEnv = process.env.NODE_ENV === 'test'
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
'babel-preset-expo',
|
||||
{
|
||||
lazyImports: true,
|
||||
native: {
|
||||
// We should be able to remove this after upgrading Expo
|
||||
// to a version that includes https://github.com/expo/expo/pull/24672.
|
||||
unstable_transformProfile: 'hermes-stable',
|
||||
// Disable ESM -> CJS compilation because Metro takes care of it.
|
||||
// However, we need it in Jest tests since those run without Metro.
|
||||
disableImportExportTransform: !isTestEnv,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
@@ -37,7 +21,6 @@ module.exports = function (api) {
|
||||
{
|
||||
alias: {
|
||||
// This needs to be mirrored in tsconfig.json
|
||||
'#': './src',
|
||||
lib: './src/lib',
|
||||
platform: './src/platform',
|
||||
state: './src/state',
|
||||
@@ -45,13 +28,7 @@ module.exports = function (api) {
|
||||
},
|
||||
},
|
||||
],
|
||||
'macros',
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +91,6 @@ func serve(cctx *cli.Context) error {
|
||||
}
|
||||
|
||||
e.HideBanner = true
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
|
||||
e.IPExtractor = echo.ExtractIPFromXFFHeader()
|
||||
|
||||
// SECURITY: Do not modify without due consideration.
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
ContentTypeNosniff: "nosniff",
|
||||
@@ -111,23 +106,8 @@ func serve(cctx *cli.Context) error {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
}))
|
||||
e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
|
||||
middleware.RateLimiterMemoryStoreConfig{
|
||||
Rate: 10, // requests per second
|
||||
Burst: 30, // allow bursts
|
||||
ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
|
||||
},
|
||||
),
|
||||
IdentifierExtractor: func(ctx echo.Context) (string, error) {
|
||||
id := ctx.RealIP()
|
||||
return id, nil
|
||||
},
|
||||
DenyHandler: func(c echo.Context, identifier string, err error) error {
|
||||
return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact security@bsky.app if you believe this was a mistake.\n")
|
||||
},
|
||||
}))
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
@@ -181,9 +161,8 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/lists", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
e.GET("/moderation/mute-lists", server.WebGeneric)
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
|
||||
@@ -176,8 +176,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
<meta name="application-name" name="Bluesky">
|
||||
<meta name="generator" name="bskyweb">
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
|
||||
+14
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
## App Build
|
||||
|
||||
- Set up your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
||||
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
||||
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
|
||||
- yarn global add detox-cli
|
||||
- brew tap wix/brew
|
||||
@@ -20,7 +20,7 @@
|
||||
- iOS: `yarn ios`
|
||||
- Android: `yarn android`
|
||||
- Web: `yarn web`
|
||||
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
|
||||
- If you are cloning or forking this repo as an open source developer, please check the tips below as well
|
||||
- Run e2e tests
|
||||
- Start in various console tabs:
|
||||
- `yarn e2e:mock-server`
|
||||
@@ -28,14 +28,14 @@
|
||||
- Run once: `yarn e2e:build`
|
||||
- Each test run: `yarn e2e:run`
|
||||
- Tips
|
||||
- Copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on the device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` and `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- Make sure to copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- If the Android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The Android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- If the android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- For instance, the locally-hosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
|
||||
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file that doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
|
||||
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
|
||||
|
||||
### Adding Sentry
|
||||
|
||||
@@ -81,24 +81,24 @@ To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-me
|
||||
- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal
|
||||
- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal
|
||||
- iOS Device: Shake the device, or touch 3 fingers to the screen
|
||||
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture or press Cmd ⌘ + d
|
||||
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d
|
||||
|
||||
### Running E2E Tests
|
||||
|
||||
- Make sure you've set your environment following the above
|
||||
- Make sure you've setup your environment following above
|
||||
- Make sure Metro and the dev server are running
|
||||
- Run `yarn e2e`
|
||||
- Find the artifacts in the `artifact` folder
|
||||
|
||||
### Polyfills
|
||||
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
|
||||
### Sentry sourcemaps
|
||||
|
||||
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to take the following steps to upload sourcemaps to Sentry:
|
||||
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry:
|
||||
|
||||
- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step.
|
||||
- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS).
|
||||
@@ -122,6 +122,6 @@ dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
|
||||
|
||||
### OTA updates
|
||||
|
||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. All the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||
|
||||
The clients which can receive an OTA update are governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update to update. If we are manually setting `runtimeVersion`, we should increment the version each time the native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Internationalization
|
||||
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app)
|
||||
|
||||
## Tools
|
||||
We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
### Adding new strings
|
||||
When adding a new string, do it as follows:
|
||||
```jsx
|
||||
// Before
|
||||
import { Text } from "react-native";
|
||||
|
||||
<Text>Hello World</Text>
|
||||
```
|
||||
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
|
||||
The `<Trans>` macro will extract the string and add it to the catalog. It is not really a component, but a macro. Further reading [here](https://lingui.dev/ref/macro.html)
|
||||
|
||||
However sometimes you will run into this case:
|
||||
```jsx
|
||||
// Before
|
||||
import { Text } from "react-native";
|
||||
|
||||
const text = "Hello World";
|
||||
<Text accessibilityLabel="Label is here">{text}</Text>
|
||||
```
|
||||
In this case, you cannot use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
const text = t`Hello World`;
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
|
||||
The configuration for translations is defined in `lingui.config.js`
|
||||
|
||||
So the workflow is as follows:
|
||||
1. Wrap messages in Trans macro
|
||||
2. Run `yarn intl:extract` command to generate message catalogs
|
||||
3. Translate message catalogs (send them to translators usually)
|
||||
4. Run `yarn intl:compile` to create runtime catalogs
|
||||
5. Load runtime catalog
|
||||
6. Enjoy translated app!
|
||||
|
||||
### Common pitfalls
|
||||
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
|
||||
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
// ❌ Bad! This code won't work
|
||||
export function Welcome() {
|
||||
const buggyWelcome = useMemo(() => {
|
||||
return i18n._(welcomeMessage);
|
||||
}, []);
|
||||
|
||||
return <div>{buggyWelcome}</div>;
|
||||
}
|
||||
|
||||
// ❌ Bad! This code won't work either because the reference to i18n does not change
|
||||
export function Welcome() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const buggyWelcome = useMemo(() => {
|
||||
return i18n._(welcomeMessage);
|
||||
}, [i18n]);
|
||||
|
||||
return <div>{buggyWelcome}</div>;
|
||||
}
|
||||
|
||||
// ✅ Good! `useMemo` has i18n context in the dependency
|
||||
export function Welcome() {
|
||||
const linguiCtx = useLingui();
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
return linguiCtx.i18n._(welcomeMessage);
|
||||
}, [linguiCtx]);
|
||||
|
||||
return <div>{welcome}</div>;
|
||||
}
|
||||
|
||||
// 🤩 Better! `useMemo` consumes the `_` function from the Lingui context
|
||||
export function Welcome() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
return _(welcomeMessage);
|
||||
}, [_]);
|
||||
|
||||
return <div>{welcome}</div>;
|
||||
}
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
# Testing instructions
|
||||
|
||||
### 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.
|
||||
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
|
||||
|
||||
|
||||
### Using Flashlight for Performance Testing
|
||||
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
|
||||
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`.
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
get_container_id() {
|
||||
local compose_file=$1
|
||||
local service=$2
|
||||
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||
echo "usage: get_container_id <compose_file> <service>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -f $compose_file ps --format json --status running \
|
||||
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||
}
|
||||
|
||||
# Exports all environment variables
|
||||
export_env() {
|
||||
export_pg_env
|
||||
export_redis_env
|
||||
}
|
||||
|
||||
# Exports postgres environment variables
|
||||
export_pg_env() {
|
||||
# Based on creds in compose.yaml
|
||||
export PGPORT=5433
|
||||
export PGHOST=localhost
|
||||
export PGUSER=pg
|
||||
export PGPASSWORD=password
|
||||
export PGDATABASE=postgres
|
||||
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||
}
|
||||
|
||||
# Exports redis environment variables
|
||||
export_redis_env() {
|
||||
export REDIS_HOST="127.0.0.1:6380"
|
||||
}
|
||||
|
||||
# Main entry point
|
||||
main() {
|
||||
# Expect a SERVICES env var to be set with the docker service names
|
||||
local services=${SERVICES}
|
||||
|
||||
dir=$(dirname $0)
|
||||
compose_file="${dir}/docker-compose.yaml"
|
||||
|
||||
# whether this particular script started the container(s)
|
||||
started_container=false
|
||||
|
||||
# trap SIGINT and performs cleanup as necessary, i.e.
|
||||
# taking down containers if this script started them
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
local services=$@
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||
fi
|
||||
exit $?
|
||||
}
|
||||
|
||||
# check if all services are running already
|
||||
not_running=false
|
||||
for service in $services; do
|
||||
container_id=$(get_container_id $compose_file $service)
|
||||
if [ -z $container_id ]; then
|
||||
not_running=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# if any are missing, recreate all services
|
||||
if $not_running; then
|
||||
docker compose -f $compose_file up --wait --force-recreate ${services}
|
||||
started_container=true
|
||||
else
|
||||
echo "all services ${services} are already running"
|
||||
fi
|
||||
|
||||
# setup environment variables and run args
|
||||
export_env
|
||||
"$@"
|
||||
# save return code for later
|
||||
code=$?
|
||||
|
||||
# performs cleanup as necessary, i.e. taking down containers
|
||||
# if this script started them
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||
fi
|
||||
|
||||
exit ${code}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
# An ephermerally-stored postgres database for single-use test runs
|
||||
db_test: &db_test
|
||||
image: postgres:14.4-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=pg
|
||||
- POSTGRES_PASSWORD=password
|
||||
ports:
|
||||
- '5433:5432'
|
||||
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: 'pg_isready -U pg'
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored postgres database
|
||||
db:
|
||||
<<: *db_test
|
||||
ports:
|
||||
- '5432:5432'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- atp_db:/var/lib/postgresql/data
|
||||
# An ephermerally-stored redis cache for single-use test runs
|
||||
redis_test: &redis_test
|
||||
image: redis:7.0-alpine
|
||||
ports:
|
||||
- '6380:6379'
|
||||
# Healthcheck ensures redis is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', '[ "$$(redis-cli ping)" = "PONG" ]']
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored redis cache
|
||||
redis:
|
||||
<<: *redis_test
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
ports:
|
||||
- '6379:6379'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- atp_redis:/data
|
||||
volumes:
|
||||
atp_db:
|
||||
atp_redis:
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test" main "$@"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-redis-and-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
# ./with-test-redis-and-db.sh redis-cli -h localhost -p 6380 ping
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test redis_test" main "$@"
|
||||
@@ -74,14 +74,3 @@ jest.mock('lande', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn().mockReturnValue([['eng']]),
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
Native: {
|
||||
ReactNativeTracing: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
})),
|
||||
ReactNavigationInstrumentation: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
+60
-94
@@ -1,7 +1,7 @@
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestNetwork} from '@atproto/dev-env'
|
||||
import {TestNetworkNoAppView} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
@@ -18,59 +18,14 @@ export interface TestPDS {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
class StringIdGenerator {
|
||||
_nextId = [0]
|
||||
constructor(
|
||||
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
) {}
|
||||
|
||||
next() {
|
||||
const r = []
|
||||
for (const char of this._nextId) {
|
||||
r.unshift(this._chars[char])
|
||||
}
|
||||
this._increment()
|
||||
return r.join('')
|
||||
}
|
||||
|
||||
_increment() {
|
||||
for (let i = 0; i < this._nextId.length; i++) {
|
||||
const val = ++this._nextId[i]
|
||||
if (val >= this._chars.length) {
|
||||
this._nextId[i] = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
this._nextId.push(0)
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
while (true) {
|
||||
yield this.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||
): Promise<TestPDS> {
|
||||
const port = await getPort()
|
||||
const port2 = await getPort(port + 1)
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const id = ids.next()
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
publicUrl: pdsUrl,
|
||||
inviteRequired,
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
},
|
||||
const testNet = await TestNetworkNoAppView.create({
|
||||
pds: {port, publicUrl: pdsUrl, inviteRequired},
|
||||
plc: {port: port2},
|
||||
})
|
||||
|
||||
@@ -93,7 +48,7 @@ class Mocker {
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(
|
||||
public testNet: TestNetwork,
|
||||
public testNet: TestNetworkNoAppView,
|
||||
public service: string,
|
||||
public pic: Uint8Array,
|
||||
) {
|
||||
@@ -104,10 +59,6 @@ class Mocker {
|
||||
return this.testNet.pds
|
||||
}
|
||||
|
||||
get bsky() {
|
||||
return this.testNet.bsky
|
||||
}
|
||||
|
||||
get plc() {
|
||||
return this.testNet.plc
|
||||
}
|
||||
@@ -130,7 +81,11 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -305,7 +260,11 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -316,21 +275,24 @@ class Mocker {
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.bsky.ctx
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -345,39 +307,43 @@ class Mocker {
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.bsky.ctx
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.bsky.ctx
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
locales: ['en', 'cs', 'fr', 'hi', 'es'],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locale/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: 'po',
|
||||
}
|
||||
@@ -1,25 +1,7 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const {getDefaultConfig} = require('expo/metro-config')
|
||||
const cfg = getDefaultConfig(__dirname)
|
||||
|
||||
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
||||
: cfg.resolver.sourceExts
|
||||
|
||||
cfg.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true,
|
||||
nonInlinedRequires: [
|
||||
// We can remove this option and rely on the default after
|
||||
// https://github.com/facebook/metro/pull/1126 is released.
|
||||
'React',
|
||||
'react',
|
||||
'react/jsx-dev-runtime',
|
||||
'react/jsx-runtime',
|
||||
'react-native',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = cfg
|
||||
|
||||
+18
-36
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.56.0",
|
||||
"version": "1.51.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
@@ -11,29 +11,21 @@
|
||||
"web": "expo start --web",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
"test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"test": "jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "jest --watchAll",
|
||||
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
|
||||
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build": "detox build -c ios.sim.debug",
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
"perf:test": "NODE_ENV=test maestro test",
|
||||
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"build:apk": "eas build -p android --profile dev-android-apk",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:compile": "lingui compile"
|
||||
"build:apk": "eas build -p android --profile dev-android-apk"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.6.23",
|
||||
"@atproto/api": "^0.6.20",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -43,8 +35,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^4.5.1",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@gorhom/bottom-sheet": "^4.4.7",
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@react-native-async-storage/async-storage": "1.18.2",
|
||||
@@ -62,8 +53,8 @@
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "5.10.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
"@sentry/react-native": "5.5.0",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tiptap/core": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-hard-break": "^2.0.3",
|
||||
@@ -80,10 +71,8 @@
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
"await-lock": "^2.2.2",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"base64-js": "^1.5.1",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
@@ -106,6 +95,7 @@
|
||||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"graphemer": "^1.4.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
@@ -122,7 +112,6 @@
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
"nanoid": "^5.0.2",
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
@@ -133,6 +122,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-native": "0.72.5",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-draggable-flatlist": "^4.0.1",
|
||||
"react-native-drawer-layout": "^3.2.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "^2.12.1",
|
||||
@@ -158,21 +148,18 @@
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"sentry-expo": "~7.0.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.5",
|
||||
"@babel/core": "^7.23.2",
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@did-plc/server": "^0.0.1",
|
||||
"@lingui/cli": "^4.5.0",
|
||||
"@lingui/macro": "^4.5.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
|
||||
"@react-native-community/eslint-config": "^3.0.0",
|
||||
"@testing-library/jest-native": "^5.4.1",
|
||||
@@ -197,10 +184,9 @@
|
||||
"@typescript-eslint/parser": "^5.48.2",
|
||||
"babel-jest": "^29.4.2",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"babel-plugin-react-native-web": "^0.18.12",
|
||||
"detox": "^20.13.0",
|
||||
"detox": "^20.11.3",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-detox": "^1.0.0",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
@@ -227,8 +213,7 @@
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18",
|
||||
"**/zeed-dom": "0.10.9"
|
||||
"@types/react": "^18"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
@@ -244,11 +229,8 @@
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
"transform": {
|
||||
"\\.[jt]sx?$": "babel-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
+1
-2
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
index 7e0b4cd..177454c 100644
|
||||
index 7e0b4cd..3fd7406 100644
|
||||
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
|
||||
@@ -12,4 +12,3 @@ index 7e0b4cd..177454c 100644
|
||||
+ } catch (e) {}
|
||||
}
|
||||
//# sourceMappingURL=ignorerequirecyclelogs.js.map
|
||||
\ No newline at end of file
|
||||
@@ -1,14 +0,0 @@
|
||||
diff --git a/node_modules/babel-preset-expo/index.js b/node_modules/babel-preset-expo/index.js
|
||||
index 2099ee3..2b9e092 100644
|
||||
--- a/node_modules/babel-preset-expo/index.js
|
||||
+++ b/node_modules/babel-preset-expo/index.js
|
||||
@@ -105,7 +105,8 @@ module.exports = function (api, options = {}) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
- getObjectRestSpreadPlugin(),
|
||||
+ // - dan: This will be disabled anyway when we upgrade Expo, but let's do it now.
|
||||
+ // getObjectRestSpreadPlugin(),
|
||||
...extraPlugins,
|
||||
getAliasPlugin(),
|
||||
[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }],
|
||||
@@ -1,12 +0,0 @@
|
||||
diff --git a/node_modules/babel-preset-fbjs/plugins/inline-requires.js b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
index b11fc83..e18661a 100644
|
||||
--- a/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
+++ b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
@@ -256,6 +256,7 @@ function getInlineableModule(path, state) {
|
||||
|
||||
return moduleName == null ||
|
||||
state.ignoredRequires.has(moduleName) ||
|
||||
+ moduleName.startsWith('@babel/runtime/') ||
|
||||
isRequireInScope
|
||||
? null
|
||||
: { moduleName, requireFnName: fnName };
|
||||
@@ -1,44 +0,0 @@
|
||||
diff --git a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
index 48a1409..ef185c9 100644
|
||||
--- a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
+++ b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
@@ -70,14 +70,19 @@ function wrapModule(
|
||||
importDefaultName,
|
||||
importAllName,
|
||||
dependencyMapName,
|
||||
- globalPrefix
|
||||
+ globalPrefix,
|
||||
+ moduleFactoryName
|
||||
) {
|
||||
const params = buildParameters(
|
||||
importDefaultName,
|
||||
importAllName,
|
||||
dependencyMapName
|
||||
);
|
||||
- const factory = functionFromProgram(fileAst.program, params);
|
||||
+ const factory = functionFromProgram(
|
||||
+ fileAst.program,
|
||||
+ params,
|
||||
+ moduleFactoryName
|
||||
+ );
|
||||
const def = t.callExpression(t.identifier(`${globalPrefix}__d`), [factory]);
|
||||
const ast = t.file(t.program([t.expressionStatement(def)]));
|
||||
const requireName = renameRequires(ast);
|
||||
@@ -107,7 +112,16 @@ function wrapJson(source, globalPrefix) {
|
||||
"});",
|
||||
].join("\n");
|
||||
}
|
||||
-function functionFromProgram(program, parameters) {
|
||||
+const JS_INVALID_IDENT_RE = /[^a-zA-Z0-9$_]/g;
|
||||
+function functionFromProgram(program, parameters, moduleFactoryName) {
|
||||
+ let identifier;
|
||||
+ if (typeof moduleFactoryName === "string" && moduleFactoryName !== "") {
|
||||
+ // Keep the name readable so it shows up in profiler traces.
|
||||
+ // Add an unlikely suffix to avoid collisions with the module code.
|
||||
+ identifier = t.identifier(
|
||||
+ `${moduleFactoryName.replace(JS_INVALID_IDENT_RE, "_")}__module_factory__`
|
||||
+ );
|
||||
+ }
|
||||
return t.functionExpression(
|
||||
undefined,
|
||||
parameters.map(makeIdentifier),
|
||||
@@ -1,50 +0,0 @@
|
||||
diff --git a/node_modules/metro-runtime/src/polyfills/require.js b/node_modules/metro-runtime/src/polyfills/require.js
|
||||
index ce67cb4..eeeae84 100644
|
||||
--- a/node_modules/metro-runtime/src/polyfills/require.js
|
||||
+++ b/node_modules/metro-runtime/src/polyfills/require.js
|
||||
@@ -22,6 +22,13 @@ global.__c = clear;
|
||||
global.__registerSegment = registerSegment;
|
||||
var modules = clear();
|
||||
|
||||
+if (__DEV__) {
|
||||
+ // Added by Dan for module init logging.
|
||||
+ global.__INIT_LOGS__ = []
|
||||
+ var initModuleCounter = 0
|
||||
+ var initModuleStack = []
|
||||
+}
|
||||
+
|
||||
// Don't use a Symbol here, it would pull in an extra polyfill with all sorts of
|
||||
// additional stuff (e.g. Array.from).
|
||||
const EMPTY = {};
|
||||
@@ -303,7 +310,30 @@ function loadModuleImplementation(moduleId, module) {
|
||||
throw module.error;
|
||||
}
|
||||
if (__DEV__) {
|
||||
- var Systrace = requireSystrace();
|
||||
+ // Added by Dan for module init logging.
|
||||
+ var Systrace = {
|
||||
+ beginEvent(label) {
|
||||
+ let fullLabel = initModuleCounter++ + ' ' + label
|
||||
+ global.__INIT_LOGS__.push(
|
||||
+ ' '.repeat(initModuleStack.length) +
|
||||
+ ' ENTER ' + fullLabel
|
||||
+ )
|
||||
+ initModuleStack.push({
|
||||
+ fullLabel,
|
||||
+ startTime: nativePerformanceNow(),
|
||||
+ })
|
||||
+ },
|
||||
+ endEvent() {
|
||||
+ const res = initModuleStack.pop()
|
||||
+ const fullLabel = res.fullLabel
|
||||
+ const startTime = res.startTime
|
||||
+ const timeElapsed = Math.round(nativePerformanceNow() - startTime)
|
||||
+ global.__INIT_LOGS__.push(
|
||||
+ ' '.repeat(initModuleStack.length) +
|
||||
+ ' LEAVE ' + fullLabel + ' [' + timeElapsed + 'ms]',
|
||||
+ )
|
||||
+ }
|
||||
+ };
|
||||
var Refresh = requireRefresh();
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
|
||||
index 27d4cb3..fd71f47 100644
|
||||
--- a/node_modules/metro-transform-worker/src/index.js
|
||||
+++ b/node_modules/metro-transform-worker/src/index.js
|
||||
@@ -190,6 +190,10 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
let dependencyMapName = "";
|
||||
let dependencies;
|
||||
let wrappedAst;
|
||||
+ const minify =
|
||||
+ options.minify &&
|
||||
+ options.unstable_transformProfile !== "hermes-canary" &&
|
||||
+ options.unstable_transformProfile !== "hermes-stable";
|
||||
|
||||
// If the module to transform is a script (meaning that is not part of the
|
||||
// dependency graph and it code will just be prepended to the bundle modules),
|
||||
@@ -229,19 +233,20 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
if (config.unstable_disableModuleWrapping === true) {
|
||||
wrappedAst = ast;
|
||||
} else {
|
||||
+ let moduleFactoryName;
|
||||
+ if (options.dev && !minify) {
|
||||
+ moduleFactoryName = file.filename;
|
||||
+ }
|
||||
({ ast: wrappedAst } = JsFileWrapping.wrapModule(
|
||||
ast,
|
||||
importDefault,
|
||||
importAll,
|
||||
dependencyMapName,
|
||||
- config.globalPrefix
|
||||
+ config.globalPrefix,
|
||||
+ moduleFactoryName
|
||||
));
|
||||
}
|
||||
}
|
||||
- const minify =
|
||||
- options.minify &&
|
||||
- options.unstable_transformProfile !== "hermes-canary" &&
|
||||
- options.unstable_transformProfile !== "hermes-stable";
|
||||
const reserved = [];
|
||||
if (config.unstable_dependencyMapReservedName != null) {
|
||||
reserved.push(config.unstable_dependencyMapReservedName);
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
index ab0fc7f..1ace752 100644
|
||||
index ab0fc7f..fbbf19f 100644
|
||||
--- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
+++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
@@ -1,6 +1,6 @@
|
||||
@@ -19,7 +19,7 @@ index ab0fc7f..1ace752 100644
|
||||
|
||||
@property(nonatomic, strong) UIPageViewController *reactPageViewController;
|
||||
@property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
|
||||
@@ -80,6 +80,10 @@ - (void)didMoveToWindow {
|
||||
@@ -80,6 +80,10 @@
|
||||
[self setupInitialController];
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ index ab0fc7f..1ace752 100644
|
||||
if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
|
||||
[self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
|
||||
}
|
||||
@@ -463,4 +467,21 @@ - (NSString *)determineScrollDirection:(UIScrollView *)scrollView {
|
||||
@@ -463,4 +467,21 @@
|
||||
- (BOOL)isLtrLayout {
|
||||
return [_layoutDirection isEqualToString:@"ltr"];
|
||||
}
|
||||
+
|
||||
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||
+ if (!_overdrag && otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ if (otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [p velocityInView:self];
|
||||
+ if (self.currentIndex == 0 && velocity.x > 0) {
|
||||
|
||||
+43
-99
@@ -1,130 +1,74 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {Linking} from 'react-native'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {enableFreeze} from 'react-native-screens'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {init as initReminders} from '#/state/shell/reminders'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
import {useColorMode} from 'state/shell'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as view from './view/index'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import * as Toast from './view/com/util/Toast'
|
||||
import {handleLink} from './Navigation'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {i18n} from '@lingui/core'
|
||||
import {I18nProvider} from '@lingui/react'
|
||||
import {messages} from './locale/locales/en/messages'
|
||||
i18n.load('en', messages)
|
||||
i18n.activate('en')
|
||||
|
||||
enableFreeze(true)
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
function InnerApp() {
|
||||
const colorMode = useColorMode()
|
||||
const {isInitialLoad} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
initReminders()
|
||||
analytics.init()
|
||||
notifications.init(queryClient)
|
||||
listenSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
notifications.init(store)
|
||||
SplashScreen.hideAsync()
|
||||
Linking.getInitialURL().then((url: string | null) => {
|
||||
if (url) {
|
||||
handleLink(url)
|
||||
}
|
||||
})
|
||||
Linking.addEventListener('url', ({url}) => {
|
||||
handleLink(url)
|
||||
})
|
||||
store.onSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
})
|
||||
})
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
if (isInitialLoad) {
|
||||
// TODO add a loading state
|
||||
if (!rootStore) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* Session and initial state should be loaded prior to rendering below.
|
||||
*/
|
||||
|
||||
return (
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={colorMode}>
|
||||
<analytics.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</I18nProvider>
|
||||
</analytics.Provider>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: only nothing here can depend on other data or session state, since
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<InnerApp />
|
||||
</LightboxStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default App
|
||||
export default withSentry(App)
|
||||
|
||||
+28
-94
@@ -1,118 +1,52 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {enableFreeze} from 'react-native-screens'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {init as initReminders} from '#/state/shell/reminders'
|
||||
import {useColorMode} from 'state/shell'
|
||||
import * as view from './view/index'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell/index'
|
||||
import {ToastContainer} from './view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {i18n} from '@lingui/core'
|
||||
import {I18nProvider} from '@lingui/react'
|
||||
import {defaultLocale, dynamicActivate} from './locale/i18n'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
enableFreeze(true)
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const colorMode = useColorMode()
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
initReminders()
|
||||
analytics.init()
|
||||
dynamicActivate(defaultLocale) // async import of locale data
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
if (isInitialLoad) {
|
||||
// TODO add a loading state
|
||||
if (!rootStore) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* Session and initial state should be loaded prior to rendering below.
|
||||
*/
|
||||
|
||||
return (
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={colorMode}>
|
||||
<analytics.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
</I18nProvider>
|
||||
<ToastContainer />
|
||||
</analytics.Provider>
|
||||
<ToastContainer />
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: only nothing here can depend on other data or session state, since
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<InnerApp />
|
||||
</LightboxStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default App
|
||||
|
||||
+69
-101
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
@@ -32,26 +32,26 @@ import {isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from './state'
|
||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from './view/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {CustomFeedScreen} from './view/screens/CustomFeed'
|
||||
import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
@@ -91,47 +91,42 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="NotFound"
|
||||
getComponent={() => NotFoundScreen}
|
||||
component={NotFoundScreen}
|
||||
options={{title: title('Not Found')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Lists"
|
||||
component={ListsScreen}
|
||||
options={{title: title('Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Moderation"
|
||||
getComponent={() => ModerationScreen}
|
||||
component={ModerationScreen}
|
||||
options={{title: title('Moderation')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationModlists"
|
||||
getComponent={() => ModerationModlistsScreen}
|
||||
options={{title: title('Moderation Lists')}}
|
||||
name="ModerationMuteLists"
|
||||
component={ModerationMuteListsScreen}
|
||||
options={{title: title('Mute Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMutedAccounts"
|
||||
getComponent={() => ModerationMutedAccounts}
|
||||
component={ModerationMutedAccounts}
|
||||
options={{title: title('Muted Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationBlockedAccounts"
|
||||
getComponent={() => ModerationBlockedAccounts}
|
||||
component={ModerationBlockedAccounts}
|
||||
options={{title: title('Blocked Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
getComponent={() => SettingsScreen}
|
||||
component={SettingsScreen}
|
||||
options={{title: title('Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LanguageSettings"
|
||||
getComponent={() => LanguageSettingsScreen}
|
||||
component={LanguageSettingsScreen}
|
||||
options={{title: title('Language Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Profile"
|
||||
getComponent={() => ProfileScreen}
|
||||
component={ProfileScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`@${route.params.name}`),
|
||||
animation: 'none',
|
||||
@@ -139,101 +134,101 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollowers"
|
||||
getComponent={() => ProfileFollowersScreen}
|
||||
component={ProfileFollowersScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People following @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollows"
|
||||
getComponent={() => ProfileFollowsScreen}
|
||||
component={ProfileFollowsScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People followed by @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title('List')}}
|
||||
component={ProfileListScreen}
|
||||
options={{title: title('Mute List')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
getComponent={() => PostThreadScreen}
|
||||
component={PostThreadScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostLikedBy"
|
||||
getComponent={() => PostLikedByScreen}
|
||||
component={PostLikedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostRepostedBy"
|
||||
getComponent={() => PostRepostedByScreen}
|
||||
component={PostRepostedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
name="CustomFeed"
|
||||
component={CustomFeedScreen}
|
||||
options={{title: title('Feed')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeedLikedBy"
|
||||
getComponent={() => ProfileFeedLikedByScreen}
|
||||
name="CustomFeedLikedBy"
|
||||
component={CustomFeedLikedByScreen}
|
||||
options={{title: title('Liked by')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
getComponent={() => DebugScreen}
|
||||
component={DebugScreen}
|
||||
options={{title: title('Debug')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Log"
|
||||
getComponent={() => LogScreen}
|
||||
component={LogScreen}
|
||||
options={{title: title('Log')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Support"
|
||||
getComponent={() => SupportScreen}
|
||||
component={SupportScreen}
|
||||
options={{title: title('Support')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PrivacyPolicy"
|
||||
getComponent={() => PrivacyPolicyScreen}
|
||||
component={PrivacyPolicyScreen}
|
||||
options={{title: title('Privacy Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="TermsOfService"
|
||||
getComponent={() => TermsOfServiceScreen}
|
||||
component={TermsOfServiceScreen}
|
||||
options={{title: title('Terms of Service')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CommunityGuidelines"
|
||||
getComponent={() => CommunityGuidelinesScreen}
|
||||
component={CommunityGuidelinesScreen}
|
||||
options={{title: title('Community Guidelines')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CopyrightPolicy"
|
||||
getComponent={() => CopyrightPolicyScreen}
|
||||
component={CopyrightPolicyScreen}
|
||||
options={{title: title('Copyright Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AppPasswords"
|
||||
getComponent={() => AppPasswords}
|
||||
component={AppPasswords}
|
||||
options={{title: title('App Passwords')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SavedFeeds"
|
||||
getComponent={() => SavedFeeds}
|
||||
component={SavedFeeds}
|
||||
options={{title: title('Edit My Feeds')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesHomeFeed"
|
||||
getComponent={() => PreferencesHomeFeed}
|
||||
component={PreferencesHomeFeed}
|
||||
options={{title: title('Home Feed Preferences')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesThreads"
|
||||
getComponent={() => PreferencesThreads}
|
||||
component={PreferencesThreads}
|
||||
options={{title: title('Threads Preferences')}}
|
||||
/>
|
||||
</>
|
||||
@@ -258,17 +253,14 @@ function TabsNavigator() {
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
|
||||
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
getComponent={() => NotificationsTabNavigator}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MyProfileTab"
|
||||
getComponent={() => MyProfileTabNavigator}
|
||||
component={NotificationsTabNavigator}
|
||||
/>
|
||||
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -285,7 +277,7 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen name="Home" component={HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -302,7 +294,7 @@ function SearchTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
|
||||
<SearchTab.Screen name="Search" component={SearchScreen} />
|
||||
{commonScreens(SearchTab as typeof HomeTab)}
|
||||
</SearchTab.Navigator>
|
||||
)
|
||||
@@ -319,7 +311,7 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -338,15 +330,16 @@ function NotificationsTabNavigator() {
|
||||
}}>
|
||||
<NotificationsTab.Screen
|
||||
name="Notifications"
|
||||
getComponent={() => NotificationsScreen}
|
||||
component={NotificationsScreen}
|
||||
/>
|
||||
{commonScreens(NotificationsTab as typeof HomeTab)}
|
||||
</NotificationsTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function MyProfileTabNavigator() {
|
||||
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
return (
|
||||
<MyProfileTab.Navigator
|
||||
screenOptions={{
|
||||
@@ -359,25 +352,26 @@ function MyProfileTabNavigator() {
|
||||
<MyProfileTab.Screen
|
||||
name="MyProfile"
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
getComponent={() => ProfileScreen}
|
||||
component={ProfileScreen}
|
||||
initialParams={{
|
||||
name: 'me',
|
||||
name: store.me.did,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
</MyProfileTab.Navigator>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = () => {
|
||||
const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||
const pal = usePalette('default')
|
||||
const numUnread = useUnreadNotifications()
|
||||
const store = useStores()
|
||||
const unreadCountLabel = store.me.notifications.unreadCountLabel
|
||||
|
||||
const title = (page: string) => bskyTitle(page, numUnread)
|
||||
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
||||
return (
|
||||
<Flat.Navigator
|
||||
screenOptions={{
|
||||
@@ -389,28 +383,28 @@ const FlatNavigator = () => {
|
||||
}}>
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
component={HomeScreen}
|
||||
options={{title: title('Home')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
getComponent={() => SearchScreen}
|
||||
component={SearchScreen}
|
||||
options={{title: title('Search')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
component={FeedsScreen}
|
||||
options={{title: title('Feeds')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
getComponent={() => NotificationsScreen}
|
||||
component={NotificationsScreen}
|
||||
options={{title: title('Notifications')}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The RoutesContainer should wrap all components which need access
|
||||
@@ -468,13 +462,11 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onReady={() => {
|
||||
SplashScreen.hideAsync()
|
||||
const initMs = Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logModuleInitTrace()
|
||||
// Register the navigation container with the Sentry instrumentation (only works on native)
|
||||
if (isNative) {
|
||||
const routingInstrumentation = getRoutingInstrumentation()
|
||||
routingInstrumentation.registerNavigationContainer(navigationRef)
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
@@ -491,21 +483,9 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
params?: AllNavigatorParams[K],
|
||||
) {
|
||||
if (navigationRef.isReady()) {
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
const handler = () => {
|
||||
resolve()
|
||||
navigationRef.removeListener('state', handler)
|
||||
}
|
||||
navigationRef.addListener('state', handler)
|
||||
|
||||
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
}),
|
||||
timeout(1e3),
|
||||
])
|
||||
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
|
||||
@@ -583,18 +563,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
})
|
||||
|
||||
function logModuleInitTrace() {
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
const shouldLog = false
|
||||
// Relies on our patch to polyfill.js in metro-runtime
|
||||
const initLogs = (global as any).__INIT_LOGS__
|
||||
if (shouldLog && Array.isArray(initLogs)) {
|
||||
console.log(initLogs.join('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export const IS_TEST = process.env.NODE_ENV === 'test'
|
||||
export const IS_DEV = __DEV__
|
||||
export const IS_PROD = !IS_DEV
|
||||
export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
|
||||
export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
|
||||
| 'debug'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'error'
|
||||
@@ -1,26 +1,15 @@
|
||||
import React from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
createClient,
|
||||
AnalyticsProvider,
|
||||
useAnalytics as useAnalyticsOrig,
|
||||
ClientMethods,
|
||||
} from '@segment/analytics-react-native'
|
||||
import {z} from 'zod'
|
||||
import {useSession} from '#/state/session'
|
||||
import {RootStoreModel, AppInfo} from 'state/models/root-store'
|
||||
import {useStores} from 'state/models/root-store'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {ScreenEvent, TrackEvent} from './types'
|
||||
import {logger} from '#/logger'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
export const appInfo = z.object({
|
||||
build: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
export type AppInfo = z.infer<typeof appInfo>
|
||||
|
||||
const segmentClient = createClient({
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
@@ -31,10 +20,10 @@ const segmentClient = createClient({
|
||||
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
const store = useStores()
|
||||
const methods: ClientMethods = useAnalyticsOrig()
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
if (store.session.hasSession) {
|
||||
return {
|
||||
screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
|
||||
track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
|
||||
@@ -55,18 +44,21 @@ export function useAnalytics() {
|
||||
alias: () => Promise<void>,
|
||||
reset: () => Promise<void>,
|
||||
}
|
||||
}, [hasSession, methods])
|
||||
}, [store, methods])
|
||||
}
|
||||
|
||||
export function init() {
|
||||
listenSessionLoaded(account => {
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
store.log.debug('Ping w/hash')
|
||||
const email_hashed = sha256(sess.email)
|
||||
segmentClient.identify(email_hashed, {email_hashed})
|
||||
} else {
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,40 +66,46 @@ export function init() {
|
||||
// this is a copy of segment's own lifecycle event tracking
|
||||
// we handle it manually to ensure that it never fires while the app is backgrounded
|
||||
// -prf
|
||||
segmentClient.isReady.onChange(async () => {
|
||||
segmentClient.isReady.onChange(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
logger.debug('Prevented a metrics ping while the app was backgrounded')
|
||||
store.log.debug('Prevented a metrics ping while the app was backgrounded')
|
||||
return
|
||||
}
|
||||
const context = segmentClient.context.get()
|
||||
if (typeof context?.app === 'undefined') {
|
||||
logger.debug('Aborted metrics ping due to unavailable context')
|
||||
store.log.debug('Aborted metrics ping due to unavailable context')
|
||||
return
|
||||
}
|
||||
|
||||
const oldAppInfo = await readAppInfo()
|
||||
const oldAppInfo = store.appInfo
|
||||
const newAppInfo = context.app as AppInfo
|
||||
writeAppInfo(newAppInfo)
|
||||
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
store.setAppInfo(newAppInfo)
|
||||
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
|
||||
if (typeof oldAppInfo === 'undefined') {
|
||||
segmentClient.track('Application Installed', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Installed', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
}
|
||||
} else if (newAppInfo.version !== oldAppInfo.version) {
|
||||
segmentClient.track('Application Updated', {
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Updated', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
previous_version: oldAppInfo.version,
|
||||
previous_build: oldAppInfo.build,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (store.session.hasSession) {
|
||||
segmentClient.track('Application Opened', {
|
||||
from_background: false,
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
previous_version: oldAppInfo.version,
|
||||
previous_build: oldAppInfo.build,
|
||||
})
|
||||
}
|
||||
segmentClient.track('Application Opened', {
|
||||
from_background: false,
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
})
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
@@ -131,16 +129,3 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function writeAppInfo(value: AppInfo) {
|
||||
await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function readAppInfo(): Promise<AppInfo | undefined> {
|
||||
const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
|
||||
const obj = rawData ? JSON.parse(rawData) : undefined
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
AnalyticsProvider,
|
||||
useAnalytics as useAnalyticsOrig,
|
||||
} from '@segment/analytics-react'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {useStores} from 'state/models/root-store'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {logger} from '#/logger'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
const segmentClient = createClient(
|
||||
{
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
@@ -25,10 +23,10 @@ const segmentClient = createClient(
|
||||
export const track = segmentClient?.track?.bind?.(segmentClient)
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
const store = useStores()
|
||||
const methods = useAnalyticsOrig()
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
if (store.session.hasSession) {
|
||||
return methods
|
||||
}
|
||||
// dont send analytics pings for anonymous users
|
||||
@@ -41,18 +39,19 @@ export function useAnalytics() {
|
||||
alias: () => {},
|
||||
reset: () => {},
|
||||
}
|
||||
}, [hasSession, methods])
|
||||
}, [store, methods])
|
||||
}
|
||||
|
||||
export function init() {
|
||||
listenSessionLoaded(account => {
|
||||
if (account.did) {
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
store.log.debug('Ping w/hash')
|
||||
const email_hashed = sha256(sess.email)
|
||||
segmentClient.identify(email_hashed, {email_hashed})
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,10 @@ interface TrackPropertiesMap {
|
||||
// LISTS events
|
||||
'Lists:onRefresh': {}
|
||||
'Lists:onEndReached': {}
|
||||
'CreateList:AvatarSelected': {}
|
||||
'CreateList:SaveCurateList': {} // CAN BE SERVER
|
||||
'CreateList:SaveModList': {} // CAN BE SERVER
|
||||
'Lists:Mute': {} // CAN BE SERVER
|
||||
'Lists:Unmute': {} // CAN BE SERVER
|
||||
'Lists:Block': {} // CAN BE SERVER
|
||||
'Lists:Unblock': {} // CAN BE SERVER
|
||||
'CreateMuteList:AvatarSelected': {}
|
||||
'CreateMuteList:Save': {} // CAN BE SERVER
|
||||
'Lists:Subscribe': {} // CAN BE SERVER
|
||||
'Lists:Unsubscribe': {} // CAN BE SERVER
|
||||
// CUSTOM FEED events
|
||||
'CustomFeed:Save': {}
|
||||
'CustomFeed:Unsave': {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
import {FeedSourceInfo} from './feed/types'
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
|
||||
)
|
||||
}
|
||||
|
||||
get source(): ReasonFeedSource | undefined {
|
||||
get source(): FeedSourceInfo | undefined {
|
||||
return this.items.find(item => '__source' in item && !!item.__source)
|
||||
?.__source as ReasonFeedSource
|
||||
?.__source as FeedSourceInfo
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
@@ -116,17 +116,6 @@ export class FeedViewPostsSlice {
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopFeedTuner {
|
||||
reset() {}
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
_tunerFns: FeedTunerFn[] = [],
|
||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
||||
): FeedViewPostsSlice[] {
|
||||
return feed.map(item => new FeedViewPostsSlice([item]))
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenUris: Set<string> = new Set()
|
||||
|
||||
|
||||
+14
-59
@@ -1,90 +1,45 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetAuthorFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: this._filter(res.data.feed),
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
|
||||
_filter(feed: AppBskyFeedDefs.FeedViewPost[]) {
|
||||
if (this.params.filter === 'posts_no_replies') {
|
||||
return feed.filter(post => {
|
||||
const isReply = post.reply
|
||||
const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason)
|
||||
if (!isReply) return true
|
||||
if (isRepost) return true
|
||||
return isReply && isAuthorReplyChain(this.params.actor, post, feed)
|
||||
})
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorReplyChain(
|
||||
actor: string,
|
||||
post: AppBskyFeedDefs.FeedViewPost,
|
||||
posts: AppBskyFeedDefs.FeedViewPost[],
|
||||
): boolean {
|
||||
// current post is by a different user (shouldn't happen)
|
||||
if (post.post.author.handle !== actor) return false
|
||||
|
||||
const replyParent = post.reply?.parent
|
||||
|
||||
if (AppBskyFeedDefs.isPostView(replyParent)) {
|
||||
// reply parent is by a different user
|
||||
if (replyParent.author.handle !== actor) return false
|
||||
|
||||
// A top-level post that matches the parent of the current post.
|
||||
const parentPost = posts.find(p => p.post.uri === replyParent.uri)
|
||||
|
||||
/*
|
||||
* Either we haven't fetched the parent at the top level, or the only
|
||||
* record we have is on feedItem.reply.parent, which we've already checked
|
||||
* above.
|
||||
*/
|
||||
if (!parentPost) return true
|
||||
|
||||
// Walk up to parent
|
||||
return isAuthorReplyChain(actor, parentPost, posts)
|
||||
}
|
||||
|
||||
// Just default to showing it
|
||||
return true
|
||||
}
|
||||
|
||||
+13
-12
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetCustomFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
constructor(public agent: BskyAgent) {}
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getTimeline({
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getTimeline({
|
||||
cursor,
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
||||
+13
-12
@@ -1,37 +1,38 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetActorLikes.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getActorLikes({
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getActorLikes({
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
cursor,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public params: GetListFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-82
@@ -1,12 +1,11 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {feedUriToHref} from 'lib/strings/url-helpers'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedTunerFn} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
|
||||
|
||||
const REQUEST_WAIT_MS = 500 // 500ms
|
||||
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
@@ -18,49 +17,28 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
itemCursor = 0
|
||||
sampleCursor = 0
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public params: FeedParams,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
) {
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
|
||||
this.feedCursor = 0
|
||||
this.itemCursor = 0
|
||||
this.sampleCursor = 0
|
||||
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
|
||||
this.customFeeds = shuffle(
|
||||
this.params.mergeFeedSources.map(
|
||||
feedUri =>
|
||||
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.customFeeds = []
|
||||
}
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.agent.getTimeline({
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
if (!cursor) {
|
||||
this.reset()
|
||||
}
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
// we capture here to ensure the data has loaded
|
||||
this._captureFeedsIfNeeded()
|
||||
|
||||
const promises = []
|
||||
|
||||
@@ -98,7 +76,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: posts.length ? String(this.itemCursor) : undefined,
|
||||
cursor: posts.length ? 'fake' : undefined,
|
||||
feed: posts,
|
||||
}
|
||||
}
|
||||
@@ -129,15 +107,33 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
// provide follow
|
||||
return this.following.take(1)
|
||||
}
|
||||
|
||||
_captureFeedsIfNeeded() {
|
||||
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
|
||||
return
|
||||
}
|
||||
if (this.customFeeds.length === 0) {
|
||||
this.customFeeds = shuffle(
|
||||
this.rootStore.me.savedFeeds.all.map(
|
||||
feed =>
|
||||
new MergeFeedSource_Custom(
|
||||
this.rootStore,
|
||||
feed.uri,
|
||||
feed.displayName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
sourceInfo: FeedSourceInfo | undefined
|
||||
cursor: string | undefined = undefined
|
||||
queue: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
get numReady() {
|
||||
return this.queue.length
|
||||
@@ -199,12 +195,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await this.agent.getTimeline({cursor, limit})
|
||||
const res = await this.rootStore.agent.getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
const slices = this.tuner.tune(
|
||||
res.data.feed,
|
||||
this.rootStore.preferences.getFeedTuners('home'),
|
||||
{
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
},
|
||||
)
|
||||
res.data.feed = slices.map(slice => slice.rootItem)
|
||||
return res
|
||||
}
|
||||
@@ -214,60 +214,42 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
minDate: Date
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public rootStore: RootStoreModel,
|
||||
public feedUri: string,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
public feedDisplayName: string,
|
||||
) {
|
||||
super(agent, feedTuners)
|
||||
super(rootStore)
|
||||
this.sourceInfo = {
|
||||
$type: 'reasonFeedSource',
|
||||
displayName: feedUri.split('/').pop() || '',
|
||||
displayName: feedDisplayName,
|
||||
uri: feedUriToHref(feedUri),
|
||||
}
|
||||
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
|
||||
this.agent.app.bsky.feed
|
||||
.getFeedGenerator({
|
||||
feed: feedUri,
|
||||
})
|
||||
.then(
|
||||
res => {
|
||||
if (this.sourceInfo) {
|
||||
this.sourceInfo.displayName = res.data.view.displayName
|
||||
}
|
||||
},
|
||||
_err => {},
|
||||
)
|
||||
}
|
||||
|
||||
protected async _getFeed(
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
try {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
})
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -prf
|
||||
if (limit && res.data.feed.length > limit) {
|
||||
res.data.feed = res.data.feed.slice(0, limit)
|
||||
}
|
||||
// filter out older posts
|
||||
res.data.feed = res.data.feed.filter(
|
||||
post => new Date(post.post.indexedAt) > this.minDate,
|
||||
)
|
||||
// attach source info
|
||||
for (const post of res.data.feed) {
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return res
|
||||
} catch {
|
||||
// dont bubble custom-feed errors
|
||||
return {success: false, headers: {}, data: {feed: []}}
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
})
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -prf
|
||||
if (limit && res.data.feed.length > limit) {
|
||||
res.data.feed = res.data.feed.slice(0, limit)
|
||||
}
|
||||
// filter out older posts
|
||||
res.data.feed = res.data.feed.filter(
|
||||
post => new Date(post.post.indexedAt) > this.minDate,
|
||||
)
|
||||
// attach source info
|
||||
for (const post of res.data.feed) {
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,12 @@ export interface FeedAPIResponse {
|
||||
}
|
||||
|
||||
export interface FeedAPI {
|
||||
reset(): void
|
||||
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
|
||||
fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse>
|
||||
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
|
||||
}
|
||||
|
||||
export interface ReasonFeedSource {
|
||||
$type: 'reasonFeedSource'
|
||||
export interface FeedSourceInfo {
|
||||
uri: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === 'object' &&
|
||||
'$type' in v &&
|
||||
v.$type === 'reasonFeedSource'
|
||||
)
|
||||
}
|
||||
|
||||
+43
-15
@@ -4,18 +4,17 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyRichtextFacet,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
@@ -25,19 +24,46 @@ export interface ExternalEmbedDraft {
|
||||
localThumb?: ImageModel
|
||||
}
|
||||
|
||||
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
||||
if (!didOrHandle) {
|
||||
throw new Error('Invalid handle: ""')
|
||||
}
|
||||
if (didOrHandle.startsWith('did:')) {
|
||||
return didOrHandle
|
||||
}
|
||||
|
||||
// we run the resolution always to ensure freshness
|
||||
const promise = store.agent
|
||||
.resolveHandle({
|
||||
handle: didOrHandle,
|
||||
})
|
||||
.then(res => {
|
||||
store.handleResolutions.cache.set(didOrHandle, res.data.did)
|
||||
return res.data.did
|
||||
})
|
||||
|
||||
// but we can return immediately if it's cached
|
||||
const cached = store.handleResolutions.cache.get(didOrHandle)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
blob: string,
|
||||
encoding: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (isWeb) {
|
||||
// `blob` should be a data uri
|
||||
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
encoding,
|
||||
})
|
||||
} else {
|
||||
// `blob` should be a path to a file in the local FS
|
||||
return agent.uploadBlob(
|
||||
return store.agent.uploadBlob(
|
||||
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
)
|
||||
@@ -54,11 +80,12 @@ interface PostOpts {
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
knownHandles?: Set<string>
|
||||
onStateChange?: (state: string) => void
|
||||
langs?: string[]
|
||||
}
|
||||
|
||||
export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
let embed:
|
||||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
@@ -67,14 +94,14 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{text: opts.rawText.trim()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
)
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
await rt.detectFacets(agent)
|
||||
await rt.detectFacets(store.agent)
|
||||
rt = shortenLinks(rt)
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
@@ -107,7 +134,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
@@ -151,13 +178,14 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
logger.warn('Unexpected image format for thumbnail, skipping', {
|
||||
thumbnail: opts.extLink.localThumb.path,
|
||||
})
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
agent,
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
@@ -196,7 +224,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
// add replyTo if post is a reply to another post
|
||||
if (opts.replyTo) {
|
||||
const replyToUrip = new AtUri(opts.replyTo)
|
||||
const parentPost = await agent.getPost({
|
||||
const parentPost = await store.agent.getPost({
|
||||
repo: replyToUrip.host,
|
||||
rkey: replyToUrip.rkey,
|
||||
})
|
||||
@@ -229,7 +257,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
|
||||
try {
|
||||
opts.onStateChange?.('Posting...')
|
||||
return await agent.post({
|
||||
return await store.agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
reply,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface AccumulateResponse<T> {
|
||||
cursor?: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export type AccumulateFetchFn<T> = (
|
||||
cursor: string | undefined,
|
||||
) => Promise<AccumulateResponse<T>>
|
||||
|
||||
export async function accumulate<T>(
|
||||
fn: AccumulateFetchFn<T>,
|
||||
pageLimit = 100,
|
||||
): Promise<T[]> {
|
||||
let cursor: string | undefined
|
||||
let acc: T[] = []
|
||||
for (let i = 0; i < pageLimit; i++) {
|
||||
const res = await fn(cursor)
|
||||
cursor = res.cursor
|
||||
acc = acc.concat(res.items)
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {runInAction} from 'mobx'
|
||||
import {deepObserve} from 'mobx-utils'
|
||||
import set from 'lodash.set'
|
||||
|
||||
const ongoingActions = new Set<any>()
|
||||
|
||||
/**
|
||||
* This is a TypeScript function that optimistically updates data on the client-side before sending a
|
||||
* request to the server and rolling back changes if the request fails.
|
||||
* @param {T} model - The object or record that needs to be updated optimistically.
|
||||
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
|
||||
* can be used to perform any necessary actions or updates on the model or UI before the server update
|
||||
* is initiated.
|
||||
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
|
||||
* update operation. This function is called after the previous state of the model has been recorded
|
||||
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
|
||||
* function is called with the result
|
||||
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
|
||||
* server update is successful. It takes in the response from the server update as its parameter. If
|
||||
* this parameter is not provided, nothing will happen after the server update.
|
||||
* @returns A Promise that resolves to `void`.
|
||||
*/
|
||||
export const updateDataOptimistically = async <
|
||||
T extends Record<string, any>,
|
||||
U,
|
||||
>(
|
||||
model: T,
|
||||
preUpdate: () => void,
|
||||
serverUpdate: () => Promise<U>,
|
||||
postUpdate?: (res: U) => void,
|
||||
): Promise<void> => {
|
||||
if (ongoingActions.has(model)) {
|
||||
return
|
||||
}
|
||||
ongoingActions.add(model)
|
||||
|
||||
const prevState: Map<string, any> = new Map<string, any>()
|
||||
const dispose = deepObserve(model, (change, path) => {
|
||||
if (change.observableKind === 'object') {
|
||||
if (change.type === 'update') {
|
||||
prevState.set(
|
||||
[path, change.name].filter(Boolean).join('.'),
|
||||
change.oldValue,
|
||||
)
|
||||
} else if (change.type === 'add') {
|
||||
prevState.set([path, change.name].filter(Boolean).join('.'), undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
preUpdate()
|
||||
dispose()
|
||||
|
||||
try {
|
||||
const res = await serverUpdate()
|
||||
runInAction(() => {
|
||||
postUpdate?.(res)
|
||||
})
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
prevState.forEach((value, path) => {
|
||||
set(model, path, value)
|
||||
})
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
ongoingActions.delete(model)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {timeout} from './timeout'
|
||||
|
||||
export async function until(
|
||||
retries: number,
|
||||
delay: number,
|
||||
cond: (v: any, err: any) => boolean,
|
||||
fn: () => Promise<any>,
|
||||
): Promise<boolean> {
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const v = await fn()
|
||||
if (cond(v, undefined)) {
|
||||
return true
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (cond(undefined, e)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
await timeout(delay)
|
||||
retries--
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export default class BroadcastChannel {
|
||||
constructor(public name: string) {}
|
||||
postMessage(_data: any) {}
|
||||
close() {}
|
||||
onmessage: (event: MessageEvent) => void = () => {}
|
||||
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
|
||||
removeEventListener(
|
||||
_type: string,
|
||||
_listener: (event: MessageEvent) => void,
|
||||
) {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default BroadcastChannel
|
||||
+2
-12
@@ -1,10 +1,4 @@
|
||||
import {Insets, Platform} from 'react-native'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
import {Insets} from 'react-native'
|
||||
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
@@ -49,10 +43,7 @@ export function IS_PROD(url: string) {
|
||||
// until open federation, "production" is defined as the main server
|
||||
// this definition will not work once federation is enabled!
|
||||
// -prf
|
||||
return (
|
||||
url.startsWith('https://bsky.social') ||
|
||||
url.startsWith('https://api.bsky.app')
|
||||
)
|
||||
return url.startsWith('https://bsky.social')
|
||||
}
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
@@ -156,4 +147,3 @@ export const HITSLOP_10 = createHitslop(10)
|
||||
export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {AccountData} from 'state/models/session'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
export function useAccountSwitcher(): [
|
||||
boolean,
|
||||
(v: boolean) => void,
|
||||
(acct: AccountData) => Promise<void>,
|
||||
] {
|
||||
const {track} = useAnalytics()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const store = useStores()
|
||||
const [isSwitching, setIsSwitching] = useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (acct: SessionAccount) => {
|
||||
async (acct: AccountData) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
|
||||
try {
|
||||
await selectAccount(acct)
|
||||
closeAllActiveElements()
|
||||
Toast.show(`Signed in as ${acct.handle}`)
|
||||
} catch (e) {
|
||||
setIsSwitching(true)
|
||||
const success = await store.session.resumeSession(acct)
|
||||
store.shell.closeAllActiveElements()
|
||||
if (success) {
|
||||
resetNavigation()
|
||||
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
|
||||
} else {
|
||||
Toast.show('Sorry! We need you to enter your password.')
|
||||
clearCurrentAccount() // back user out to login
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
store.session.clear()
|
||||
}
|
||||
},
|
||||
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
|
||||
[track, setIsSwitching, navigation, store],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount}
|
||||
return [isSwitching, setIsSwitching, onPressSwitchAccount]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Be warned. This Hook is very buggy unless used in a very constrained way.
|
||||
// To use it safely:
|
||||
//
|
||||
// - DO NOT pass its return value as a prop to any user-defined component.
|
||||
// - DO NOT pass its return value to more than a single component.
|
||||
//
|
||||
// In other words, the only safe way to use it is next to the leaf Reanimated View.
|
||||
//
|
||||
// Relevant bug reports:
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5345
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5360
|
||||
// - https://github.com/software-mansion/react-native-reanimated/issues/5364
|
||||
//
|
||||
// It's great when it works though.
|
||||
export {useAnimatedScrollHandler} from 'react-native-reanimated'
|
||||
@@ -1,44 +0,0 @@
|
||||
import {useRef, useEffect} from 'react'
|
||||
import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
|
||||
|
||||
export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
|
||||
config,
|
||||
deps,
|
||||
) => {
|
||||
const ref = useRef(config)
|
||||
useEffect(() => {
|
||||
ref.current = config
|
||||
})
|
||||
return useAnimatedScrollHandler_BUGGY(
|
||||
{
|
||||
onBeginDrag(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
|
||||
ref.current.onBeginDrag(e)
|
||||
}
|
||||
},
|
||||
onEndDrag(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
|
||||
ref.current.onEndDrag(e)
|
||||
}
|
||||
},
|
||||
onMomentumBegin(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
|
||||
ref.current.onMomentumBegin(e)
|
||||
}
|
||||
},
|
||||
onMomentumEnd(e) {
|
||||
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
|
||||
ref.current.onMomentumEnd(e)
|
||||
}
|
||||
},
|
||||
onScroll(e) {
|
||||
if (typeof ref.current === 'function') {
|
||||
ref.current(e)
|
||||
} else if (ref.current.onScroll) {
|
||||
ref.current.onScroll(e)
|
||||
}
|
||||
},
|
||||
},
|
||||
deps,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
|
||||
const store = useStores()
|
||||
const [item, setItem] = useState<CustomFeedModel | undefined>()
|
||||
useEffect(() => {
|
||||
async function fetchView() {
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: uri,
|
||||
})
|
||||
const view = res.data.view
|
||||
return view
|
||||
}
|
||||
async function buildFeedItem() {
|
||||
const view = await fetchView()
|
||||
if (view) {
|
||||
const temp = new CustomFeedModel(store, view)
|
||||
setItem(temp)
|
||||
}
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
|
||||
export function useFollowDid({did}: {did: string}) {
|
||||
const store = useStores()
|
||||
const state = store.me.follows.getFollowState(did)
|
||||
|
||||
return {
|
||||
state,
|
||||
following: state === FollowState.Following,
|
||||
toggle: React.useCallback(async () => {
|
||||
if (state === FollowState.Following) {
|
||||
try {
|
||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
return {
|
||||
state: FollowState.NotFollowing,
|
||||
following: false,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to delete follow', e)
|
||||
throw e
|
||||
}
|
||||
} else if (state === FollowState.NotFollowing) {
|
||||
try {
|
||||
const res = await store.agent.follow(did)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
return {
|
||||
state: FollowState.Following,
|
||||
following: true,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to create follow', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: FollowState.Unknown,
|
||||
following: false,
|
||||
}
|
||||
}, [store, did, state]),
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function useIsKeyboardVisible({
|
||||
const [isKeyboardVisible, setKeyboardVisible] = useState(false)
|
||||
|
||||
// NOTE
|
||||
// only iOS supports the "will" events
|
||||
// only iOS suppose the "will" events
|
||||
// -prf
|
||||
const showEvent =
|
||||
isIOS && iosUseWillEvents ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import React from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {Animated} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
|
||||
export function useMinimalShellMode() {
|
||||
const mode = useMinimalShellModeState()
|
||||
const {footerHeight, headerHeight} = useShellLayout()
|
||||
|
||||
const footerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const headerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const fabMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
return {
|
||||
footerMinimalShellTransform,
|
||||
headerMinimalShellTransform,
|
||||
fabMinimalShellTransform,
|
||||
const store = useStores()
|
||||
const minimalShellInterp = useAnimatedValue(0)
|
||||
const footerMinimalShellTransform = {
|
||||
transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
} else {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
}
|
||||
}, [minimalShellInterp, store.shell.minimalShellMode])
|
||||
|
||||
return {footerMinimalShellTransform}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||
|
||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
// change, the function itself will remain the same. This means that if you
|
||||
// use this at a higher level of your tree, and then some state you read in it
|
||||
// changes, there is no mechanism for anything below in the tree to "react"
|
||||
// to this change (e.g. by knowing to call your function again).
|
||||
//
|
||||
// Also, you should avoid calling the returned function during rendering
|
||||
// since the values captured by it are going to lag behind.
|
||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
||||
const ref = useRef(fn)
|
||||
useInsertionEffect(() => {
|
||||
ref.current = fn
|
||||
}, [fn])
|
||||
return useCallback(
|
||||
(...args: any) => {
|
||||
const latestFn = ref.current
|
||||
return latestFn(...args)
|
||||
},
|
||||
[ref],
|
||||
) as unknown as T
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import * as Updates from 'expo-updates'
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export function useOTAUpdate() {
|
||||
const {openModal} = useModalControls()
|
||||
const store = useStores()
|
||||
|
||||
// HELPER FUNCTIONS
|
||||
const showUpdatePopup = useCallback(() => {
|
||||
openModal({
|
||||
store.shell.openModal({
|
||||
name: 'confirm',
|
||||
title: 'Update Available',
|
||||
message:
|
||||
@@ -20,9 +19,9 @@ export function useOTAUpdate() {
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [openModal])
|
||||
}, [store.shell])
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
logger.debug('useOTAUpdate: Checking for update...')
|
||||
store.log.debug('useOTAUpdate: Checking for update...')
|
||||
try {
|
||||
// Check if new OTA update is available
|
||||
const update = await Updates.checkForUpdateAsync()
|
||||
@@ -35,18 +34,18 @@ export function useOTAUpdate() {
|
||||
// show a popup modal
|
||||
showUpdatePopup()
|
||||
} catch (e) {
|
||||
logger.error('useOTAUpdate: Error while checking for update', {
|
||||
error: e,
|
||||
})
|
||||
console.error('useOTAUpdate: Error while checking for update', e)
|
||||
store.log.error('useOTAUpdate: Error while checking for update', e)
|
||||
}
|
||||
}, [showUpdatePopup])
|
||||
}, [showUpdatePopup, store.log])
|
||||
const updateEventListener = useCallback(
|
||||
(event: Updates.UpdateEvent) => {
|
||||
logger.debug('useOTAUpdate: Listening for update...')
|
||||
store.log.debug('useOTAUpdate: Listening for update...')
|
||||
if (event.type === Updates.UpdateEventType.ERROR) {
|
||||
logger.error('useOTAUpdate: Error while listening for update', {
|
||||
message: event.message,
|
||||
})
|
||||
store.log.error(
|
||||
'useOTAUpdate: Error while listening for update',
|
||||
event.message,
|
||||
)
|
||||
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
|
||||
// Handle no update available
|
||||
// do nothing
|
||||
@@ -56,7 +55,7 @@ export function useOTAUpdate() {
|
||||
showUpdatePopup()
|
||||
}
|
||||
},
|
||||
[showUpdatePopup],
|
||||
[showUpdatePopup, store.log],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,125 +1,66 @@
|
||||
import {useState, useCallback, useMemo} from 'react'
|
||||
import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {
|
||||
useSharedValue,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
ScrollHandlers,
|
||||
} from 'react-native-reanimated'
|
||||
import {useWebMediaQueries} from './useWebMediaQueries'
|
||||
|
||||
function clamp(num: number, min: number, max: number) {
|
||||
'worklet'
|
||||
return Math.min(Math.max(num, min), max)
|
||||
const Y_LIMIT = 10
|
||||
|
||||
const useDeviceLimits = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return {
|
||||
dyLimitUp: isDesktop ? 30 : 10,
|
||||
dyLimitDown: isDesktop ? 150 : 10,
|
||||
}
|
||||
}
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
export type OnScrollHandler = ScrollHandlers<any>
|
||||
export type ResetCb = () => void
|
||||
|
||||
export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const mode = useMinimalShellMode()
|
||||
const setMode = useSetMinimalShellMode()
|
||||
const startDragOffset = useSharedValue<number | null>(null)
|
||||
const startMode = useSharedValue<number | null>(null)
|
||||
|
||||
const onBeginDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
},
|
||||
[mode, startDragOffset, startMode],
|
||||
)
|
||||
|
||||
const onEndDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (e.contentOffset.y < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
}
|
||||
},
|
||||
[startDragOffset, startMode, setMode, mode, headerHeight],
|
||||
)
|
||||
|
||||
const onScroll = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
// Keep track of whether we want to show "scroll to top".
|
||||
if (!isScrolledDown && e.contentOffset.y > s.window.height) {
|
||||
runOnJS(setIsScrolledDown)(true)
|
||||
} else if (isScrolledDown && e.contentOffset.y < s.window.height) {
|
||||
runOnJS(setIsScrolledDown)(false)
|
||||
}
|
||||
|
||||
if (startDragOffset.value === null || startMode.value === null) {
|
||||
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
|
||||
// If we're close enough to the top, always show the shell.
|
||||
// Even if we're not dragging.
|
||||
setMode(false)
|
||||
return
|
||||
}
|
||||
if (isWeb) {
|
||||
// On the web, there is no concept of "starting" the drag.
|
||||
// When we get the first scroll event, we consider that the start.
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The "mode" value is always between 0 and 1.
|
||||
// Figure out how much to move it based on the current dragged distance.
|
||||
const dy = e.contentOffset.y - startDragOffset.value
|
||||
const dProgress = interpolate(
|
||||
dy,
|
||||
[-headerHeight.value, headerHeight.value],
|
||||
[-1, 1],
|
||||
)
|
||||
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
||||
if (newValue !== mode.value) {
|
||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||
mode.value = newValue
|
||||
}
|
||||
if (isWeb) {
|
||||
// On the web, there is no concept of "starting" the drag,
|
||||
// so we don't have any specific anchor point to calculate the distance.
|
||||
// Instead, update it continuosly along the way and diff with the last event.
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
}
|
||||
},
|
||||
[headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
|
||||
)
|
||||
|
||||
const scrollHandler: ScrollHandlers<any> = useMemo(
|
||||
() => ({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
}),
|
||||
[onBeginDrag, onEndDrag, onScroll],
|
||||
)
|
||||
export function useOnMainScroll(
|
||||
store: RootStoreModel,
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
|
||||
|
||||
return [
|
||||
scrollHandler,
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y > s.window.height
|
||||
) {
|
||||
setIsScrolledDown(true)
|
||||
} else if (
|
||||
isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y < s.window.height
|
||||
) {
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
setIsScrolledDown(false)
|
||||
setMode(false)
|
||||
}, [setMode]),
|
||||
store.shell.setMinimalShellMode(false)
|
||||
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
|
||||
}, [store, setIsScrolledDown]),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
/**
|
||||
* Requires consuming component to be wrapped in `observer`:
|
||||
* https://stackoverflow.com/a/71488009
|
||||
*/
|
||||
export function useSetTitle(title?: string) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const numUnread = useUnreadNotifications()
|
||||
const {unreadCountLabel} = useStores().me.notifications
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
navigation.setOptions({title: bskyTitle(title, numUnread)})
|
||||
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
|
||||
}
|
||||
}, [title, navigation, numUnread])
|
||||
}, [title, navigation, unreadCountLabel])
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import {useState, useRef, useEffect, useCallback} from 'react'
|
||||
|
||||
type Task<TServerState> = {
|
||||
isOn: boolean
|
||||
resolve: (serverState: TServerState) => void
|
||||
reject: (e: unknown) => void
|
||||
}
|
||||
|
||||
type TaskQueue<TServerState> = {
|
||||
activeTask: Task<TServerState> | null
|
||||
queuedTask: Task<TServerState> | null
|
||||
}
|
||||
|
||||
function AbortError() {
|
||||
const e = new Error()
|
||||
e.name = 'AbortError'
|
||||
return e
|
||||
}
|
||||
|
||||
export function useToggleMutationQueue<TServerState>({
|
||||
initialState,
|
||||
runMutation,
|
||||
onSuccess,
|
||||
}: {
|
||||
initialState: TServerState
|
||||
runMutation: (
|
||||
prevState: TServerState,
|
||||
nextIsOn: boolean,
|
||||
) => Promise<TServerState>
|
||||
onSuccess: (finalState: TServerState) => void
|
||||
}) {
|
||||
// We use the queue as a mutable object.
|
||||
// This is safe becuase it is not used for rendering.
|
||||
const [queue] = useState<TaskQueue<TServerState>>({
|
||||
activeTask: null,
|
||||
queuedTask: null,
|
||||
})
|
||||
|
||||
async function processQueue() {
|
||||
if (queue.activeTask) {
|
||||
// There is another active processQueue call iterating over tasks.
|
||||
// It will handle any newly added tasks, so we should exit early.
|
||||
return
|
||||
}
|
||||
// To avoid relying on the rendered state, capture it once at the start.
|
||||
// From that point on, and until the queue is drained, we'll use the real server state.
|
||||
let confirmedState: TServerState = initialState
|
||||
try {
|
||||
while (queue.queuedTask) {
|
||||
const prevTask = queue.activeTask
|
||||
const nextTask = queue.queuedTask
|
||||
queue.activeTask = nextTask
|
||||
queue.queuedTask = null
|
||||
if (prevTask?.isOn === nextTask.isOn) {
|
||||
// Skip multiple requests to update to the same value in a row.
|
||||
prevTask.reject(new (AbortError as any)())
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// The state received from the server feeds into the next task.
|
||||
// This lets us queue deletions of not-yet-created resources.
|
||||
confirmedState = await runMutation(confirmedState, nextTask.isOn)
|
||||
nextTask.resolve(confirmedState)
|
||||
} catch (e) {
|
||||
nextTask.reject(e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
onSuccess(confirmedState)
|
||||
queue.activeTask = null
|
||||
queue.queuedTask = null
|
||||
}
|
||||
}
|
||||
|
||||
function queueToggle(isOn: boolean): Promise<TServerState> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// This is a toggle, so the next queued value can safely replace the queued one.
|
||||
if (queue.queuedTask) {
|
||||
queue.queuedTask.reject(new (AbortError as any)())
|
||||
}
|
||||
queue.queuedTask = {isOn, resolve, reject}
|
||||
processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
const queueToggleRef = useRef(queueToggle)
|
||||
useEffect(() => {
|
||||
queueToggleRef.current = queueToggle
|
||||
})
|
||||
const queueToggleStable = useCallback(
|
||||
(isOn: boolean): Promise<TServerState> => {
|
||||
const queueToggleLatest = queueToggleRef.current
|
||||
return queueToggleLatest(isOn)
|
||||
},
|
||||
[],
|
||||
)
|
||||
return queueToggleStable
|
||||
}
|
||||
@@ -947,30 +947,3 @@ export function ShieldExclamation({
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1.5}
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
import {LabelValGroup} from './types'
|
||||
|
||||
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'illegal',
|
||||
title: 'Illegal Content',
|
||||
warning: 'Illegal Content',
|
||||
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
|
||||
}
|
||||
|
||||
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-filter',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!filter'],
|
||||
}
|
||||
|
||||
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-warn',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!warn', 'account-security'],
|
||||
}
|
||||
|
||||
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'unknown',
|
||||
title: 'Unknown Label',
|
||||
warning: 'Content Warning',
|
||||
values: [],
|
||||
}
|
||||
|
||||
export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
keyof LabelPreferencesModel,
|
||||
LabelValGroup
|
||||
> = {
|
||||
nsfw: {
|
||||
id: 'nsfw',
|
||||
title: 'Explicit Sexual Images',
|
||||
subtitle: 'i.e. pornography',
|
||||
warning: 'Sexually Explicit',
|
||||
values: ['porn', 'nsfl'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
nudity: {
|
||||
id: 'nudity',
|
||||
title: 'Other Nudity',
|
||||
subtitle: 'Including non-sexual and artistic',
|
||||
warning: 'Nudity',
|
||||
values: ['nudity'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
suggestive: {
|
||||
id: 'suggestive',
|
||||
title: 'Sexually Suggestive',
|
||||
subtitle: 'Does not include nudity',
|
||||
warning: 'Sexually Suggestive',
|
||||
values: ['sexual'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
gore: {
|
||||
id: 'gore',
|
||||
title: 'Violent / Bloody',
|
||||
subtitle: 'Gore, self-harm, torture',
|
||||
warning: 'Violence',
|
||||
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
hate: {
|
||||
id: 'hate',
|
||||
title: 'Hate Group Iconography',
|
||||
subtitle: 'Images of terror groups, articles covering events, etc.',
|
||||
warning: 'Hate Groups',
|
||||
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
|
||||
},
|
||||
spam: {
|
||||
id: 'spam',
|
||||
title: 'Spam',
|
||||
subtitle: 'Excessive unwanted interactions',
|
||||
warning: 'Spam',
|
||||
values: ['spam'],
|
||||
},
|
||||
impersonation: {
|
||||
id: 'impersonation',
|
||||
title: 'Impersonation',
|
||||
subtitle: 'Accounts falsely claiming to be people or orgs',
|
||||
warning: 'Impersonation',
|
||||
values: ['impersonation'],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export type Label = ComAtprotoLabelDefs.Label
|
||||
|
||||
export interface LabelValGroup {
|
||||
id:
|
||||
| keyof LabelPreferencesModel
|
||||
| 'illegal'
|
||||
| 'always-filter'
|
||||
| 'always-warn'
|
||||
| 'unknown'
|
||||
title: string
|
||||
isAdultImagery?: boolean
|
||||
subtitle?: string
|
||||
warning: string
|
||||
values: string[]
|
||||
}
|
||||
+24
-16
@@ -1,10 +1,10 @@
|
||||
import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
// import {match as matchRoute} from 'view/routes'
|
||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
||||
import {ComposerOptsQuote} from 'state/models/ui/shell'
|
||||
|
||||
// TODO
|
||||
// import {Home} from 'view/screens/Home'
|
||||
@@ -22,7 +22,7 @@ import {useGetPost} from '#/state/queries/post'
|
||||
// remove once that's implemented
|
||||
// -prf
|
||||
export async function extractBskyMeta(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<LinkMeta> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
@@ -102,30 +102,38 @@ export async function extractBskyMeta(
|
||||
}
|
||||
|
||||
export async function getPostAsQuote(
|
||||
getPost: ReturnType<typeof useGetPost>,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<ComposerOptsQuote> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
const post = await getPost({uri: uri})
|
||||
const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
const threadView = new PostThreadModel(store, {
|
||||
uri: threadUri,
|
||||
depth: 0,
|
||||
})
|
||||
await threadView.setup()
|
||||
if (!threadView.thread || threadView.notFound) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
return {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
|
||||
indexedAt: post.indexedAt,
|
||||
author: post.author,
|
||||
uri: threadView.thread.post.uri,
|
||||
cid: threadView.thread.post.cid,
|
||||
text: threadView.thread.postRecord?.text || '',
|
||||
indexedAt: threadView.thread.post.indexedAt,
|
||||
author: threadView.thread.post.author,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedAsEmbed(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
|
||||
const res = await agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: feed,
|
||||
@@ -145,13 +153,13 @@ export async function getFeedAsEmbed(
|
||||
}
|
||||
|
||||
export async function getListAsEmbed(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
|
||||
const res = await agent.app.bsky.graph.getList({list})
|
||||
const res = await store.agent.app.bsky.graph.getList({list})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: list,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
|
||||
@@ -23,12 +23,12 @@ export interface LinkMeta {
|
||||
}
|
||||
|
||||
export async function getLinkMeta(
|
||||
agent: BskyAgent,
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
timeout = 5e3,
|
||||
): Promise<LinkMeta> {
|
||||
if (isBskyAppUrl(url)) {
|
||||
return extractBskyMeta(agent, url)
|
||||
return extractBskyMeta(store, url)
|
||||
}
|
||||
|
||||
let urlp
|
||||
@@ -55,9 +55,9 @@ export async function getLinkMeta(
|
||||
const to = setTimeout(() => controller.abort(), timeout || 5e3)
|
||||
|
||||
const response = await fetch(
|
||||
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
|
||||
url,
|
||||
)}`,
|
||||
`${LINK_META_PROXY(
|
||||
store.session.currentSession?.service || '',
|
||||
)}${encodeURIComponent(url)}`,
|
||||
{signal: controller.signal},
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ export function getLikelyType(url: URL | string): LikelyType {
|
||||
}
|
||||
|
||||
const ext = url.pathname.split('.').pop() || ''
|
||||
if (ext === 'html' || ext === 'htm' || ext === 'php') {
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
return LikelyType.HTML
|
||||
}
|
||||
const mimeType = EXT_MIME_TYPES[ext]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
|
||||
export async function openAltTextModal(
|
||||
store: RootStoreModel,
|
||||
image: ImageModel,
|
||||
) {
|
||||
store.shell.openModal({
|
||||
name: 'alt-text-image',
|
||||
image,
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {Image} from 'react-native'
|
||||
import type {Dimensions} from 'lib/media/types'
|
||||
|
||||
const sizes: Map<string, Dimensions> = new Map()
|
||||
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
|
||||
|
||||
export function get(uri: string): Dimensions | undefined {
|
||||
return sizes.get(uri)
|
||||
}
|
||||
|
||||
export async function fetch(uri: string): Promise<Dimensions> {
|
||||
const Dimensions = sizes.get(uri)
|
||||
if (Dimensions) {
|
||||
return Dimensions
|
||||
}
|
||||
|
||||
const prom =
|
||||
activeRequests.get(uri) ||
|
||||
new Promise<Dimensions>(resolve => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width: number, height: number) => resolve({width, height}),
|
||||
(err: any) => {
|
||||
console.error('Failed to fetch image dimensions for', uri, err)
|
||||
resolve({width: 0, height: 0})
|
||||
},
|
||||
)
|
||||
})
|
||||
activeRequests.set(uri, prom)
|
||||
const res = await prom
|
||||
activeRequests.delete(uri)
|
||||
sizes.set(uri, res)
|
||||
return res
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
@@ -21,15 +22,18 @@ async function getFile() {
|
||||
})
|
||||
}
|
||||
|
||||
export async function openPicker(): Promise<RNImage[]> {
|
||||
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
|
||||
return [await getFile()]
|
||||
}
|
||||
|
||||
export async function openCamera(): Promise<RNImage> {
|
||||
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
|
||||
return await getFile()
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
return {
|
||||
path: opts.path,
|
||||
mime: 'image/jpeg',
|
||||
|
||||
@@ -3,10 +3,23 @@ import {
|
||||
openCropper as openCropperFn,
|
||||
Image as RNImage,
|
||||
} from 'react-native-image-crop-picker'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
export {openPicker} from './picker.shared'
|
||||
|
||||
export async function openCamera(opts: CameraOpts): Promise<RNImage> {
|
||||
/**
|
||||
* NOTE
|
||||
* These methods all include the RootStoreModel as the first param
|
||||
* because the web versions require it. The signatures have to remain
|
||||
* equivalent between the different forms, but the store param is not
|
||||
* used here.
|
||||
* -prf
|
||||
*/
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
): Promise<RNImage> {
|
||||
const item = await openCameraFn({
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
@@ -26,7 +39,10 @@ export async function openCamera(opts: CameraOpts): Promise<RNImage> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions) {
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
) {
|
||||
const item = await openCropperFn({
|
||||
...opts,
|
||||
forceJpg: true, // ios only
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
export {openPicker} from './picker.shared'
|
||||
import {unstable__openModal} from '#/state/modals'
|
||||
|
||||
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
_opts: CameraOpts,
|
||||
): Promise<RNImage> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
export async function openCropper(
|
||||
store: RootStoreModel,
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
// TODO handle more opts
|
||||
return new Promise((resolve, reject) => {
|
||||
unstable__openModal({
|
||||
store.shell.openModal({
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
onSelect: (img?: RNImage) => {
|
||||
|
||||
+3
-12
@@ -17,18 +17,9 @@ export function describeModerationCause(
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocking') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
name: `User Blocked by "${cause.source.list.name}"`,
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'User Blocked',
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
return {
|
||||
name: 'User Blocked',
|
||||
description: 'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocked-by') {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
import {RootStoreModel} from '../../state'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {listenSessionLoaded} from '#/state/events'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
? 'did:web:api.staging.bsky.dev'
|
||||
: 'did:web:api.bsky.app'
|
||||
|
||||
export function init(queryClient: QueryClient) {
|
||||
listenSessionLoaded(async (account, agent) => {
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
|
||||
|
||||
store.onSessionLoaded(async () => {
|
||||
// request notifications permission once the user has logged in
|
||||
const perms = await Notifications.getPermissionsAsync()
|
||||
if (!perms.granted) {
|
||||
@@ -24,51 +23,39 @@ export function init(queryClient: QueryClient) {
|
||||
const token = await getPushToken()
|
||||
if (token) {
|
||||
try {
|
||||
await agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(account.service),
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
token: token.data,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
logger.debug(
|
||||
'Notifications: Sent push token (init)',
|
||||
{
|
||||
tokenType: token.type,
|
||||
token: token.data,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Sent push token (init)', {
|
||||
type: token.type,
|
||||
token: token.data,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Notifications: Failed to set push token', {error})
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
|
||||
// listens for new changes to the push token
|
||||
// In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
|
||||
Notifications.addPushTokenListener(async ({data: t, type}) => {
|
||||
logger.debug(
|
||||
'Notifications: Push token changed',
|
||||
{t, tokenType: type},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Push token changed', {t, type})
|
||||
if (t) {
|
||||
try {
|
||||
await agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(account.service),
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
token: t,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
logger.debug(
|
||||
'Notifications: Sent push token (event)',
|
||||
{
|
||||
tokenType: type,
|
||||
token: t,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: Sent push token (event)', {
|
||||
type,
|
||||
token: t,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Notifications: Failed to set push token', {error})
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -76,14 +63,10 @@ export function init(queryClient: QueryClient) {
|
||||
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
Notifications.addNotificationReceivedListener(event => {
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{event},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: received', event)
|
||||
if (event.request.trigger.type === 'push') {
|
||||
// refresh notifications in the background
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
store.me.notifications.syncQueue()
|
||||
// handle payload-based deeplinks
|
||||
let payload
|
||||
if (isIOS) {
|
||||
@@ -92,11 +75,7 @@ export function init(queryClient: QueryClient) {
|
||||
// TODO: handle android payload deeplink
|
||||
}
|
||||
if (payload) {
|
||||
logger.debug(
|
||||
'Notifications: received payload',
|
||||
payload,
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
store.log.debug('Notifications: received payload', payload)
|
||||
// TODO: deeplink notif here
|
||||
}
|
||||
}
|
||||
@@ -105,23 +84,18 @@ export function init(queryClient: QueryClient) {
|
||||
// handle notifications that are tapped on
|
||||
const sub = Notifications.addNotificationResponseReceivedListener(
|
||||
response => {
|
||||
logger.debug(
|
||||
store.log.debug(
|
||||
'Notifications: response received',
|
||||
{
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
response.actionIdentifier,
|
||||
)
|
||||
if (
|
||||
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
|
||||
) {
|
||||
logger.debug(
|
||||
store.log.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
store.me.notifications.refresh() // refresh notifications
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
|
||||
+1
-11
@@ -1,13 +1,3 @@
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we NEVER want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export const queryClient = new QueryClient()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {BackHandler} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
// only register back handler on android, otherwise it throws an error
|
||||
if (isAndroid) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
backHandler.remove()
|
||||
}
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
@@ -13,15 +13,3 @@ export function makeProfileLink(
|
||||
...segments,
|
||||
].join('/')
|
||||
}
|
||||
|
||||
export function makeCustomFeedLink(
|
||||
did: string,
|
||||
rkey: string,
|
||||
...segments: string[]
|
||||
) {
|
||||
return [`/profile`, did, 'feed', rkey, ...segments].join('/')
|
||||
}
|
||||
|
||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
export type CommonNavigatorParams = {
|
||||
NotFound: undefined
|
||||
Lists: undefined
|
||||
Moderation: undefined
|
||||
ModerationModlists: undefined
|
||||
ModerationMuteLists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
Settings: undefined
|
||||
@@ -19,8 +18,8 @@ export type CommonNavigatorParams = {
|
||||
PostThread: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
ProfileFeed: {name: string; rkey: string}
|
||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||
CustomFeed: {name: string; rkey: string}
|
||||
CustomFeedLikedBy: {name: string; rkey: string}
|
||||
Debug: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
|
||||
+46
-2
@@ -1,8 +1,52 @@
|
||||
import {init} from 'sentry-expo'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {FC} from 'react'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
|
||||
init({
|
||||
// Sentry Initialization
|
||||
|
||||
export const getRoutingInstrumentation = () => {
|
||||
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
|
||||
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
|
||||
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
|
||||
environment: __DEV__ ? 'development' : 'production', // Set the environment
|
||||
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
|
||||
enableAutoPerformanceTracking: true, // Enable auto performance tracking
|
||||
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
|
||||
_experiments: {
|
||||
// The sampling rate for profiling is relative to TracesSampleRate.
|
||||
// In this case, we'll capture profiles for 50% of transactions.
|
||||
profilesSampleRate: 0.5,
|
||||
},
|
||||
integrations: isNative
|
||||
? [
|
||||
new Sentry.Native.ReactNativeTracing({
|
||||
shouldCreateSpanForRequest: url => {
|
||||
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
|
||||
return !url.match(/\/logs$/)
|
||||
},
|
||||
routingInstrumentation: getRoutingInstrumentation(),
|
||||
}),
|
||||
]
|
||||
: [], // no integrations for web, yet
|
||||
})
|
||||
|
||||
// if web, use Browser client, otherwise use Native client
|
||||
export function getSentryClient() {
|
||||
if (isWeb) {
|
||||
return Sentry.Browser
|
||||
}
|
||||
return Sentry.Native
|
||||
}
|
||||
|
||||
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
|
||||
export function withSentry(Component: FC) {
|
||||
if (isWeb) {
|
||||
return Component // .wrap is not required or available for web
|
||||
}
|
||||
const sentryClient = getSentryClient()
|
||||
return sentryClient.wrap(Component)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function bskyTitle(page: string, unreadCountLabel?: string) {
|
||||
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
|
||||
return `${unreadPrefix}${page} — Bluesky`
|
||||
return `${unreadPrefix}${page} - Bluesky`
|
||||
}
|
||||
|
||||
@@ -32,8 +32,3 @@ export function toHashCode(str: string, seed = 0): number {
|
||||
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
export function countLines(str: string | undefined): number {
|
||||
if (!str) return 0
|
||||
return str.match(/\n/g)?.length ?? 0
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {PROD_SERVICE} from 'lib/constants'
|
||||
import {PROD_SERVICE} from 'state/index'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
@@ -170,31 +170,14 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
||||
|
||||
export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
let urip
|
||||
try {
|
||||
urip = new URL(uri)
|
||||
} catch {
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
|
||||
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 (
|
||||
labelDomain &&
|
||||
labelDomain !== 'bsky.app' &&
|
||||
isPossiblyAUrl(labelDomain)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// if this is a link to external content,
|
||||
// warn if the label doesnt match the target
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const urip = new URL(uri)
|
||||
return labelDomain !== urip.hostname
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ export const s = StyleSheet.create({
|
||||
flexRow: {flexDirection: 'row'},
|
||||
flexCol: {flexDirection: 'column'},
|
||||
flex1: {flex: 1},
|
||||
flexGrow1: {flexGrow: 1},
|
||||
alignCenter: {alignItems: 'center'},
|
||||
alignBaseline: {alignItems: 'baseline'},
|
||||
|
||||
|
||||
+2
-2
@@ -264,8 +264,8 @@ export const defaultTheme: Theme = {
|
||||
fontWeight: '400',
|
||||
},
|
||||
'post-text-lg': {
|
||||
fontSize: 20,
|
||||
letterSpacing: 0.2,
|
||||
fontSize: 22,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: '400',
|
||||
},
|
||||
'button-lg': {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
export const locales = {
|
||||
en: 'English',
|
||||
cs: 'Česky',
|
||||
fr: 'Français',
|
||||
hi: 'हिंदी',
|
||||
es: 'Español',
|
||||
}
|
||||
export const defaultLocale = 'en'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
* @param locale any locale string
|
||||
*/
|
||||
export async function dynamicActivate(locale: string) {
|
||||
const {messages} = await import(`./locales/${locale}/messages`)
|
||||
i18n.load(locale, messages)
|
||||
i18n.activate(locale)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user