Compare commits

..

4 Commits

Author SHA1 Message Date
Paul Frazee 2009d04de7 Move most responsive queries to the hook 2023-08-31 13:37:29 -07:00
Ansh Nanda 0ce21f082c Merge branch 'main' into 1338-mark-notifs-read-with-timeout 2023-08-30 17:03:33 -07:00
Ansh Nanda 47cc3fe844 fix type 2023-08-30 16:57:04 -07:00
Ansh Nanda 1ca1fe3f72 mark notifications read with timeout and on app state change 2023-08-30 16:50:01 -07:00
499 changed files with 25564 additions and 40679 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15 Pro',
type: 'iPhone 14',
},
},
attached: {
-2
View File
@@ -1,3 +1 @@
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+10 -9
View File
@@ -2,16 +2,11 @@ module.exports = {
root: true,
extends: [
'@react-native-community',
'plugin:react/recommended',
'plugin:react-native-a11y/ios',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'detox', 'react'],
rules: {
'react/no-unescaped-entities': 0,
'react-native/no-inline-styles': 0,
},
plugins: ['@typescript-eslint', 'detox'],
ignorePatterns: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
@@ -26,7 +21,13 @@ module.exports = {
'*.html',
'bskyweb',
],
settings: {
componentWrapperFunctions: ['observer'],
},
overrides: [
{
files: ['*.js', '*.mjs', '*.ts', '*.tsx'],
rules: {
semi: [2, 'never'],
'react-native/no-inline-styles': 0,
},
},
],
}
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v3
- name: Yarn install
run: yarn --frozen-lockfile
run: yarn
- name: Lint check
run: yarn lint
- name: Type check
@@ -33,7 +33,7 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v3
- name: Yarn install
run: yarn --frozen-lockfile
run: yarn
- name: Run tests
run: |
yarn test --forceExit
+1 -4
View File
@@ -99,7 +99,4 @@ ios/
.env.*
# Firebase (Android) Google services
google-services.json
# Performance results (Flashlight)
.perf/
google-services.json
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 2023 Bluesky PBC
Copyright 2023 Bluesky PBLLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+9 -18
View File
@@ -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:**
-77
View File
@@ -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"
+1 -80
View File
@@ -55,7 +55,7 @@ async function main() {
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
await server.mocker.createFeed('alice')
}
if ('thread' in url.query) {
console.log('Generating mock posts')
@@ -70,82 +70,6 @@ async function main() {
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(
await server.mocker.createPost(user, `Post ${i}`),
)
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
@@ -502,9 +426,6 @@ async function main() {
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
+4 -3
View File
@@ -1,17 +1,18 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {openApp, login, createServer, sleep} from '../util'
describe('Composer', () => {
let service: string
beforeAll(async () => {
await createServer('?users')
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
+1 -5
View File
@@ -25,11 +25,7 @@ describe('Create account', () => {
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await expect(element(by.id('welcomeScreen'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
-208
View File
@@ -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()
})
})
+4 -3
View File
@@ -1,15 +1,16 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('Home screen', () => {
let service: string
beforeAll(async () => {
await createServer('?users&follows&posts')
service = await createServer('?users&follows&posts')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
+16 -20
View File
@@ -1,11 +1,6 @@
/* eslint-env detox/detox */
/**
* This test is being skipped until we can resolve the detox crash issue
* with the side drawer.
*/
import {openApp, loginAsAlice, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('invite-codes', () => {
let service: string
@@ -17,13 +12,17 @@ describe('invite-codes', () => {
it('I can fetch invite codes', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await login(service, 'alice', 'hunter2')
await element(by.id('viewHeaderDrawerBtn')).tap()
await expect(element(by.id('drawer'))).toBeVisible()
await element(by.id('menuItemInviteCodes')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
inviteCode = attrs.text
await element(by.id('closeBtn')).tap()
await element(by.id('e2eSignOut')).tap()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('I can create a new account with the invite code', async () => {
@@ -43,22 +42,19 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await expect(element(by.id('welcomeScreen'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('I get a notification for the new user', async () => {
await element(by.id('e2eSignOut')).tap()
await loginAsAlice()
await waitFor(element(by.id('homeScreen')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('bottomBarNotificationsBtn')).tap()
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2')
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Notifications')).tap()
await expect(element(by.id('invitedUser'))).toBeVisible()
})
-157
View File
@@ -1,157 +0,0 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
describe('Mergefeed', () => {
beforeAll(async () => {
await createServer('?mergefeed')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await loginAsAlice()
await element(by.id('e2eToggleMergefeed')).tap()
})
it('Sees the expected mix of posts with default filters', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-carla.test')),
).atIndex(0),
).toHaveText('Post 9')
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-bob.test')),
).atIndex(0),
).toHaveText('Post 9')
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'up',
'fast',
1,
0.5,
0.5,
)
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
it('Sees the expected mix of posts with replies disabled', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
await element(by.id('viewHeaderHomeFeedPrefsBtn')).tap()
await element(by.id('toggleRepliesBtn')).tap()
await element(by.id('confirmBtn')).tap()
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-carla.test')),
).atIndex(0),
).toHaveText('Post 9')
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-bob.test')),
).atIndex(0),
).toHaveText('Post 9')
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'up',
'fast',
1,
0.5,
0.5,
)
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
it('Sees the expected mix of posts with no follows', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
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('unfollowBtn')).tap()
await element(by.id('profileHeaderBackBtn')).tap()
// have to wait for the toast to clear
await waitFor(element(by.id('searchTextInputClearBtn')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('searchTextInputClearBtn')).tap()
await element(by.id('searchTextInput')).typeText('carla')
await element(by.id('searchAutoCompleteResult-carla.test')).tap()
await expect(element(by.id('profileView'))).toBeVisible()
await element(by.id('unfollowBtn')).tap()
await element(by.id('profileHeaderBackBtn')).tap()
await element(by.id('bottomBarHomeBtn')).tap()
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users NOT present
await expect(element(by.id('feedItem-by-carla.test'))).not.toExist()
await expect(element(by.id('feedItem-by-bob.test'))).not.toExist()
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
})
-187
View File
@@ -1,187 +0,0 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mod lists', () => {
beforeAll(async () => {
await createServer('?users&follows&labels')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login and view my modlists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eGotoModeration')).tap()
await element(by.id('moderationlistsBtn')).tap()
await expect(element(by.id('list-Muted Users'))).toBeVisible()
await element(by.id('list-Muted Users')).tap()
await expect(
element(by.id('user-muted-by-list-account.test')),
).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('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()
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 modlist 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 modlist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await 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 modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await 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 modlist', 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 modlist', async () => {
await element(by.id('newModListBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).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('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')))
.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 () => {
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 () => {
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-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Can report a mute 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-Bad 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()
})
})
+172
View File
@@ -0,0 +1,172 @@
/* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util'
describe('Mute lists', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users&follows&labels')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login and view my mutelists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2')
await element(by.id('viewHeaderDrawerBtn')).tap()
await expect(element(by.id('drawer'))).toBeVisible()
await element(by.id('menuItemButton-Moderation')).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(
element(by.id('user-muted-by-list-account.test')),
).toBeVisible()
})
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 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('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('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
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('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('listDescription'))).not.toBeVisible()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Set avi via the edit mutelist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
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('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarImage'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Remove avi via the edit mutelist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
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('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Delete the mutelist', async () => {
await element(by.id('deleteListBtn')).tap()
await element(by.id('confirmBtn')).tap()
await expect(element(by.id('emptyMuteLists'))).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('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('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
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 mutelists', 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('listAddRemoveUserModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
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('listAddRemoveUserModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
})
it('Can report a mute list', async () => {
await element(by.id('bottomBarHomeBtn')).tap()
// Last test leaves us in the list view so we are going back 1 screen to the lists list screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then to the moderation screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then to the home screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then open the drawer to go to settings
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'bob.test', 'hunter2')
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-Bad Ppl')).tap()
await element(by.id('reportListBtn')).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()
})
})
+5 -4
View File
@@ -1,10 +1,11 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {openApp, login, createServer, sleep} from '../util'
describe('Profile screen', () => {
let service: string
beforeAll(async () => {
await createServer('?users&posts&feeds')
service = await createServer('?users&posts&feeds')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
@@ -12,14 +13,14 @@ describe('Profile screen', () => {
it('Login and navigate to my profile', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('bottomBarProfileBtn')).tap()
})
it('Can see feeds', async () => {
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await expect(element(by.id('feed-alice-favs'))).toBeVisible()
await expect(element(by.id('feed-alices feed'))).toBeVisible()
await element(by.id('selector')).swipe('right')
await element(by.id('selector-0')).tap()
})
+4 -3
View File
@@ -1,17 +1,18 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('Search screen', () => {
let service: string
beforeAll(async () => {
await createServer('?users')
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
})
it('Navigate to another user profile via autocomplete', async () => {
+4 -3
View File
@@ -1,17 +1,18 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {openApp, login, createServer, sleep} from '../util'
describe('Self-labeling', () => {
let service: string
beforeAll(async () => {
await createServer('?users')
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
@@ -1,15 +1,16 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('Shell', () => {
let service: string
beforeAll(async () => {
await createServer('?users')
service = await createServer('?users')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
+23 -8
View File
@@ -1,34 +1,42 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('Thread muting', () => {
let service: string
beforeAll(async () => {
await createServer('?users&follows')
service = await createServer('?users&follows')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login, create a thread, and log out', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText('Test thread')
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('Login, reply to the thread, and log out', async () => {
await loginAsBob()
await login(service, 'bob', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
const alicePosts = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply 1')
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('Login, confirm notification exists, mute thread, and log out', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('bottomBarNotificationsBtn')).tap()
const bobNotifs = by.id('feedItem-by-bob.test')
await expect(
@@ -42,10 +50,14 @@ describe('Thread muting', () => {
await waitFor(element(by.id('viewHeaderDrawerBtn')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('Login, reply to the thread twice, and log out', async () => {
await loginAsBob()
await login(service, 'bob', 'hunter2')
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('selector-1')).tap()
@@ -62,10 +74,13 @@ describe('Thread muting', () => {
await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('bottomBarHomeBtn')).tap()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
})
it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('bottomBarNotificationsBtn')).tap()
const bobNotifs = by.id('feedItem-by-bob.test')
@@ -78,7 +93,7 @@ describe('Thread muting', () => {
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0)
.tap()
await element(by.text('Unmute thread')).tap()
await element(by.text('Mute thread')).tap()
// TODO
// the swipe down to trigger PTR isnt working and I dont want to block on this
+4 -3
View File
@@ -1,15 +1,16 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
import {openApp, login, createServer} from '../util'
describe('Thread screen', () => {
let service: string
beforeAll(async () => {
await createServer('?users&follows&thread')
service = await createServer('?users&follows&thread')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login & navigate to thread', async () => {
await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
await element(by.id('feedItem-by-bob.test')).atIndex(0).tap()
await expect(
-8
View File
@@ -69,14 +69,6 @@ export async function login(
await element(by.id('loginNextButton')).tap()
}
export async function loginAsAlice() {
await element(by.id('e2eSignInAlice')).tap()
}
export async function loginAsBob() {
await element(by.id('e2eSignInBob')).tap()
}
async function openAppForDebugBuild(platform: string, opts: any) {
const deepLinkUrl = // Local testing with packager
/*process.env.EXPO_USE_UPDATES
-3
View File
@@ -1,3 +0,0 @@
jest.mock('sentry-expo', () => ({
init: () => jest.fn(),
}))
+1 -3
View File
@@ -175,7 +175,6 @@ describe('ago', () => {
1671461038,
'04 Dec 1995 00:12:00 GMT',
new Date(),
new Date().setSeconds(new Date().getSeconds() - 10),
new Date().setMinutes(new Date().getMinutes() - 10),
new Date().setHours(new Date().getHours() - 1),
new Date().setDate(new Date().getDate() - 1),
@@ -184,8 +183,7 @@ describe('ago', () => {
const outputs = [
new Date(1671461038).toLocaleDateString(),
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
'now',
'10s',
'0s',
'10m',
'1h',
'1d',
-134
View File
@@ -1,134 +0,0 @@
import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
['http://example.com', 'example.com/page', false],
['http://example.com', '', true],
['http://example.com', 'other.com', true],
['http://example.com', 'http://other.com', true],
['http://example.com', 'some label', true],
['http://example.com', 'example.com more', true],
['http://example.com', 'http://example.co', true],
['http://example.co', 'http://example.com', true],
['http://example.com', 'example.co', true],
['http://example.co', 'example.com', true],
['http://site.pages.dev', 'http://site.page', true],
['http://site.page', 'http://site.pages.dev', true],
['http://site.pages.dev', 'site.page', true],
['http://site.page', 'site.pages.dev', true],
['http://site.pages.dev', 'http://site.pages', true],
['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],
['example.com', 'example.com', true],
]
it.each(cases)(
'given input uri %p and text %p, returns %p',
(uri, text, expected) => {
const output = linkRequiresWarning(uri, text)
expect(output).toEqual(expected)
},
)
})
describe('isPossiblyAUrl', () => {
type Case = [string, boolean]
const cases: Case[] = [
['', false],
['text', false],
['some text', false],
['some text', false],
['some domain.com', false],
['domain.com', true],
[' domain.com', true],
['domain.com ', true],
[' domain.com ', true],
['http://domain.com', true],
[' http://domain.com', true],
['http://domain.com ', true],
[' http://domain.com ', true],
['https://domain.com', true],
[' https://domain.com', true],
['https://domain.com ', true],
[' https://domain.com ', true],
['http://domain.com/foo', true],
['http://domain.com stuff', true],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
const output = isPossiblyAUrl(str)
expect(output).toEqual(expected)
})
})
describe('splitApexDomain', () => {
type Case = [string, string, string]
const cases: Case[] = [
['', '', ''],
['example.com', '', 'example.com'],
['foo.example.com', 'foo.', 'example.com'],
['foo.bar.example.com', 'foo.bar.', 'example.com'],
['example.co.uk', '', 'example.co.uk'],
['foo.example.co.uk', 'foo.', 'example.co.uk'],
['example.nonsense', '', 'example.nonsense'],
['foo.example.nonsense', '', 'foo.example.nonsense'],
['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'],
['example.com.example.com', 'example.com.', 'example.com'],
]
it.each(cases)(
'given input uri %p, returns %p,%p',
(str, expected1, expected2) => {
const output = splitApexDomain(str)
expect(output[0]).toEqual(expected1)
expect(output[1]).toEqual(expected2)
},
)
})
-114
View File
@@ -1,114 +0,0 @@
module.exports = function () {
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
return {
expo: {
name: 'Bluesky',
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
splash: {
image: './assets/cloud-splash.png',
resizeMode: 'cover',
backgroundColor: '#ffffff',
},
ios: {
buildNumber: '3',
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
usesNonExemptEncryption: false,
},
infoPlist: {
UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
'Used for posts and other kinds of content.',
NSPhotoLibraryAddUsageDescription:
'Used to save images to your library.',
NSPhotoLibraryUsageDescription:
'Used for profile pictures, posts, and other kinds of content',
},
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
},
androidStatusBar: {
barStyle: 'dark-content',
backgroundColor: '#ffffff',
},
android: {
versionCode: 46,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
},
googleServicesFile: './google-services.json',
package: 'xyz.blueskyweb.app',
intentFilters: [
{
action: 'VIEW',
autoVerify: true,
data: [
{
scheme: 'https',
host: 'bsky.app',
},
],
category: ['BROWSABLE', 'DEFAULT'],
},
],
},
web: {
favicon: './assets/favicon.png',
},
updates: {
enabled: true,
fallbackToCacheTimeout: 1000,
url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302',
},
plugins: [
'expo-localization',
hasSentryToken && 'sentry-expo',
[
'expo-build-properties',
{
android: {
compileSdkVersion: 34,
targetSdkVersion: 34,
buildToolsVersion: '34.0.0',
kotlinVersion: '1.8.0',
},
},
],
[
'expo-updates',
{
username: 'blueskysocial',
},
],
].filter(Boolean),
extra: {
eas: {
projectId: '55bd077a-d905-4184-9c7f-94789ba0f302',
},
},
hooks: {
postPublish: [
{
file: 'sentry-expo/upload-sourcemaps',
config: {
organization: 'blueskyweb',
project: 'react-native',
},
},
],
},
},
}
}
+109
View File
@@ -0,0 +1,109 @@
{
"expo": {
"name": "Bluesky",
"slug": "bluesky",
"scheme": "bluesky",
"owner": "blueskysocial",
"version": "1.48.0",
"runtimeVersion": {
"policy": "appVersion"
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/cloud-splash.png",
"resizeMode": "cover",
"backgroundColor": "#ffffff"
},
"ios": {
"buildNumber": "1",
"supportsTablet": false,
"bundleIdentifier": "xyz.blueskyweb.app",
"config": {
"usesNonExemptEncryption": false
},
"infoPlist": {
"UIBackgroundModes": [
"remote-notification"
],
"NSCameraUsageDescription": "Used for profile pictures, posts, and other kinds of content.",
"NSMicrophoneUsageDescription": "Used for posts and other kinds of content.",
"NSPhotoLibraryAddUsageDescription": "Used to save images to your library.",
"NSPhotoLibraryUsageDescription": "Used for profile pictures, posts, and other kinds of content"
},
"associatedDomains": ["applinks:bsky.app", "applinks:staging.bsky.app"]
},
"androidStatusBar": {
"barStyle": "dark-content",
"backgroundColor": "#ffffff"
},
"android": {
"versionCode": 35,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"googleServicesFile": "./google-services.json",
"package": "xyz.blueskyweb.app",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "bsky.app"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"web": {
"favicon": "./assets/favicon.png"
},
"updates": {
"enabled": true,
"fallbackToCacheTimeout": 1000,
"url": "https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302"
},
"plugins": [
"expo-localization",
"sentry-expo",
[
"expo-build-properties",
{
"android": {
"compileSdkVersion": 34,
"targetSdkVersion": 34,
"buildToolsVersion": "34.0.0",
"kotlinVersion": "1.8.0"
}
}
],
[
"expo-updates",
{
"username": "blueskysocial"
}
]
],
"extra": {
"eas": {
"projectId": "55bd077a-d905-4184-9c7f-94789ba0f302"
}
},
"hooks": {
"postPublish": [
{
"file": "sentry-expo/upload-sourcemaps",
"config": {
"organization": "blueskyweb",
"project": "react-native"
}
}
]
}
}
}
+1 -24
View File
@@ -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'],
},
},
}
}
+4 -26
View File
@@ -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.
@@ -179,19 +159,17 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/search", server.WebGeneric)
e.GET("/search/feeds", 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)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/home-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
+2 -5
View File
@@ -91,9 +91,6 @@
*[data-word-wrap] {
word-break: break-word;
}
*[data-stable-gutters] {
scrollbar-gutter: stable both-edges;
}
/* ProseMirror */
.ProseMirror {
@@ -176,8 +173,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 %}
+22 -27
View File
@@ -2,25 +2,23 @@
## 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
- brew install applesimutils
- After initial setup:
- Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required)
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes
- Start the dev servers
- `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto`
- `pnpm i`
- `pnpm build`
- `cd packages/dev-env && pnpm start`
- `yarn`
- `cd packages/dev-env && yarn start`
- Run the dev app
- 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,22 +26,20 @@
- 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 you copy the `.env.example` to `.env` and add the appropriate tokens (e.g. `SENTRY_AUTH_TOKEN` can be created on the Sentry dashboard using [these instructions](https://docs.expo.dev/guides/using-sentry/#sign-up-for-a-sentry-account-and-create-a-project)). If this is not required, you can remove it from `eas.json` and `package.json`, as well as any mentions in the code. Please check the section below on how to remove Sentry from the codebase
- 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}`
- 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
Adding Sentry is NOT required. You can keep `SENTRY_AUTH_TOKEN=` in `.env` which will build the app without Sentry.
However, if you're a part of the Bluesky team and want to enable Sentry, fill in `SENTRY_AUTH_TOKEN` in your `.env`. It can be created on the Sentry dashboard using [these instructions](https://docs.expo.dev/guides/using-sentry/#sign-up-for-a-sentry-account-and-create-a-project).
If you change `SENTRY_AUTH_TOKEN`, you need to do `yarn prebuild` before running `yarn ios` or `yarn android` again.
### Removing Sentry
If you are part of the Bluesky team, you should have access to our Sentry dashboard, and you shouldn't need to remove Sentry. Even if you are not part of the Bluesky team, you can create your own Sentry account and add the `SENTRY_AUTH_TOKEN` env var and add your sentry account detials to `app.json` to make the app build and run successfully. However, if that is not possible, follow these steps to remove Sentry from the project (please don't commit this code in any PR):
- `yarn remove sentry-expo @sentry/react-native`
- Remove `sentry-expo` plugin in `app.json` and also remove the `postPublish` hook in `app.json`
- Remove any mentions of `sentry` from the `App.native.tsx`, `App.web.tsx` and `Navigation.tsx` files. Also, delete `sentry.ts`
- Run `rm -rf ios android` or delete the existing `android` and `ios` folders in the project (don't worry! `yarn prebuild` gets these back)
- Run `yarn prebuild` and `yarn ios` and build the app!
## Go-Server Build
@@ -81,24 +77,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).
@@ -121,7 +117,6 @@ upload-sourcemaps \
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/)
-113
View File
@@ -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>;
}
```
-14
View File
@@ -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`.
-7
View File
@@ -33,13 +33,6 @@
"resourceClass": "m-large"
},
"channel": "production"
},
"dev-android-apk": {
"developmentClient": true,
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
-41
View File
@@ -1,41 +0,0 @@
{
"project_info": {
"project_id": "blueskyweb-example",
"project_number": "100000000000",
"firebase_url": "https://blueskyweb-example.firebaseio.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063",
"android_client_info": {
"package_name": "xyz.blueskyweb.app"
}
},
"oauth_client": [
{
"client_id": "123456789000.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "123456789000"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
}
],
"configuration_version": "1"
}
-92
View File
@@ -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}
}
-49
View File
@@ -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:
-9
View File
@@ -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 "$@"
-10
View File
@@ -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 "$@"
-11
View File
@@ -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(),
},
}))
+69 -121
View File
@@ -1,7 +1,7 @@
import net from 'net'
import path from 'path'
import fs from 'fs'
import {TestNetwork} from '@atproto/dev-env'
import {TestPds as DevEnvTestPDS, 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 {pds, plc} = await TestNetworkNoAppView.create({
pds: {port, publicUrl: pdsUrl, inviteRequired},
plc: {port: port2},
})
@@ -80,10 +35,10 @@ export async function createServer(
return {
pdsUrl,
mocker: new Mocker(testNet, pdsUrl, pic),
mocker: new Mocker(pds, pdsUrl, pic),
async close() {
await testNet.pds.server.destroy()
await testNet.plc.server.destroy()
await pds.server.destroy()
await plc.server.destroy()
},
}
}
@@ -93,25 +48,13 @@ class Mocker {
users: Record<string, TestUser> = {}
constructor(
public testNet: TestNetwork,
public pds: DevEnvTestPDS,
public service: string,
public pic: Uint8Array,
) {
this.agent = new BskyAgent({service})
}
get pds() {
return this.testNet.pds
}
get bsky() {
return this.testNet.bsky
}
get plc() {
return this.testNet.plc
}
// NOTE
// deterministic date generator
// we use this to ensure the mock dataset is always the same
@@ -130,7 +73,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',
},
)
@@ -265,34 +212,24 @@ class Mocker {
return await agent.like(uri, cid)
}
async createFeed(user: string, rkey: string, posts: string[]) {
async createFeed(user: string) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
const fgUri = AtUri.make(
const fg1Uri = AtUri.make(
this.users[user].did,
'app.bsky.feed.generator',
rkey,
'alice-favs',
)
const fg1 = await this.testNet.createFeedGen({
[fgUri.toString()]: async () => {
return {
encoding: 'application/json',
body: {
feed: posts.slice(0, 30).map(uri => ({post: uri})),
},
}
},
})
const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
encoding: 'image/png',
})
return await agent.api.app.bsky.feed.generator.create(
{repo: this.users[user].did, rkey},
{repo: this.users[user].did, rkey: fg1Uri.rkey},
{
did: fg1.did,
displayName: rkey,
did: 'did:web:fake.com',
displayName: 'alices feed',
description: 'all my fav stuff',
avatar: avatarRes.data.blob,
createdAt: new Date().toISOString(),
@@ -305,7 +242,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 +257,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 +289,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> {
-11
View File
@@ -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',
}
-18
View File
@@ -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
+50 -79
View File
@@ -1,59 +1,48 @@
{
"name": "bsky.app",
"version": "1.56.0",
"version": "1.48.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
"postinstall": "patch-package",
"prebuild": "expo prebuild --clean",
"prebuild": "expo prebuild",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
"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"
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
},
"dependencies": {
"@atproto/api": "^0.6.23",
"@atproto/api": "^0.6.8",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0",
"@expo/webpack-config": "^18.0.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@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",
"@react-native-async-storage/async-storage": "^1.17.6",
"@react-native-camera-roll/camera-roll": "^5.2.2",
"@react-native-clipboard/clipboard": "^1.10.0",
"@react-native-community/blur": "^4.3.0",
"@react-native-community/datetimepicker": "7.2.0",
"@react-native-community/datetimepicker": "6.7.3",
"@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.4.10",
"@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
@@ -62,8 +51,7 @@
"@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": "4.13.0",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
@@ -72,40 +60,37 @@
"@tiptap/extension-paragraph": "^2.0.0-beta.220",
"@tiptap/extension-placeholder": "^2.0.0-beta.220",
"@tiptap/extension-text": "^2.0.0-beta.220",
"@tiptap/html": "^2.1.11",
"@tiptap/pm": "^2.0.0-beta.220",
"@tiptap/react": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.0-beta.220",
"@types/node": "^18.16.2",
"@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",
"expo": "^49.0.8",
"expo-application": "~5.3.0",
"expo-build-properties": "~0.8.3",
"expo-camera": "13.5.1",
"expo-constants": "~14.4.2",
"expo-dev-client": "2.4.7",
"expo-device": "~5.4.0",
"expo-image": "~1.3.2",
"expo-image-manipulator": "~11.5.0",
"expo-image-picker": "~14.5.0",
"expo-localization": "~14.3.0",
"expo-media-library": "~15.4.1",
"expo-notifications": "~0.20.1",
"expo-sharing": "~11.5.0",
"expo-splash-screen": "~0.20.5",
"expo-status-bar": "~1.6.0",
"expo-system-ui": "~2.4.0",
"expo-updates": "~0.18.12",
"expo": "~48.0.18",
"expo-application": "~5.1.1",
"expo-build-properties": "~0.5.1",
"expo-camera": "~13.2.1",
"expo-constants": "~14.2.1",
"expo-dev-client": "~2.1.1",
"expo-device": "~5.2.1",
"expo-image": "^1.2.3",
"expo-image-manipulator": "^11.1.1",
"expo-image-picker": "^14.1.1",
"expo-localization": "~14.1.1",
"expo-media-library": "~15.2.3",
"expo-notifications": "~0.18.1",
"expo-sharing": "~11.2.2",
"expo-splash-screen": "~0.18.2",
"expo-status-bar": "~1.4.4",
"expo-system-ui": "~2.2.1",
"expo-updates": "~0.16.4",
"fast-text-encoding": "^1.0.6",
"graphemer": "^1.4.0",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
"lande": "^1.0.10",
@@ -114,7 +99,6 @@
"lodash.isequal": "^4.5.0",
"lodash.omit": "^4.5.0",
"lodash.once": "^4.1.1",
"lodash.random": "^3.2.0",
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
@@ -122,17 +106,16 @@
"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",
"psl": "^1.9.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-native": "0.72.5",
"react-native": "0.71.8",
"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",
@@ -143,37 +126,33 @@
"react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.1.4",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.4.2",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.0",
"react-native-safe-area-context": "^4.4.1",
"react-native-screens": "^3.20.0",
"react-native-splash-screen": "^3.3.0",
"react-native-svg": "13.9.0",
"react-native-svg": "13.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6",
"react-native-web": "^0.18.11",
"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": "~6.1.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",
"@atproto/dev-env": "^0.2.3",
"@atproto/pds": "^0.2.0-beta.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",
"@testing-library/react-native": "^11.5.2",
@@ -185,50 +164,45 @@
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.omit": "^4.5.7",
"@types/lodash.once": "^4.1.7",
"@types/lodash.random": "^3.2.7",
"@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-native": "^0.67.3",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@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",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-native-a11y": "^3.3.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.4.3",
"jest-expo": "^49.0.0",
"jest-expo": "^48.0.2",
"jest-junit": "^15.0.0",
"lint-staged": "^13.2.3",
"metro-react-native-babel-preset": "^0.73.7",
"prettier": "^2.8.3",
"react-native-dotenv": "^3.3.1",
"react-refresh": "^0.14.0",
"react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.1.3",
"typescript": "^4.4.4",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"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 +218,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__",
@@ -280,6 +251,6 @@
]
},
"lint-staged": {
"*{.js,.jsx,.ts,.tsx}": "yarn eslint --fix"
"*": "yarn eslint --ext .js,.jsx,.ts,.tsx --fix"
}
}
-15
View File
@@ -1,15 +0,0 @@
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
--- 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';
* This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning.
*/
export function ignoreRequireCycleLogs() {
- LogBox.ignoreLogs(['Require cycle:']);
+ try {
+ LogBox.ignoreLogs(['Require cycle:']);
+ } catch (e) {}
}
//# sourceMappingURL=ignorerequirecyclelogs.js.map
\ No newline at end of file
-14
View 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 }],
-12
View File
@@ -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 };
-44
View File
@@ -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),
-50
View File
@@ -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,7 +1,16 @@
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
index 9dca6a5..090bda5 100644
index 1c8f8e0..090bda5 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
@@ -256,7 +256,7 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
- (void)textViewDidChange:(__unused UITextView *)textView
{
- if (_ignoreNextTextInputCall) {
+ if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
_ignoreNextTextInputCall = NO;
return;
}
@@ -266,11 +266,10 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
- (void)textViewDidChangeSelection:(__unused UITextView *)textView
@@ -14,4 +23,4 @@ index 9dca6a5..090bda5 100644
- _lastStringStateWasUpdatedWith = _backedTextInputView.attributedText;
[self textViewProbablyDidChangeSelection];
}
+4 -4
View File
@@ -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) {
+45 -107
View File
@@ -1,130 +1,68 @@
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 {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')
import * as Toast from './view/com/util/Toast'
import {handleLink} from './Navigation'
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const App = observer(() => {
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
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>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
// show nothing prior to init
if (!rootStore) {
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>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
)
}
})
export default App
export default withSentry(App)
+30 -100
View File
@@ -1,118 +1,48 @@
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 {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'
import {observer} from 'mobx-react-lite'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
const App = observer(() => {
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])
// show nothing prior to init
if (isInitialLoad) {
// TODO add a loading state
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>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
view.setup()
setupState().then(store => {
setRootStore(store)
analytics.init(store)
})
}, [])
if (!isReady) {
// show nothing prior to init
if (!rootStore) {
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>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
)
}
})
export default App
+76 -116
View File
@@ -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,22 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
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 {useStores} from './state'
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 {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
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'
@@ -67,8 +63,11 @@ import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {getRoutingInstrumentation} from 'lib/sentry'
import {bskyTitle} from 'lib/strings/headings'
import {JSX} from 'react/jsx-runtime'
import {timeout} from 'lib/async/timeout'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -91,47 +90,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="DiscoverFeeds"
component={DiscoverFeedsScreen}
options={{title: title('Discover Feeds')}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
component={SettingsScreen}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings')}}
/>
<Stack.Screen
name="Profile"
getComponent={() => ProfileScreen}
component={ProfileScreen}
options={({route}) => ({
title: title(`@${route.params.name}`),
animation: 'none',
@@ -139,103 +133,98 @@ 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}
options={{title: title('Threads Preferences')}}
/>
</>
)
}
@@ -251,24 +240,20 @@ function TabsNavigator() {
),
[],
)
return (
<Tab.Navigator
initialRouteName="HomeTab"
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 +270,7 @@ function HomeTabNavigator() {
animationDuration: 250,
contentStyle,
}}>
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
<HomeTab.Screen name="Home" component={HomeScreen} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
@@ -302,7 +287,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 +304,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 +323,16 @@ function NotificationsTabNavigator() {
}}>
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
component={NotificationsScreen}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
)
}
function MyProfileTabNavigator() {
const MyProfileTabNavigator = observer(() => {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const store = useStores()
return (
<MyProfileTab.Navigator
screenOptions={{
@@ -359,25 +345,25 @@ 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,
hideBackButton: true,
}}
/>
{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(() => {
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const title = (page: string) => bskyTitle(page, numUnread)
const unreadCountLabel = useStores().me.notifications.unreadCountLabel
const title = (page: string) => bskyTitle(page, unreadCountLabel)
return (
<Flat.Navigator
screenOptions={{
@@ -389,28 +375,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 +454,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 +475,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 +555,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,
-9
View File
@@ -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'
+11 -43
View File
@@ -1,12 +1,5 @@
import {isWeb} from 'platform/detection'
import React, {ReactNode, createContext, useContext} from 'react'
import {
AppState,
TextStyle,
useColorScheme as useColorScheme_BUGGY,
ViewStyle,
ColorSchemeName,
} from 'react-native'
import React, {ReactNode, createContext, useContext, useMemo} from 'react'
import {TextStyle, useColorScheme, ViewStyle} from 'react-native'
import {darkTheme, defaultTheme} from './themes'
export type ColorScheme = 'light' | 'dark'
@@ -93,44 +86,19 @@ export const ThemeContext = createContext<Theme>(defaultTheme)
export const useTheme = () => useContext(ThemeContext)
function getTheme(theme: ColorSchemeName) {
return theme === 'dark' ? darkTheme : defaultTheme
}
/**
* With RN iOS, we can only "trust" the color scheme reported while the app is
* active. This is a workaround until the bug is fixed upstream.
*
* @see https://github.com/bluesky-social/social-app/pull/1417#issuecomment-1719868504
* @see https://github.com/facebook/react-native/pull/39439
*/
function useColorScheme_FIXED() {
const colorScheme = useColorScheme_BUGGY()
const [currentColorScheme, setCurrentColorScheme] =
React.useState<ColorSchemeName>(colorScheme)
React.useEffect(() => {
// we don't need to be updating state on web
if (isWeb) return
const subscription = AppState.addEventListener('change', state => {
const isActive = state === 'active'
if (!isActive) return
setCurrentColorScheme(colorScheme)
})
return () => subscription.remove()
}, [colorScheme])
return isWeb ? colorScheme : currentColorScheme
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme,
children,
}) => {
const colorScheme = useColorScheme_FIXED()
const themeValue = getTheme(theme === 'system' ? colorScheme : theme)
const colorSchemeFromRN = useColorScheme()
return (
<ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
// if theme is 'system', use the device's configured color scheme
let colorScheme = theme === 'system' ? colorSchemeFromRN : theme
const value = useMemo(
() => (colorScheme === 'dark' ? darkTheme : defaultTheme),
[colorScheme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
+41 -57
View File
@@ -1,40 +1,28 @@
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',
trackAppLifecycleEvents: false,
proxy: 'https://api.events.bsky.app/v1',
})
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 +43,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 +65,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 +128,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
}
+15 -16
View File
@@ -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',
@@ -17,7 +15,7 @@ const segmentClient = createClient(
{
integrations: {
'Segment.io': {
apiHost: 'api.events.bsky.app/v1',
apiHost: 'api.evt.bsky.app/v1',
},
},
},
@@ -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()
}
}
+6 -16
View File
@@ -56,12 +56,8 @@ interface TrackPropertiesMap {
}
// PROFILE HEADER events
'ProfileHeader:EditProfileButtonClicked': {}
'ProfileHeader:FollowersButtonClicked': {
handle: string
}
'ProfileHeader:FollowsButtonClicked': {
handle: string
}
'ProfileHeader:FollowersButtonClicked': {}
'ProfileHeader:FollowsButtonClicked': {}
'ProfileHeader:ShareButtonClicked': {}
'ProfileHeader:MuteAccountButtonClicked': {}
'ProfileHeader:UnmuteAccountButtonClicked': {}
@@ -71,8 +67,6 @@ interface TrackPropertiesMap {
'ProfileHeader:UnblockAccountButtonClicked': {}
'ProfileHeader:FollowButtonClicked': {}
'ProfileHeader:UnfollowButtonClicked': {}
'ProfileHeader:SuggestedFollowsOpened': {}
'ProfileHeader:SuggestedFollowFollowed': {}
'ViewHeader:MenuButtonClicked': {}
// SETTINGS events
'Settings:SwitchAccountButtonClicked': {}
@@ -97,13 +91,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': {}
@@ -133,7 +124,6 @@ interface TrackPropertiesMap {
'Onboarding:Complete': {}
'Onboarding:Skipped': {}
'Onboarding:Reset': {}
'Onboarding:SuggestedFollowFollowed': {}
}
interface ScreenPropertiesMap {
@@ -10,6 +10,7 @@
import {useState, useCallback, useEffect} from 'react'
import {BskyAgent} from '@atproto/api'
import {isWeb} from 'platform/detection'
import * as Storage from 'lib/storage'
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
@@ -50,6 +51,9 @@ export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
}
export async function applyDebugHeader(agent: BskyAgent) {
if (!isWeb) {
return
}
if (await isEnabled()) {
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
}
+17 -77
View File
@@ -4,7 +4,6 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@@ -65,11 +64,6 @@ export class FeedViewPostsSlice {
)
}
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as ReasonFeedSource
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
@@ -97,34 +91,6 @@ export class FeedViewPostsSlice {
}
}
}
isFollowingAllAuthors(userDid: string) {
const item = this.rootItem
if (item.post.author.did === userDid) {
return true
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
const parent = item.reply?.parent
if (parent?.author.did === userDid) {
return true
}
return (
parent?.author.viewer?.following && item.post.author.viewer?.following
)
}
return false
}
}
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 {
@@ -139,32 +105,23 @@ export class FeedTuner {
tune(
feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: false,
},
{dryRun}: {dryRun: boolean} = {dryRun: false},
): FeedViewPostsSlice[] {
let slices: FeedViewPostsSlice[] = []
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i]
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i]
const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) {
const parent = slices.find(item2 =>
item2.isNextInThread(selfReplyUri),
)
if (parent) {
parent.insert(item)
continue
}
const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) {
const parent = slices.find(item2 => item2.isNextInThread(selfReplyUri))
if (parent) {
parent.insert(item)
continue
}
slices.unshift(new FeedViewPostsSlice([item]))
}
slices.unshift(new FeedViewPostsSlice([item]))
}
// run the custom tuners
@@ -265,34 +222,20 @@ export class FeedTuner {
return slices
}
static thresholdRepliesOnly({
userDid,
minLikes,
followedOnly,
}: {
userDid: string
minLikes: number
followedOnly: boolean
}) {
static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
// remove any replies without at least minLikes likes
// remove any replies without at least repliesThreshold likes
for (let i = slices.length - 1; i >= 0; i--) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
if (slices[i].isFullThread || !slices[i].isReply) {
continue
}
const item = slice.rootItem
const item = slices[i].rootItem
const isRepost = Boolean(item.reason)
if (isRepost) {
continue
}
if ((item.post.likeCount || 0) < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
slices.splice(i, 1)
}
}
@@ -338,10 +281,7 @@ export class FeedTuner {
function getSelfReplyUri(item: FeedViewPost): string | undefined {
if (item.reply) {
if (
AppBskyFeedDefs.isPostView(item.reply.parent) &&
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
) {
if (AppBskyFeedDefs.isPostView(item.reply.parent)) {
return item.reply.parent.author.did === item.post.author.did
? item.reply.parent.uri
: undefined
-90
View File
@@ -1,90 +0,0 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.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({
...this.params,
cursor,
limit,
})
if (res.success) {
return {
cursor: res.data.cursor,
feed: this._filter(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
}
-51
View File
@@ -1,51 +0,0 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetCustomFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.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({
...this.params,
cursor,
limit,
})
if (res.success) {
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (res.data.feed.length > limit) {
res.data.feed = res.data.feed.slice(0, limit)
}
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}
-35
View File
@@ -1,35 +0,0 @@
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
constructor(public agent: BskyAgent) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.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,
limit,
})
if (res.success) {
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}
-44
View File
@@ -1,44 +0,0 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetActorLikes.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.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({
...this.params,
cursor,
limit,
})
if (res.success) {
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}
-44
View File
@@ -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: [],
}
}
}
-273
View File
@@ -1,273 +0,0 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle'
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'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
following: MergeFeedSource_Following
customFeeds: MergeFeedSource_Custom[] = []
feedCursor = 0
itemCursor = 0
sampleCursor = 0
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
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({
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
const promises = []
// always keep following topped up
if (this.following.numReady < limit) {
promises.push(this.following.fetchNext(60))
}
// pick the next feeds to sample from
const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3)
this.feedCursor += 3
if (this.feedCursor > this.customFeeds.length) {
this.feedCursor = 0
}
// top up the feeds
for (const feed of feeds) {
if (feed.numReady < 5) {
promises.push(feed.fetchNext(10))
}
}
// wait for requests (all capped at a fixed timeout)
await Promise.all(promises)
// assemble a response by sampling from feeds with content
const posts: AppBskyFeedDefs.FeedViewPost[] = []
while (posts.length < limit) {
let slice = this.sampleItem()
if (slice[0]) {
posts.push(slice[0])
} else {
break
}
}
return {
cursor: posts.length ? String(this.itemCursor) : undefined,
feed: posts,
}
}
sampleItem() {
const i = this.itemCursor++
const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
const canSample = candidateFeeds.length > 0
const hasFollows = this.following.hasMore
const hasFollowsReady = this.following.numReady > 0
// this condition establishes the frequency that custom feeds are woven into follows
const shouldSample =
i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0)
if (!canSample && !hasFollows) {
// no data available
return []
}
if (shouldSample || !hasFollows) {
// time to sample, or the user isnt following anybody
return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
}
if (!hasFollowsReady) {
// stop here so more follows can be fetched
return []
}
// provide follow
return this.following.take(1)
}
}
class MergeFeedSource {
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
}
get needsFetch() {
return this.hasMore && this.queue.length === 0
}
reset() {
this.cursor = undefined
this.queue = []
this.hasMore = true
}
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
return this.queue.splice(0, n)
}
async fetchNext(n: number) {
await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)])
}
_fetchNextInner = bundleAsync(async (n: number) => {
const res = await this._getFeed(this.cursor, n)
if (res.success) {
this.cursor = res.data.cursor
if (res.data.feed.length) {
this.queue = this.queue.concat(res.data.feed)
} else {
this.hasMore = false
}
} else {
this.hasMore = false
}
})
protected _getFeed(
_cursor: string | undefined,
_limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
throw new Error('Must be overridden')
}
}
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner()
reset() {
super.reset()
this.tuner.reset()
}
async fetchNext(n: number) {
return this._fetchNextInner(n)
}
protected async _getFeed(
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.agent.getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
return res
}
}
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
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: []}}
}
}
}
-32
View File
@@ -1,32 +0,0 @@
import {AppBskyFeedDefs} from '@atproto/api'
export interface FeedAPIResponse {
cursor?: string
feed: AppBskyFeedDefs.FeedViewPost[]
}
export interface FeedAPI {
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse>
}
export interface ReasonFeedSource {
$type: 'reasonFeedSource'
uri: string
displayName: string
}
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
return (
!!v &&
typeof v === 'object' &&
'$type' in v &&
v.$type === 'reasonFeedSource'
)
}
+43 -17
View File
@@ -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
@@ -106,12 +133,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
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 ?? '',
aspectRatio: {width, height},
})
}
@@ -151,13 +176,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 +222,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 +255,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,
-25
View File
@@ -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
}
+68
View File
@@ -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)
}
}
-24
View File
@@ -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
}
-11
View File
@@ -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
View File
@@ -1 +0,0 @@
export default BroadcastChannel
+121 -23
View File
@@ -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 = [
@@ -88,19 +79,12 @@ export async function DEFAULT_FEEDS(
serviceUrl: string,
resolveHandle: (name: string) => Promise<string>,
) {
// TODO: remove this when the test suite no longer relies on it
if (IS_LOCAL_DEV(serviceUrl)) {
// local dev
const aliceDid = await resolveHandle('alice.test')
return {
pinned: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
saved: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
}
} else if (IS_STAGING(serviceUrl)) {
// staging
@@ -116,8 +100,16 @@ export async function DEFAULT_FEEDS(
} else {
// production
return {
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
pinned: [
PROD_DEFAULT_FEED('whats-hot'),
PROD_DEFAULT_FEED('with-friends'),
],
saved: [
PROD_DEFAULT_FEED('bsky-team'),
PROD_DEFAULT_FEED('with-friends'),
PROD_DEFAULT_FEED('whats-hot'),
PROD_DEFAULT_FEED('hot-classic'),
],
}
}
}
@@ -156,4 +148,110 @@ 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
export const RECOMMENDED_FEEDS = [
{
did: 'did:plc:hsqwcidfez66lwm3gxhfv5in',
rkey: 'aaaf2pqeodmpy',
},
{
did: 'did:plc:gekdk2nd47gkk3utfz2xf7cn',
rkey: 'aaap4tbjcfe5y',
},
{
did: 'did:plc:5rw2on4i56btlcajojaxwcat',
rkey: 'aaao6g552b33o',
},
{
did: 'did:plc:jfhpnnst6flqway4eaeqzj2a',
rkey: 'for-science',
},
{
did: 'did:plc:7q4nnnxawajbfaq7to5dpbsy',
rkey: 'bsky-news',
},
{
did: 'did:plc:jcoy7v3a2t4rcfdh6i4kza25',
rkey: 'astro',
},
{
did: 'did:plc:tenurhgjptubkk5zf5qhi3og',
rkey: 'h-nba',
},
{
did: 'did:plc:vpkhqolt662uhesyj6nxm7ys',
rkey: 'devfeed',
},
{
did: 'did:plc:cndfx4udwgvpjaakvxvh7wm5',
rkey: 'flipboard-tech',
},
{
did: 'did:plc:w4xbfzo7kqfes5zb7r6qv3rw',
rkey: 'blacksky',
},
{
did: 'did:plc:lptjvw6ut224kwrj7ub3sqbe',
rkey: 'aaaotfjzjplna',
},
{
did: 'did:plc:gkvpokm7ec5j5yxls6xk4e3z',
rkey: 'formula-one',
},
{
did: 'did:plc:q6gjnaw2blty4crticxkmujt',
rkey: 'positivifeed',
},
{
did: 'did:plc:l72uci4styb4jucsgcrrj5ap',
rkey: 'aaao5dzfm36u4',
},
{
did: 'did:plc:k3jkadxv5kkjgs6boyon7m6n',
rkey: 'aaaavlyvqzst2',
},
{
did: 'did:plc:nkahctfdi6bxk72umytfwghw',
rkey: 'aaado2uvfsc6w',
},
{
did: 'did:plc:epihigio3d7un7u3gpqiy5gv',
rkey: 'aaaekwsc7zsvs',
},
{
did: 'did:plc:qiknc4t5rq7yngvz7g4aezq7',
rkey: 'aaaejxlobe474',
},
{
did: 'did:plc:mlq4aycufcuolr7ax6sezpc4',
rkey: 'aaaoudweck6uy',
},
{
did: 'did:plc:rcez5hcvq3vzlu5x7xrjyccg',
rkey: 'aaadzjxbcddzi',
},
{
did: 'did:plc:lnxbuzaenlwjrncx6sc4cfdr',
rkey: 'aaab2vesjtszc',
},
{
did: 'did:plc:x3cya3wkt4n6u4ihmvpsc5if',
rkey: 'aaacynbxwimok',
},
{
did: 'did:plc:abv47bjgzjgoh3yrygwoi36x',
rkey: 'aaagt6amuur5e',
},
{
did: 'did:plc:ffkgesg3jsv2j7aagkzrtcvt',
rkey: 'aaacjerk7gwek',
},
{
did: 'did:plc:geoqe3qls5mwezckxxsewys2',
rkey: 'aaai43yetqshu',
},
{
did: 'did:plc:2wqomm3tjqbgktbrfwgvrw34',
rkey: 'authors',
},
]
-24
View File
@@ -1,24 +0,0 @@
import {
AppBskyFeedDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
} from '@atproto/api'
export function isEmbedByEmbedder(
embed: AppBskyFeedDefs.PostView['embed'],
did: string,
): boolean {
if (!embed) {
return false
}
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
return embed.record.author.did === did
}
if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record)
) {
return embed.record.record.author.did === did
}
return true
}
-29
View File
@@ -1,29 +0,0 @@
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'
export function useAccountSwitcher() {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const onPressSwitchAccount = useCallback(
async (acct: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
try {
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
}
},
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
)
return {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,
)
}
-2
View File
@@ -1,2 +0,0 @@
// does nothing in native
export const useAuxClick = () => {}
-43
View File
@@ -1,43 +0,0 @@
import {useEffect} from 'react'
// This is the handler for the middle mouse button click on the feed.
// Normally, we would do this via `onAuxClick` handler on each link element
// However, that handler is not supported on react-native-web and there are some
// discrepancies between various browsers (i.e: safari doesn't trigger it and routes through click event)
// So, this temporary alternative is meant to bridge the gap in an efficient way until the support improves.
export const useAuxClick = () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
useEffect(() => {
// On the web, it should always be there but in case it gets accidentally included in native builds
const wrapperEl = document?.body
// Safari already handles auxclick event as click+metaKey so we need to avoid doing this there in case it becomes recursive
if (wrapperEl && !isSafari) {
const handleAuxClick = (e: MouseEvent & {target: HTMLElement}) => {
// Only handle the middle mouse button click
// Only handle if the clicked element itself or one of its ancestors is a link
if (
e.button !== 1 ||
e.target.closest('a') ||
e.target.tagName === 'A'
) {
return
}
// On the original element, trigger a click event with metaKey set to true so that it triggers
// the browser's default behavior of opening the link in a new tab
e.target.dispatchEvent(
new MouseEvent('click', {metaKey: true, bubbles: true}),
)
}
// @ts-ignore For web only
wrapperEl.addEventListener('auxclick', handleAuxClick)
return () => {
// @ts-ignore For web only
wrapperEl?.removeEventListener('auxclick', handleAuxClick)
}
}
}, [isSafari])
}
+27
View File
@@ -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
}
+1 -1
View File
@@ -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'
+28 -41
View File
@@ -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}
}
-23
View File
@@ -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
}
+14 -15
View File
@@ -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(() => {
+47 -110
View File
@@ -1,125 +1,62 @@
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 {isDesktopWeb} from 'platform/detection'
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
const DY_LIMIT_UP = isDesktopWeb ? 30 : 10
const DY_LIMIT_DOWN = isDesktopWeb ? 150 : 10
const Y_LIMIT = 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)
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 > DY_LIMIT_DOWN &&
y > Y_LIMIT
) {
store.shell.setMinimalShellMode(true)
} else if (
store.shell.minimalShellMode &&
(dy < DY_LIMIT_UP * -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, 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]),
]
}
+8 -4
View File
@@ -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])
}
-98
View File
@@ -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
}
+56 -35
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
export function GridIcon({
style,
@@ -884,7 +884,45 @@ export function HandIcon({
)
}
export function HashtagIcon({
export function SatelliteDishIconSolid({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
width={size || 24}
height={size || 24}
viewBox="0 0 22 22"
style={style}
fill="none"
stroke="none">
<Path
d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
fill="currentColor"
/>
<Path
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Path
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Circle cx="10" cy="12" r="2" fill="currentColor" />
</Svg>
)
}
export function SatelliteDishIcon({
style,
size,
strokeWidth = 1.5,
@@ -896,16 +934,26 @@ export function HashtagIcon({
return (
<Svg
fill="none"
stroke="currentColor"
viewBox="0 0 30 30"
viewBox="0 0 22 22"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size}
height={size}
style={style}>
<Path d="M2 10H28" strokeLinecap="round" />
<Path d="M2 20H28" strokeLinecap="round" />
<Path d="M11 3L9 27" strokeLinecap="round" />
<Path d="M21 3L19 27" strokeLinecap="round" />
<Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" />
<Path
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
strokeLinecap="round"
/>
<Path
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
strokeLinecap="round"
/>
<Path
d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
fill="currentColor"
stroke="none"
/>
</Svg>
)
}
@@ -947,30 +995,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>
)
}
+89
View File
@@ -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'],
},
}
+18
View File
@@ -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
View File
@@ -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,
+7 -7
View File
@@ -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]
+12
View File
@@ -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,
})
}
-34
View File
@@ -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
}

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