Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Frazee bd4e949424 Move policy docs to blueskyweb.xyz 2023-06-14 18:26:40 -05:00
551 changed files with 27615 additions and 50589 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15 Pro',
type: 'iPhone 14',
},
},
attached: {
-102
View File
@@ -1,102 +0,0 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
# BUCK
buck-out/
\.buckd/
*.keystore
!debug.keystore
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
# Bundle artifact
*.jsbundle
# Ruby / CocoaPods
/ios/Pods/
/vendor/bundle/
# Testing
coverage/
junit.xml
artifacts
# Web bundle outputs
/dist/
# Expo
.expo/
dist/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# VSCode
.vscode
# gitignore and github actions
!.gitignore
!.github
# Android & iOS folders
android/
ios/
# environment variables
.env
.env.*
# Firebase (Android) Google services
# INCLUDED: google-services.json
-2
View File
@@ -1,3 +1 @@
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+9 -16
View File
@@ -2,31 +2,24 @@ 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',
'src/third-party',
'ios',
'android',
'coverage',
'*.lock',
'.husky',
'patches',
'bskyweb',
'*.html',
'bskyweb',
],
settings: {
componentWrapperFunctions: ['observer'],
},
overrides: [
{
files: ['*.js', '*.mjs', '*.ts', '*.tsx'],
rules: {
semi: [2, 'never'],
},
},
],
}
@@ -12,7 +12,6 @@ env:
jobs:
bskyweb-container-aws:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -14,7 +14,6 @@ env:
jobs:
bskyweb-container-ghcr:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.21'
go-version: '1.20'
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Check
@@ -36,7 +36,7 @@ jobs:
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.21'
go-version: '1.20'
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Lint
+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 -7
View File
@@ -96,10 +96,4 @@ ios/
# environment variables
.env
.env.*
# Firebase (Android) Google services
google-services.json
# Performance results (Flashlight)
.perf/
.env.*
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged
+1 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.21-bullseye AS build-env
FROM golang:1.20-bullseye AS build-env
WORKDIR /usr/src/social-app
@@ -13,7 +13,6 @@ ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
COPY . .
+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 -24
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:**
@@ -61,12 +52,6 @@ Please be sure to:
If you discover any security issues, please send an email to security@bsky.app. The email is automatically CCed to the entire team and we'll respond promptly.
## Are you a developer interested in building on atproto?
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
If you're a developer interested in building on atproto, we'd love to email you a Bluesky invite code. Simply share your GitHub (or similar) profile with us via [this form](https://forms.gle/BF21oxVNZiDjDhXF9).
## License (MIT)
See [./LICENSE](./LICENSE) for the full license.
-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"
+42 -259
View File
@@ -34,9 +34,6 @@ async function main() {
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
@@ -53,10 +50,6 @@ async function main() {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
@@ -70,82 +63,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')
@@ -155,35 +72,26 @@ async function main() {
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'csam-account',
'csam-profile',
'csam-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'always-filter-account',
'always-filter-profile',
'always-filter-posts',
'always-warn-account',
'always-warn-profile',
'always-warn-posts',
'muted-account',
'muted-by-list-account',
'blocking-account',
'blockedby-account',
'mutual-block-account',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
@@ -197,25 +105,25 @@ async function main() {
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelAccount('csam', 'csam-account')
await server.mocker.labelProfile('csam', 'csam-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
'csam',
await server.mocker.createPost('csam-posts', 'csam post'),
)
await server.mocker.labelPost(
'dmca-violation',
'csam',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
'csam-posts',
'csam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
'csam',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
'csam-posts',
'csam reply',
anchorPost,
),
)
@@ -224,7 +132,7 @@ async function main() {
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
await server.mocker.createPost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
@@ -247,7 +155,7 @@ async function main() {
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
await server.mocker.createPost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
@@ -266,29 +174,6 @@ async function main() {
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount(
'not-a-real-label',
'unknown-account',
@@ -318,74 +203,54 @@ async function main() {
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelAccount('!filter', 'always-filter-account')
await server.mocker.labelProfile('!filter', 'always-filter-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
'!filter',
await server.mocker.createPost(
'no-promote-posts',
'no-promote post',
'always-filter-posts',
'always-filter post',
),
)
await server.mocker.labelPost(
'!no-promote',
'!filter',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
'always-filter-posts',
'always-filter quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
'!filter',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
'always-filter-posts',
'always-filter reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelAccount('!warn', 'always-warn-account')
await server.mocker.labelProfile('!warn', 'always-warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
await server.mocker.createPost(
'always-warn-posts',
'always-warn post',
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
'always-warn-posts',
'always-warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
'always-warn-posts',
'always-warn reply',
anchorPost,
),
)
@@ -423,88 +288,6 @@ async function main() {
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost(
'mutual-block-account',
'mutual-block post',
)
await server.mocker.createQuotePost(
'mutual-block-account',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-account',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-account'].did,
},
{
subject: server.mocker.users.alice.did,
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()
})
-6
View File
@@ -25,12 +25,6 @@ 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 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()
})
})
+9 -8
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()
})
@@ -54,13 +55,13 @@ describe('Home screen', () => {
await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
.atIndex(0)
.tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
it('Can swipe between feeds', async () => {
@@ -83,7 +84,7 @@ describe('Home screen', () => {
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0)
.tap()
await element(by.text('Delete post')).tap()
await element(by.id('postDropdownDeleteBtn')).tap()
await expect(element(by.id('confirmModal'))).toBeVisible()
await element(by.id('confirmBtn')).tap()
await expect(
+15 -21
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,17 @@ 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 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()
})
})
+141
View File
@@ -0,0 +1,141 @@
/* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util'
describe('Profile screen', () => {
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.id('changeAvatarLibraryBtn')).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.id('changeAvatarRemoveBtn')).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-2')).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.id('profileHeaderDropdownListAddRemoveBtn')).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.id('profileHeaderDropdownListAddRemoveBtn')).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()
})
})
+22 -29
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')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
@@ -12,18 +13,10 @@ 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 element(by.id('selector')).swipe('right')
await element(by.id('selector-0')).tap()
})
it('Open and close edit profile modal', async () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
@@ -60,7 +53,7 @@ describe('Profile screen', () => {
await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
'alice.test',
)
await expect(element(by.id('profileHeaderDescription'))).not.toExist()
await expect(element(by.id('profileHeaderDescription'))).toHaveText('')
})
it('Set avi and banner via the edit profile modal', async () => {
@@ -69,10 +62,10 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.text('Library')).tap()
await element(by.id('changeBannerLibraryBtn')).tap()
await sleep(3e3)
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap()
await element(by.id('changeAvatarLibraryBtn')).tap()
await sleep(3e3)
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
@@ -86,9 +79,9 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.text('Remove')).tap()
await element(by.id('changeBannerRemoveBtn')).tap()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap()
await element(by.id('changeAvatarRemoveBtn')).tap()
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('userBannerFallback'))).toExist()
@@ -114,24 +107,24 @@ describe('Profile screen', () => {
})
it('Can mute/unmute another user', async () => {
await expect(element(by.id('profileHeaderAlert'))).not.toExist()
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Mute Account')).tap()
await expect(element(by.id('profileHeaderAlert'))).toBeVisible()
await element(by.id('profileHeaderDropdownMuteBtn')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Unmute Account')).tap()
await expect(element(by.id('profileHeaderAlert'))).not.toExist()
await element(by.id('profileHeaderDropdownMuteBtn')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
})
it('Can report another user', async () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Report Account')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(by.id('profileHeaderDropdownReportBtn')).tap()
await expect(element(by.id('reportAccountModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
by.id('reportAccountRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
await expect(element(by.id('reportAccountModal'))).not.toBeVisible()
})
it('Can like posts', async () => {
@@ -173,12 +166,12 @@ describe('Profile screen', () => {
it('Can report posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
})
+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 () => {
-33
View File
@@ -1,33 +0,0 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Self-labeling', () => {
beforeAll(async () => {
await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Post an image with the porn label', async () => {
await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText('Post with an image')
await element(by.id('openGalleryBtn')).tap()
await sleep(3e3)
await element(by.id('labelsBtn')).tap()
await element(by.id('pornLabelBtn')).tap()
await element(by.id('confirmBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
const posts = by.id('feedItem-by-alice.test')
await expect(
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
).toExist()
})
})
@@ -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()
})
+24 -9
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(
@@ -37,15 +45,19 @@ describe('Thread muting', () => {
await element(by.id('postDropdownBtn').withAncestor(bobNotifs))
.atIndex(0)
.tap()
await element(by.text('Mute thread')).tap()
await element(by.id('postDropdownMuteThreadBtn')).tap()
// have to wait for the toast to clear
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.id('postDropdownMuteThreadBtn')).tap()
// TODO
// the swipe down to trigger PTR isnt working and I dont want to block on this
+12 -11
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(
@@ -103,24 +104,24 @@ describe('Thread screen', () => {
it('Can report the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
it('Can report a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
})
-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
+2 -5
View File
@@ -1,4 +1,4 @@
import React, {ReactNode} from 'react'
import React from 'react'
import {View, ScrollView, Modal, FlatList, TextInput} from 'react-native'
const BottomSheetModalContext = React.createContext(null)
@@ -6,10 +6,7 @@ const BottomSheetModalContext = React.createContext(null)
const BottomSheetModalProvider = (props: any) => {
return <BottomSheetModalContext.Provider {...props} value={{}} />
}
class BottomSheet extends React.Component<{
onClose?: () => void
children?: ReactNode
}> {
class BottomSheet extends React.Component<{onClose?: () => void}> {
snapToIndex() {}
snapToPosition() {}
expand() {}
-1
View File
@@ -1 +0,0 @@
export const Slider = {}
-3
View File
@@ -1,3 +0,0 @@
jest.mock('sentry-expo', () => ({
init: () => jest.fn(),
}))
-2
View File
@@ -1,2 +0,0 @@
export const DropdownMenu = jest.fn().mockImplementation(() => {})
export const create = jest.fn().mockImplementation(() => {})
+2 -60
View File
@@ -1,4 +1,3 @@
import {RichText} from '@atproto/api'
import {
getYoutubeVideoId,
makeRecordUri,
@@ -9,7 +8,6 @@ import {
import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
@@ -175,7 +173,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 +181,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',
@@ -300,15 +296,11 @@ describe('toShortUrl', () => {
'https://bsky.app',
'https://bsky.app/3jk7x4irgv52r',
'https://bsky.app/3jk7x4irgv52r2313y182h9',
'https://very-long-domain-name.com/foo',
'https://very-long-domain-name.com/foo?bar=baz#andsomemore',
]
const outputs = [
'bsky.app',
'bsky.app/3jk7x4irgv52r',
'bsky.app/3jk7x4irgv52...',
'very-long-domain-name.com/foo',
'very-long-domain-name.com/foo?bar=baz#...',
'bsky.app/3jk7x4irgv52r2313y...',
]
it('shortens the url', () => {
@@ -360,53 +352,3 @@ describe('getYoutubeVideoId', () => {
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId')
})
})
describe('shortenLinks', () => {
const inputs = [
'start https://middle.com/foo/bar?baz=bux#hash end',
'https://start.com/foo/bar?baz=bux#hash middle end',
'start middle https://end.com/foo/bar?baz=bux#hash',
'https://newline1.com/very/long/url/here\nhttps://newline2.com/very/long/url/here',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
]
const outputs = [
[
'start middle.com/foo/bar?baz=... end',
['https://middle.com/foo/bar?baz=bux#hash'],
],
[
'start.com/foo/bar?baz=... middle end',
['https://start.com/foo/bar?baz=bux#hash'],
],
[
'start middle end.com/foo/bar?baz=...',
['https://end.com/foo/bar?baz=bux#hash'],
],
[
'newline1.com/very/long/ur...\nnewline2.com/very/long/ur...',
[
'https://newline1.com/very/long/url/here',
'https://newline2.com/very/long/url/here',
],
],
[
'Classic article socket3.wordpress.com/2018/02/03/d...',
[
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
],
],
]
it('correctly shortens rich text while preserving facet URIs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const inputRT = new RichText({text: input})
inputRT.detectFacetsWithoutResolution()
const outputRT = shortenLinks(inputRT)
expect(outputRT.text).toEqual(outputs[i][0])
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
for (let j = 0; j < outputs[i][1].length; j++) {
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
}
}
})
})
-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',
},
},
],
},
},
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"expo": {
"name": "Bluesky",
"slug": "bluesky",
"scheme": "bluesky",
"owner": "blueskysocial",
"version": "1.31.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": [
"fetch",
"processing"
],
"BGTaskSchedulerPermittedIdentifiers": [
"com.transistorsoft.fetch"
],
"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": 17,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"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",
"react-native-background-fetch",
"sentry-expo",
[
"expo-build-properties",
{
"android": {
"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'],
},
},
}
}
-3
View File
@@ -2,9 +2,6 @@
SHELL = /bin/bash
.SHELLFLAGS = -o pipefail -c
# https://github.com/golang/go/wiki/LoopvarExperiment
export GOEXPERIMENT := loopvar
.PHONY: help
help: ## Print info about all commands
@echo "Commands:"
+1 -1
View File
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
### Golang Daemon
Install golang. We are generally using v1.21+.
Install golang. We are generally using v1.20+.
In this directory (`bskyweb/`):
+16 -5
View File
@@ -34,11 +34,22 @@ func run(args []string) {
Action: serve,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "appview-host",
Usage: "method, hostname, and port of PDS instance",
Value: "http://localhost:2584",
// retain old PDS env var for easy transition
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
Name: "pds-host",
Usage: "method, hostname, and port of PDS instance",
Value: "http://localhost:4849",
EnvVars: []string{"ATP_PDS_HOST"},
},
&cli.StringFlag{
Name: "handle",
Usage: "for PDS login",
Required: true,
EnvVars: []string{"ATP_AUTH_HANDLE"},
},
&cli.StringFlag{
Name: "password",
Usage: "for PDS login",
Required: true,
EnvVars: []string{"ATP_AUTH_PASSWORD"},
},
&cli.StringFlag{
Name: "mailmodo-api-key",
+28 -48
View File
@@ -14,6 +14,7 @@ import (
"syscall"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
appbsky "github.com/bluesky-social/indigo/api/bsky"
cliutil "github.com/bluesky-social/indigo/cmd/gosky/util"
"github.com/bluesky-social/indigo/xrpc"
@@ -37,7 +38,9 @@ type Server struct {
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
pdsHost := cctx.String("pds-host")
atpHandle := cctx.String("handle")
atpPassword := cctx.String("password")
mailmodoAPIKey := cctx.String("mailmodo-api-key")
mailmodoListName := cctx.String("mailmodo-list-name")
@@ -47,12 +50,28 @@ func serve(cctx *cli.Context) error {
// Mailmodo client.
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
// create a new session (no auth)
// create a new session
// TODO: does this work with no auth at all?
xrpcc := &xrpc.Client{
Client: cliutil.NewHttpClient(),
Host: appviewHost,
Host: pdsHost,
Auth: &xrpc.AuthInfo{
Handle: atpHandle,
},
}
auth, err := comatproto.ServerCreateSession(context.TODO(), xrpcc, &comatproto.ServerCreateSession_Input{
Identifier: xrpcc.Auth.Handle,
Password: atpPassword,
})
if err != nil {
return err
}
xrpcc.Auth.AccessJwt = auth.AccessJwt
xrpcc.Auth.RefreshJwt = auth.RefreshJwt
xrpcc.Auth.Did = auth.Did
xrpcc.Auth.Handle = auth.Handle
// httpd
var (
httpTimeout = 2 * time.Minute
@@ -91,11 +110,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 +125,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.
@@ -138,6 +137,7 @@ func serve(cctx *cli.Context) error {
//
// configure routes
//
// static files
staticHandler := http.FileServer(func() http.FileSystem {
if debug {
@@ -150,48 +150,28 @@ func serve(cctx *cli.Context) error {
}
return http.FS(fsys)
}())
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)))
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
return next(c)
}
})
// home
e.GET("/", server.WebHome)
// 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)
+3 -1
View File
@@ -1,2 +1,4 @@
GOLOG_LOG_LEVEL=info
ATP_APPVIEW_HOST=http://localhost:2584
ATP_PDS_HOST=http://localhost:2583
ATP_AUTH_HANDLE="admin.test"
ATP_AUTH_PASSWORD="admin"
+3 -1
View File
@@ -1,2 +1,4 @@
GOLOG_LOG_LEVEL=info
ATP_APPVIEW_HOST=https://api.bsky.app
ATP_PDS_HOST=https://bsky.social
ATP_AUTH_HANDLE=TODO
ATP_AUTH_PASSWORD=TODO
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/bluesky-social/social-app/bskyweb
go 1.21
go 1.20
require (
github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319
-2
View File
@@ -31,7 +31,6 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -241,7 +240,6 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-30
View File
@@ -1,30 +0,0 @@
13.59.225.103/32
3.18.47.21/32
18.191.104.94/32
3.129.134.255/32
3.129.237.113/32
3.138.56.230/32
44.218.10.163/32
54.89.116.251/32
44.217.166.202/32
54.208.221.149/32
54.166.110.54/32
54.208.146.65/32
3.129.234.15/32
3.138.168.48/32
3.23.53.192/32
52.14.89.53/32
3.18.126.246/32
3.136.69.4/32
3.22.137.152/32
3.132.247.113/32
3.141.186.104/32
18.222.43.214/32
3.14.35.197/32
3.23.182.70/32
18.224.144.69/32
3.129.98.29/32
3.130.134.20/32
3.17.197.213/32
18.223.234.21/32
3.20.248.177/32
View File
+10 -71
View File
@@ -39,32 +39,6 @@
height: calc(100% + env(safe-area-inset-top));
}
/* Color theming */
:root {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
}
html.colorMode--dark {
--text: white;
--background: black;
--backgroundLight: #26272D;
}
@media (prefers-color-scheme: light) {
html.colorMode--system {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
}
}
@media (prefers-color-scheme: dark) {
html.colorMode--system {
--text: white;
--background: black;
--backgroundLight: #26272D;
}
}
body {
display: flex;
/* Allows you to scroll below the viewport; default value is visible */
@@ -75,6 +49,12 @@
-moz-osx-font-smoothing: grayscale;
-ms-overflow-style: scrollbar;
}
/* Enable for apps that support dark-theme */
/*@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}*/
/* Remove default link styling */
a {
@@ -91,9 +71,6 @@
*[data-word-wrap] {
word-break: break-word;
}
*[data-stable-gutters] {
scrollbar-gutter: stable both-edges;
}
/* ProseMirror */
.ProseMirror {
@@ -116,9 +93,9 @@
.ProseMirror .mention {
color: #0085ff;
}
.ProseMirror a,
.ProseMirror .autolink {
.ProseMirror a {
color: #0085ff;
cursor: pointer;
}
/* OLLIE: TODO -- this is not accessible */
/* Remove focus state on inputs */
@@ -132,52 +109,14 @@
.tippy-content .items {
width: fit-content;
}
/* Tooltips */
[data-tooltip] {
position: relative;
z-index: 10;
}
[data-tooltip]::after {
content: attr(data-tooltip);
display: none;
position: absolute;
bottom: 0;
left: 50%;
transform: translateY(100%) translateY(8px) translateX(-50%);
padding: 4px 10px;
border-radius: 10px;
background: var(--backgroundLight);
color: var(--text);
text-align: center;
white-space: nowrap;
font-size: 12px;
z-index: 10;
}
[data-tooltip]::before {
content: '';
display: none;
position: absolute;
border-bottom: 6px solid var(--backgroundLight);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
bottom: 0;
left: 50%;
transform: translateY(100%) translateY(2px) translateX(-50%);
z-index: 10;
}
[data-tooltip]:hover::after,
[data-tooltip]:hover::before {
display:block;
}
</style>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<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 %}
+1 -1
View File
@@ -43,6 +43,6 @@
<p id="bsky_display_name">{{ postView.Author.DisplayName }}</p>
<p id="bsky_handle">{{ postView.Author.Handle }}</p>
<p id="bsky_did">{{ postView.Author.Did }}</p>
<p id="bsky_post_text">{{ postView.Record.Val.Text }}</p>
<p id="bsky_post_text">{{ postView.Record.Text }}</p>
</div>
{%- endblock %}
+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`.
+3 -18
View File
@@ -9,37 +9,22 @@
"distribution": "internal",
"ios": {
"simulator": true,
"resourceClass": "m-large"
},
"channel": "development"
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
"resourceClass": "medium"
},
"channel": "development"
},
"preview": {
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
"resourceClass": "medium"
},
"channel": "preview"
},
"production": {
"ios": {
"resourceClass": "m-large"
"resourceClass": "medium"
},
"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(),
},
}))
+142 -181
View File
@@ -1,8 +1,19 @@
import {AddressInfo} from 'net'
import os from 'os'
import net from 'net'
import path from 'path'
import fs from 'fs'
import {TestNetwork} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api'
import * as crypto from '@atproto/crypto'
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
import * as plc from '@did-plc/lib'
import {PlcServer, Database as PlcDatabase} from '@did-plc/server'
import {BskyAgent} from '@atproto/api'
const ADMIN_PASSWORD = 'admin-pass'
const SECOND = 1000
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
export interface TestUser {
email: string
@@ -18,72 +29,95 @@ 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 repoSigningKey = await crypto.Secp256k1Keypair.create()
const plcRotationKey = await crypto.Secp256k1Keypair.create()
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}`,
},
plc: {port: port2},
const plcDb = PlcDatabase.mock()
const plcServer = PlcServer.create({db: plcDb})
const plcListener = await plcServer.start()
const plcPort = (plcListener.address() as AddressInfo).port
const plcUrl = `http://localhost:${plcPort}`
const recoveryKey = (await crypto.Secp256k1Keypair.create()).did()
const plcClient = new plc.Client(plcUrl)
const serverDid = await plcClient.createDid({
signingKey: repoSigningKey.did(),
rotationKeys: [recoveryKey, plcRotationKey.did()],
handle: 'localhost',
pds: `http://localhost:${port}`,
signer: plcRotationKey,
})
const pic = fs.readFileSync(
const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32'))
const cfg = new ServerConfig({
debugMode: true,
version: '0.0.0',
scheme: 'http',
hostname: 'localhost',
port,
serverDid,
recoveryKey,
adminPassword: ADMIN_PASSWORD,
inviteRequired,
didPlcUrl: plcUrl,
didCacheMaxTTL: DAY,
didCacheStaleTTL: HOUR,
jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'],
appUrlPasswordReset: 'app://forgot-password',
emailNoReplyAddress: 'noreply@blueskyweb.xyz',
publicUrl: `http://localhost:${port}`,
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
imgUriKey:
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
blobstoreLocation: `${blobstoreLoc}/blobs`,
blobstoreTmp: `${blobstoreLoc}/tmp`,
maxSubscriptionBuffer: 200,
repoBackfillLimitMs: HOUR,
userInviteInterval: 1,
labelerDid: 'did:example:labeler',
labelerKeywords: {},
})
const db =
cfg.dbPostgresUrl !== undefined
? Database.postgres({
url: cfg.dbPostgresUrl,
schema: cfg.dbPostgresSchema,
})
: Database.memory()
await db.migrateToLatestOrThrow()
const blobstore = new MemoryBlobStore()
const pds = PDS.create({
db,
blobstore,
repoSigningKey,
plcRotationKey,
config: cfg,
})
await pds.start()
const pdsUrl = `http://localhost:${port}`
const profilePic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
)
return {
pdsUrl,
mocker: new Mocker(testNet, pdsUrl, pic),
mocker: new Mocker(pds, pdsUrl, profilePic),
async close() {
await testNet.pds.server.destroy()
await testNet.plc.server.destroy()
await pds.destroy()
await plcServer.destroy()
},
}
}
@@ -93,25 +127,13 @@ class Mocker {
users: Record<string, TestUser> = {}
constructor(
public testNet: TestNetwork,
public pds: PDS,
public service: string,
public pic: Uint8Array,
public profilePic: 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 +152,7 @@ class Mocker {
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1},
{
headers: this.pds.adminAuthHeaders('admin'),
headers: {authorization: `Basic ${btoa(`admin:${ADMIN_PASSWORD}`)}`},
encoding: 'application/json',
},
)
@@ -143,7 +165,7 @@ class Mocker {
password: 'hunter2',
})
await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.pic, {
const blob = await agent.uploadBlob(this.profilePic, {
encoding: 'image/jpeg',
})
return {
@@ -199,26 +221,6 @@ class Mocker {
}
return await agent.post({
text,
langs: ['en'],
createdAt: new Date().toISOString(),
})
}
async createImagePost(user: string, text: string) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
const blob = await agent.uploadBlob(this.pic, {
encoding: 'image/jpeg',
})
return await agent.post({
text,
langs: ['en'],
embed: {
$type: 'app.bsky.embed.images',
images: [{image: blob.data.blob, alt: ''}],
},
createdAt: new Date().toISOString(),
})
}
@@ -235,7 +237,6 @@ class Mocker {
return await agent.post({
text,
embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
langs: ['en'],
createdAt: new Date().toISOString(),
})
}
@@ -252,7 +253,6 @@ class Mocker {
return await agent.post({
text,
reply: {root: {uri, cid}, parent: {uri, cid}},
langs: ['en'],
createdAt: new Date().toISOString(),
})
}
@@ -265,72 +265,29 @@ class Mocker {
return await agent.like(uri, cid)
}
async createFeed(user: string, rkey: string, posts: string[]) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
const fgUri = AtUri.make(
this.users[user].did,
'app.bsky.feed.generator',
rkey,
)
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},
{
did: fg1.did,
displayName: rkey,
description: 'all my fav stuff',
avatar: avatarRes.data.blob,
createdAt: new Date().toISOString(),
},
)
}
async createInvite(forAccount: string) {
const agent = new BskyAgent({service: this.agent.service})
await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount},
{
headers: this.pds.adminAuthHeaders('admin'),
encoding: 'application/json',
},
)
}
async labelAccount(label: string, user: string) {
const did = this.users[user]?.did
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 +302,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> {
@@ -419,8 +380,8 @@ const checkAvailablePort = (port: number) =>
})
})
async function getPort(start = 3000) {
for (let i = start; i < 65000; i++) {
async function getPort() {
for (let i = 3000; i < 65000; i++) {
if (await checkAvailablePort(i)) {
return i
}
-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
+55 -99
View File
@@ -1,59 +1,46 @@
{
"name": "bsky.app",
"version": "1.56.0",
"version": "1.31.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.3.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",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2",
"@gorhom/bottom-sheet": "^4.4.7",
"@mattermost/react-native-paste-input": "^0.6.0",
"@miblanchard/react-native-slider": "^2.2.0",
"@notifee/react-native": "^7.4.0",
"@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-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.4.10",
"@react-native-community/datetimepicker": "6.7.3",
"@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
@@ -62,50 +49,44 @@
"@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",
"@tiptap/extension-history": "^2.0.3",
"@tiptap/extension-link": "^2.0.0-beta.220",
"@tiptap/extension-mention": "^2.0.0-beta.220",
"@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",
"eslint-plugin-react-native-a11y": "^3.3.0",
"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-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",
@@ -113,8 +94,6 @@
"lodash.debounce": "^4.0.8",
"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,58 +101,51 @@
"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-background-fetch": "^4.1.8",
"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",
"react-native-gesture-handler": "~2.9.0",
"react-native-get-random-values": "^1.8.0",
"react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"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-reanimated": "^3.3.0",
"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.13.1",
"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/pds": "^0.1.10",
"@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",
@@ -184,51 +156,41 @@
"@types/lodash.debounce": "^4.0.7",
"@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.1.2",
"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": "^17"
},
"jest": {
"preset": "jest-expo/ios",
@@ -244,11 +206,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)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
@@ -278,8 +237,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"lint-staged": {
"*{.js,.jsx,.ts,.tsx}": "yarn eslint --fix"
}
}
@@ -51,7 +51,8 @@ index 0000000..8c9c699
+minAndroidGradlePluginVersion=1.0.0
diff --git a/node_modules/@mattermost/react-native-paste-input/android/build/intermediates/compile_r_class_jar/debug/R.jar b/node_modules/@mattermost/react-native-paste-input/android/build/intermediates/compile_r_class_jar/debug/R.jar
new file mode 100644
index 0000000..e69de29
index 0000000..c0106e0
Binary files /dev/null and b/node_modules/@mattermost/react-native-paste-input/android/build/intermediates/compile_r_class_jar/debug/R.jar differ
diff --git a/node_modules/@mattermost/react-native-paste-input/android/build/intermediates/compile_symbol_list/debug/R.txt b/node_modules/@mattermost/react-native-paste-input/android/build/intermediates/compile_symbol_list/debug/R.txt
new file mode 100644
index 0000000..7c9d30e
-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);
-17
View File
@@ -1,17 +0,0 @@
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
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
@@ -266,11 +266,10 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
- (void)textViewDidChangeSelection:(__unused UITextView *)textView
{
- if (_lastStringStateWasUpdatedWith && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
+ if (![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
[self textViewDidChange:_backedTextInputView];
_ignoreNextTextInputCall = YES;
}
- _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) {
+47 -109
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 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 view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from './view/shell'
import * as notifee from 'lib/notifee'
import * as analytics from 'lib/analytics'
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)
notifee.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)
+31 -101
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 analytics from 'lib/analytics/analytics'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import * as view from './view/index'
import * as analytics from 'lib/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from './view/shell/index'
import {ToastContainer} from './view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {defaultLocale, dynamicActivate} from './locale/i18n'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
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
+77 -150
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,
@@ -10,10 +10,7 @@ import {
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs'
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
import {
HomeTabNavigatorParams,
SearchTabNavigatorParams,
@@ -32,26 +29,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 +60,8 @@ 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 {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {getRoutingInstrumentation} from 'lib/sentry'
import {bskyTitle} from 'lib/strings/headings'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -91,151 +84,133 @@ 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}
options={({route}) => ({
title: title(`@${route.params.name}`),
animation: 'none',
})}
component={ProfileScreen}
options={({route}) => ({title: title(`@${route.params.name}`)})}
/>
<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}
options={{title: title('Home Feed Preferences')}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences')}}
/>
</>
)
}
@@ -245,37 +220,27 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator() {
const tabBar = React.useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
),
[],
)
const tabBar = React.useCallback(props => <BottomBar {...props} />, [])
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>
)
}
function HomeTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
return (
<HomeTab.Navigator
screenOptions={{
@@ -285,7 +250,7 @@ function HomeTabNavigator() {
animationDuration: 250,
contentStyle,
}}>
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
<HomeTab.Screen name="Home" component={HomeScreen} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
@@ -302,7 +267,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 +284,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 +303,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 +325,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 +355,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 +434,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 +455,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') {
@@ -517,8 +469,7 @@ function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
}
}
// returns a promise that resolves after the state reset is complete
function reset(): Promise<void> {
function reset() {
if (navigationRef.isReady()) {
navigationRef.dispatch(
CommonActions.reset({
@@ -526,18 +477,6 @@ function reset(): Promise<void> {
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
}),
)
return Promise.race([
timeout(1e3),
new Promise<void>(resolve => {
const handler = () => {
resolve()
navigationRef.removeListener('state', handler)
}
navigationRef.addListener('state', handler)
}),
])
} else {
return Promise.resolve()
}
}
@@ -583,18 +522,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 -44
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, {createContext, useContext, useMemo} from 'react'
import {TextStyle, useColorScheme, ViewStyle} from 'react-native'
import {darkTheme, defaultTheme} from './themes'
export type ColorScheme = 'light' | 'dark'
@@ -85,7 +78,6 @@ export interface Theme {
}
export interface ThemeProviderProps {
children?: ReactNode
theme?: 'light' | 'dark' | 'system'
}
@@ -93,44 +85,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>
}
+118
View File
@@ -0,0 +1,118 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {
createClient,
AnalyticsProvider,
useAnalytics as useAnalyticsOrig,
} from '@segment/analytics-react-native'
import {RootStoreModel, AppInfo} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
import {sha256} from 'js-sha256'
const segmentClient = createClient({
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
trackAppLifecycleEvents: false,
})
export function useAnalytics() {
const store = useStores()
const methods = useAnalyticsOrig()
return React.useMemo(() => {
if (store.session.hasSession) {
return methods
}
// dont send analytics pings for anonymous users
return {
screen: () => {},
track: () => {},
identify: () => {},
flush: () => {},
group: () => {},
alias: () => {},
reset: () => {},
}
}, [store, methods])
}
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()
}
}
})
// NOTE
// 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(() => {
if (AppState.currentState !== 'active') {
store.log.debug('Prevented a metrics ping while the app was backgrounded')
return
}
const context = segmentClient.context.get()
if (typeof context?.app === 'undefined') {
store.log.debug('Aborted metrics ping due to unavailable context')
return
}
const oldAppInfo = store.appInfo
const newAppInfo = context.app as AppInfo
store.setAppInfo(newAppInfo)
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
if (store.session.hasSession) {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
}
} else if (newAppInfo.version !== oldAppInfo.version) {
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,
})
}
})
let lastState: AppStateStatus = AppState.currentState
AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'active' && lastState !== 'active') {
const context = segmentClient.context.get()
segmentClient.track('Application Opened', {
from_background: true,
version: context?.app?.version,
build: context?.app?.build,
})
} else if (state !== 'active' && lastState === 'active') {
segmentClient.track('Application Backgrounded')
}
lastState = state
})
}
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
)
}
@@ -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()
}
}
-146
View File
@@ -1,146 +0,0 @@
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 {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 methods: ClientMethods = useAnalyticsOrig()
return React.useMemo(() => {
if (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
identify: methods.identify,
flush: methods.flush,
group: methods.group,
alias: methods.alias,
reset: methods.reset,
}
}
// dont send analytics pings for anonymous users
return {
screen: () => Promise<void>,
track: () => Promise<void>,
identify: () => Promise<void>,
flush: () => Promise<void>,
group: () => Promise<void>,
alias: () => Promise<void>,
reset: () => Promise<void>,
}
}, [hasSession, 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()
}
})
// NOTE
// 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 () => {
if (AppState.currentState !== 'active') {
logger.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')
return
}
const oldAppInfo = await readAppInfo()
const newAppInfo = context.app as AppInfo
writeAppInfo(newAppInfo)
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
} else if (newAppInfo.version !== oldAppInfo.version) {
segmentClient.track('Application Updated', {
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
AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'active' && lastState !== 'active') {
const context = segmentClient.context.get()
segmentClient.track('Application Opened', {
from_background: true,
version: context?.app?.version,
build: context?.app?.build,
})
} else if (state !== 'active' && lastState === 'active') {
segmentClient.track('Application Backgrounded')
}
lastState = state
})
}
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<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
}
-156
View File
@@ -1,156 +0,0 @@
export type TrackEvent = (
event: keyof TrackPropertiesMap,
properties?: TrackPropertiesMap[keyof TrackPropertiesMap],
) => Promise<void>
export type ScreenEvent = (
name: keyof ScreenPropertiesMap,
properties?: ScreenPropertiesMap[keyof ScreenPropertiesMap],
) => Promise<void>
interface TrackPropertiesMap {
// LOGIN / SIGN UP events
'Sign In': {resumedSession: boolean} // CAN BE SERVER
'Create Account': {} // CAN BE SERVER
'Try Create Account': {}
'Create Account Successfully': {}
'Signin:PressedForgotPassword': {}
'Signin:PressedSelectService': {}
// COMPOSER / CREATE POST events
'Create Post': {imageCount: string} // CAN BE SERVER
'Composer:PastedPhotos': {}
'Composer:CameraOpened': {}
'Composer:GalleryOpened': {}
'HomeScreen:PressCompose': {}
'ProfileScreen:PressCompose': {}
// EDIT PROFILE events
'EditHandle:ViewCustomForm': {}
'EditHandle:ViewProvidedForm': {}
'EditHandle:SetNewHandle': {}
'EditProfile:AvatarSelected': {}
'EditProfile:BannerSelected': {}
'EditProfile:Save': {} // CAN BE SERVER
// FEED events
'Feed:onRefresh': {}
'Feed:onEndReached': {}
// POST events
'Post:Like': {} // CAN BE SERVER
'Post:Unlike': {} // CAN BE SERVER
'Post:Repost': {} // CAN BE SERVER
'Post:Unrepost': {} // CAN BE SERVER
'Post:Delete': {} // CAN BE SERVER
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
// FEED ITEM events
'FeedItem:PostReply': {} // CAN BE SERVER
'FeedItem:PostRepost': {} // CAN BE SERVER
'FeedItem:PostLike': {} // CAN BE SERVER
'FeedItem:PostDelete': {} // CAN BE SERVER
'FeedItem:ThreadMute': {} // CAN BE SERVER
// PROFILE events
'Profile:Follow': {
username: string
}
'Profile:Unfollow': {
username: string
}
// PROFILE HEADER events
'ProfileHeader:EditProfileButtonClicked': {}
'ProfileHeader:FollowersButtonClicked': {
handle: string
}
'ProfileHeader:FollowsButtonClicked': {
handle: string
}
'ProfileHeader:ShareButtonClicked': {}
'ProfileHeader:MuteAccountButtonClicked': {}
'ProfileHeader:UnmuteAccountButtonClicked': {}
'ProfileHeader:ReportAccountButtonClicked': {}
'ProfileHeader:AddToListsButtonClicked': {}
'ProfileHeader:BlockAccountButtonClicked': {}
'ProfileHeader:UnblockAccountButtonClicked': {}
'ProfileHeader:FollowButtonClicked': {}
'ProfileHeader:UnfollowButtonClicked': {}
'ProfileHeader:SuggestedFollowsOpened': {}
'ProfileHeader:SuggestedFollowFollowed': {}
'ViewHeader:MenuButtonClicked': {}
// SETTINGS events
'Settings:SwitchAccountButtonClicked': {}
'Settings:AddAccountButtonClicked': {}
'Settings:ChangeHandleButtonClicked': {}
'Settings:InvitecodesButtonClicked': {}
'Settings:ContentfilteringButtonClicked': {}
'Settings:SignOutButtonClicked': {}
'Settings:ContentlanguagesButtonClicked': {}
// MENU events
'Menu:ItemClicked': {url: string}
'Menu:FeedbackClicked': {}
'Menu:HelpClicked': {}
// MOBILE SHELL events
'MobileShell:MyProfileButtonPressed': {}
'MobileShell:HomeButtonPressed': {}
'MobileShell:SearchButtonPressed': {}
'MobileShell:NotificationsButtonPressed': {}
'MobileShell:FeedsButtonPressed': {}
// NOTIFICATIONS events
'Notificatons:OpenApp': {}
// 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
// CUSTOM FEED events
'CustomFeed:Save': {}
'CustomFeed:Unsave': {}
'CustomFeed:Like': {}
'CustomFeed:Unlike': {}
'CustomFeed:Share': {}
'CustomFeed:Pin': {
uri: string
name: string
}
'CustomFeed:Unpin': {
uri: string
name: string
}
'CustomFeed:Reorder': {
uri: string
name: string
index: number
}
'CustomFeed:LoadMore': {}
'MultiFeed:onEndReached': {}
'MultiFeed:onRefresh': {}
// MODERATION events
'Moderation:ContentfilteringButtonClicked': {}
// ONBOARDING events
'Onboarding:Begin': {}
'Onboarding:Complete': {}
'Onboarding:Skipped': {}
'Onboarding:Reset': {}
'Onboarding:SuggestedFollowFollowed': {}
}
interface ScreenPropertiesMap {
Login: {}
CreateAccount: {}
'Choose Account': {}
'Signin:ForgotPassword': {}
'Signin:SetNewPasswordForm': {}
'Signin:PasswordUpdatedForm': {}
Feed: {}
Notifications: {}
Profile: {}
'Profile:Preview': {}
Settings: {}
AppPasswords: {}
Moderation: {}
BlockedAccounts: {}
MutedAccounts: {}
SavedFeeds: {}
}
+23 -20
View File
@@ -8,29 +8,23 @@
* version of the app.
*/
import {useState, useCallback, useEffect} from 'react'
import {useState, useCallback} from 'react'
import {BskyAgent} from '@atproto/api'
import * as Storage from 'lib/storage'
import {isWeb} from 'platform/detection'
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
const [enabled, setEnabled] = useState<boolean>(false)
useEffect(() => {
async function check() {
if (await isEnabled()) {
setEnabled(true)
}
}
check()
}, [])
const [enabled, setEnabled] = useState<boolean>(isEnabled())
const toggle = useCallback(() => {
if (!isWeb || typeof window === 'undefined') {
return
}
if (!enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
localStorage.setItem('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
setEnabled(true)
} else {
Storage.remove('set-header-x-appview-proxy')
localStorage.removeItem('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
setEnabled(false)
}
@@ -40,21 +34,30 @@ export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
}
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
if (!isWeb || typeof window === 'undefined') {
return
}
if (enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
localStorage.setItem('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
} else {
Storage.remove('set-header-x-appview-proxy')
localStorage.removeItem('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
}
}
export async function applyDebugHeader(agent: BskyAgent) {
if (await isEnabled()) {
export function applyDebugHeader(agent: BskyAgent) {
if (!isWeb) {
return
}
if (isEnabled()) {
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
}
}
async function isEnabled() {
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
function isEnabled() {
if (!isWeb || typeof window === 'undefined') {
return false
}
return localStorage.getItem('set-header-x-appview-proxy') === 'yes'
}
+54 -153
View File
@@ -1,11 +1,7 @@
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import lande from 'lande'
import {hasProp} from 'lib/type-guards'
import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
export type FeedTunerFn = (
@@ -18,12 +14,6 @@ export class FeedViewPostsSlice {
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
}`
}
get uri() {
if (this.isFlattenedReply) {
return this.items[1].post.uri
@@ -65,11 +55,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 +82,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 +96,22 @@ export class FeedTuner {
tune(
feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: 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
@@ -200,49 +147,15 @@ export class FeedTuner {
}
}
if (!dryRun) {
for (const slice of slices) {
for (const item of slice.items) {
this.seenUris.add(item.post.uri)
}
for (const slice of slices) {
for (const item of slice.items) {
this.seenUris.add(item.post.uri)
}
}
return slices
}
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
if (slices[i].isReply) {
slices.splice(i, 1)
}
}
return slices
}
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const reason = slices[i].rootItem.reason
if (AppBskyFeedDefs.isReasonRepost(reason)) {
slices.splice(i, 1)
}
}
return slices
}
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const embed = slices[i].rootItem.post.embed
if (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
) {
slices.splice(i, 1)
}
}
return slices
}
static dedupReposts(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
@@ -265,68 +178,59 @@ export class FeedTuner {
return slices
}
static thresholdRepliesOnly({
userDid,
minLikes,
followedOnly,
}: {
userDid: string
minLikes: number
followedOnly: boolean
}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
// remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
continue
}
const item = slice.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)) {
slices.splice(i, 1)
}
static likedRepliesOnly(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] {
// remove any replies without at least 2 likes
for (let i = slices.length - 1; i >= 0; i--) {
if (slices[i].isFullThread || !slices[i].isReply) {
continue
}
const item = slices[i].rootItem
const isRepost = Boolean(item.reason)
if (!isRepost && (item.post.likeCount || 0) < 2) {
slices.splice(i, 1)
}
return slices
}
return slices
}
/**
* This function filters a list of FeedViewPostsSlice items based on whether they contain text in a
* preferred language.
* @param {string[]} preferredLangsCode2 - An array of preferred language codes in ISO 639-1 or ISO 639-2 format.
* @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and
* returns an array of `FeedViewPostsSlice` objects.
*/
static preferredLangOnly(preferredLangsCode2: string[]) {
static preferredLangOnly(langsCode2: string[]) {
const langsCode3 = langsCode2.map(l => LANGUAGES_MAP_CODE2[l]?.code3 || l)
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
// early return if no languages have been specified
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
if (!langsCode2.length) {
return slices
}
for (let i = slices.length - 1; i >= 0; i--) {
let hasPreferredLang = false
for (const item of slices[i].items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
if (
hasProp(item.post.record, 'text') &&
typeof item.post.record.text === 'string'
) {
// Treat empty text the same as no text.
if (item.post.record.text.length === 0) {
hasPreferredLang = true
break
}
const res = lande(item.post.record.text)
if (langsCode3.includes(res[0][0])) {
hasPreferredLang = true
break
}
} else {
// no text? roll with it
hasPreferredLang = true
break
}
}
// if item does not fit preferred language, remove it
if (!hasPreferredLang) {
slices.splice(i, 1)
}
@@ -338,10 +242,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'
)
}
-24
View File
@@ -1,24 +0,0 @@
import {
AppBskyFeedDefs,
AppBskyFeedPost,
ComAtprotoRepoStrongRef,
} from '@atproto/api'
/**
* HACK
* The server doesnt seem to be correctly giving the notFound view yet
* so I'm adding it manually for now
* -prf
*/
export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) {
const record = post.record as AppBskyFeedPost.Record
if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) {
post.embed = {
$type: 'app.bsky.embed.record#view',
record: {
$type: 'app.bsky.embed.record#viewNotFound',
uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri,
},
}
}
}
+30 -44
View File
@@ -4,18 +4,15 @@ 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 +22,32 @@ 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
}
const res = await store.agent.resolveHandle({
handle: didOrHandle,
})
return res.data.did
}
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},
)
@@ -53,12 +63,11 @@ 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
@@ -66,16 +75,15 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
| AppBskyEmbedRecordWithMedia.Main
| undefined
let reply
let rt = new RichText(
{text: opts.rawText.trimEnd()},
const rt = new RichText(
{text: opts.rawText.trim()},
{
cleanNewlines: true,
},
)
opts.onStateChange?.('Processing...')
await rt.detectFacets(agent)
rt = shortenLinks(rt)
await rt.detectFacets(store.agent)
// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
@@ -88,7 +96,6 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return true
})
// add quote embed if present
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.record',
@@ -99,19 +106,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
} as AppBskyEmbedRecord.Main
}
// add image embed if present
if (opts.images?.length) {
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
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},
})
}
@@ -132,7 +136,6 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
embed = opts.extLink.embed
@@ -151,13 +154,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,
)
@@ -193,10 +197,9 @@ 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,
})
@@ -212,30 +215,13 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
// set labels
let labels: ComAtprotoLabelDefs.SelfLabels | undefined
if (opts.labels?.length) {
labels = {
$type: 'com.atproto.label.defs#selfLabels',
values: opts.labels.map(val => ({val})),
}
}
// add top 3 languages from user preferences if langs is provided
let langs = opts.langs
if (opts.langs) {
langs = opts.langs.slice(0, 3)
}
try {
opts.onStateChange?.('Posting...')
return await agent.post({
return await store.agent.post({
text: rt.text,
facets: rt.facets,
reply,
embed,
langs,
labels,
})
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
-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)
}
}
-3
View File
@@ -1,3 +0,0 @@
export function timeout(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms))
}
-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
}
+18
View File
@@ -0,0 +1,18 @@
import BackgroundFetch, {
BackgroundFetchStatus,
} from 'react-native-background-fetch'
export function configure(
handler: (taskId: string) => Promise<void>,
timeoutHandler: (taskId: string) => void,
): Promise<BackgroundFetchStatus> {
return BackgroundFetch.configure(
{minimumFetchInterval: 15},
handler,
timeoutHandler,
)
}
export function finish(taskId: string) {
return BackgroundFetch.finish(taskId)
}
+13
View File
@@ -0,0 +1,13 @@
type BackgroundFetchStatus = 0 | 1 | 2
export async function configure(
_handler: (taskId: string) => Promise<void>,
_timeoutHandler: (taskId: string) => Promise<void>,
): Promise<BackgroundFetchStatus> {
// TODO
return 0
}
export function finish(_taskId: string) {
// TODO
}

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