Compare commits
344 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e52a1cd7 | |||
| f9944b55e2 | |||
| 9fb20517ae | |||
| 4bf1719092 | |||
| 85c26fb5a8 | |||
| b42806a6e0 | |||
| adb5ce2961 | |||
| b5f36dc7a3 | |||
| b2d4771b5f | |||
| ddf0a1cced | |||
| 40752982da | |||
| 68c809b09a | |||
| 21f7a2a247 | |||
| 97ce9a73d2 | |||
| 33ea9ccef7 | |||
| 33a87ca22e | |||
| ef1d6ee490 | |||
| 1eb623ee2f | |||
| d5c7b8ceb8 | |||
| bae429202a | |||
| 0747193977 | |||
| 4a4106ba45 | |||
| 2577cbf252 | |||
| 9f33badfff | |||
| e18e7ce34a | |||
| 9826e7c7b9 | |||
| 515c9d5529 | |||
| 871ded193c | |||
| e1dcedd87e | |||
| 1acf4969c5 | |||
| dbbd71c6d0 | |||
| 84ee64025f | |||
| 6c11c0b81d | |||
| 7ebf1ed371 | |||
| 53afafb04f | |||
| 3e5a64b454 | |||
| 46f3265936 | |||
| a1a61ef2e5 | |||
| 927cee18d9 | |||
| 7fa0708e0e | |||
| c13b6946ba | |||
| ee80cae75f | |||
| 1781b3aab7 | |||
| 3426b24f67 | |||
| 4a4be1de1a | |||
| 32fbb9dba7 | |||
| c6e28f88e5 | |||
| db0de651ba | |||
| 6848ade046 | |||
| de94acb6c8 | |||
| 78b374b43d | |||
| 01e25c912c | |||
| 8e9cf182c2 | |||
| 9042f503c2 | |||
| abfd9a8c0b | |||
| f447eaa669 | |||
| d5ccbd76d5 | |||
| eba9f8a166 | |||
| 2a1edab6d4 | |||
| 4431cfe2d2 | |||
| 997918547c | |||
| c2a1cf4e56 | |||
| d98e3a8b45 | |||
| d40d931d86 | |||
| 5dd673d1ff | |||
| 10deddd0ad | |||
| a48c213af5 | |||
| 0b44af38ea | |||
| aad8d12ede | |||
| 209d8b683c | |||
| aa085b0b14 | |||
| 4d450da194 | |||
| e878da04a1 | |||
| fc28fc639f | |||
| d68b4ca856 | |||
| 60c0db03f7 | |||
| d47ff542da | |||
| bc2c44cb98 | |||
| 098f4b526e | |||
| f452ce74f4 | |||
| 832b05b64a | |||
| acb003d7e1 | |||
| ce0f796008 | |||
| b12cfbe90b | |||
| d85bbc1a28 | |||
| c43a0d8160 | |||
| c39352f859 | |||
| 0122cdb0b6 | |||
| 9aff2597cf | |||
| 77b21dfb1c | |||
| 9cff82448f | |||
| 34f9d12987 | |||
| d2f11f8e36 | |||
| 3b78d3227f | |||
| 64153067e3 | |||
| 8366fe2c4a | |||
| b74a0e0fe2 | |||
| bd7db8af26 | |||
| 19f8389fc7 | |||
| 0168aada37 | |||
| 4ec5fabdd1 | |||
| 260b03a05c | |||
| eb7306b165 | |||
| 9278822088 | |||
| 2ba0c6a711 | |||
| c6da98c818 | |||
| b1a1bae02e | |||
| 2ca673d7f5 | |||
| a76fb78d53 | |||
| 847d4d19b5 | |||
| 6598fca79d | |||
| 588659380a | |||
| 001b29a7e3 | |||
| fd5bbb2769 | |||
| 2f157c152a | |||
| 90b18cf004 | |||
| 64410efafe | |||
| 72e3763446 | |||
| 04fda0f142 | |||
| 1f60e1a748 | |||
| 2aae37d67b | |||
| 2e5f73ff61 | |||
| 3e340b336e | |||
| 65b83e0d6b | |||
| f5a78c2c86 | |||
| d4a234d5ce | |||
| cd3b0e54fb | |||
| 16763d1d41 | |||
| cc1c9ab305 | |||
| d57c8f7bca | |||
| 1aa1b69a94 | |||
| e181bae9a6 | |||
| 83bb5c1710 | |||
| a427010629 | |||
| b030d94a64 | |||
| 6d4ad59416 | |||
| d87c232660 | |||
| 6325eff938 | |||
| 5d59ab2ac0 | |||
| e941da646d | |||
| 1185ac4053 | |||
| 42723dfaf6 | |||
| 95736a5168 | |||
| b0873cae97 | |||
| 83b3b5e1c0 | |||
| 5f476851a3 | |||
| be2f319a34 | |||
| e9f30894b1 | |||
| 150fe421d6 | |||
| 41b53ca37b | |||
| b7697f08d6 | |||
| 88b95df200 | |||
| 1b3b4c6692 | |||
| 28f5a8e44d | |||
| 4fa576cb30 | |||
| 146132a080 | |||
| 8584009bae | |||
| 28b692a118 | |||
| 1abe5270dd | |||
| 44985d2312 | |||
| 8aabbb9b6f | |||
| 7931787dd7 | |||
| 8a5f9cd43d | |||
| 335061f763 | |||
| e837a499f3 | |||
| 5a945c2024 | |||
| 68dd3210d1 | |||
| f5e9e4af89 | |||
| 6df1bcad31 | |||
| 498c3e2c27 | |||
| acc98ac1ab | |||
| 95e8a6bf41 | |||
| 8f8bed9a1c | |||
| d33aaf57ad | |||
| a1c040cd79 | |||
| 5665968f72 | |||
| cd96f8dcc8 | |||
| 6db7113108 | |||
| 971c8025e6 | |||
| 753fb8bfbc | |||
| 8b8fba7284 | |||
| 1af8e83d53 | |||
| d2c253a284 | |||
| 859588c3f6 | |||
| da8499c881 | |||
| 63527493fd | |||
| 9c4374f66a | |||
| 3c4899b3c4 | |||
| 255beb0c1f | |||
| 157daad605 | |||
| ea885339cf | |||
| 3118e3e933 | |||
| 754663e5c3 | |||
| 0672451ddc | |||
| 763a50692f | |||
| e3de91788e | |||
| 8593f41f36 | |||
| 188d4893f9 | |||
| 84b7edd9db | |||
| 0e8d564555 | |||
| 01870883d6 | |||
| 50f811666a | |||
| e643c43459 | |||
| 37252e90b4 | |||
| bd8dac0154 | |||
| 9a86f0dae4 | |||
| a88ac217c2 | |||
| b9268d503a | |||
| 9c72fcc9b4 | |||
| 4977a5d2a3 | |||
| 2b6fe4cb50 | |||
| 9b5bd53f84 | |||
| 9e8b14f710 | |||
| 0090371011 | |||
| 21371081c6 | |||
| f8c611118e | |||
| 977f9228f8 | |||
| 57c10cbcab | |||
| 27cb1bd276 | |||
| bcd63e710c | |||
| 01775e5bff | |||
| 731d84faaa | |||
| 775aa87540 | |||
| 6d73ed96e1 | |||
| adc5b75b2b | |||
| 44a00e1b7e | |||
| c10cb8e972 | |||
| 8a93321fb1 | |||
| 69209c988f | |||
| a5b89dffa6 | |||
| 00595591c4 | |||
| 2963fb4c6d | |||
| 138e6d9b97 | |||
| 82a17db594 | |||
| b76b56b4a4 | |||
| fdc4ae1f7b | |||
| b6d239aa37 | |||
| 63f0b2f0e5 | |||
| dd5697329f | |||
| 19adb10de1 | |||
| 565c1a0510 | |||
| 52f3810e81 | |||
| 411c1581f6 | |||
| e3e91816d1 | |||
| 9a3fa512eb | |||
| 764c7cd569 | |||
| be8084ae10 | |||
| 161746519c | |||
| 419ac2d0df | |||
| 3e96373903 | |||
| 4cd3ddecad | |||
| f16cc9537a | |||
| ba393cd642 | |||
| d2e65d2a7f | |||
| 4ac82536c9 | |||
| 4bec7c1d85 | |||
| 04992f14f1 | |||
| a498acab6e | |||
| f9cab178b9 | |||
| 59dcedeea2 | |||
| 8bc8dcc094 | |||
| 3fa9b6daba | |||
| 9446c67880 | |||
| a29f10aefe | |||
| 8ed6e72ea4 | |||
| 05d1d8d8a4 | |||
| cd8ae1298e | |||
| b847917969 | |||
| 5e765bf1cb | |||
| 5d9534ca72 | |||
| 3a90b479fd | |||
| 22b7964b5e | |||
| 5710140a73 | |||
| bf37913701 | |||
| 6bee7d518b | |||
| 461c1da6de | |||
| 40e030793a | |||
| 2619c55c10 | |||
| 742440c22d | |||
| 8f0f3be8fb | |||
| bcef7400db | |||
| 1c460c40f4 | |||
| cc2838761b | |||
| 2c60a0328d | |||
| 5ee754e6f9 | |||
| a231fdf64e | |||
| b38629b9b5 | |||
| e2f0770b88 | |||
| b2dd4ea0f5 | |||
| 3f1b313fa4 | |||
| c4cf288296 | |||
| edfd326069 | |||
| 84e065667a | |||
| c77fd588e9 | |||
| 0ca28ccca6 | |||
| 08fe7fb084 | |||
| a3cb12f584 | |||
| b15a435069 | |||
| a0dca81a74 | |||
| 953ae9c096 | |||
| 37a70f0a67 | |||
| 462022741d | |||
| 4654a9a45e | |||
| a5981e127f | |||
| 7410bcd888 | |||
| c6f321862b | |||
| a115c17a09 | |||
| 766521486c | |||
| c1c02f480b | |||
| 93d5ba1820 | |||
| 9bd9d40306 | |||
| 6487a87542 | |||
| 8ab5eb6583 | |||
| 32b9648931 | |||
| b6317d4ce7 | |||
| acad8cb455 | |||
| 473a9fc73e | |||
| b4931fa185 | |||
| 8e1d0b0396 | |||
| 54706a0437 | |||
| fcea27ea0b | |||
| 54118fe478 | |||
| 2b13dc0f3a | |||
| 548ec6c82d | |||
| 16b265a861 | |||
| 3aadc43c89 | |||
| fb1199e21d | |||
| 0a50df1cce | |||
| 17fcc2f25c | |||
| 13c11801b9 | |||
| 09a445d804 | |||
| d3f525ab28 | |||
| 88357d5c82 | |||
| 4cc2695d84 | |||
| 257c15ec1f | |||
| 2198aab8b2 | |||
| ee9cf7402f | |||
| 4342d0f84c | |||
| 26f4e313ad | |||
| c7cad72368 | |||
| c219fbe57a | |||
| 43d1f002f5 | |||
| 55ad56f689 | |||
| 4a59178cd2 |
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
simulator: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 14',
|
||||
type: 'iPhone 15 Pro',
|
||||
},
|
||||
},
|
||||
attached: {
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# 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
|
||||
+13
-9
@@ -2,13 +2,19 @@ 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'],
|
||||
plugins: ['@typescript-eslint', 'detox', 'react'],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party',
|
||||
'ios',
|
||||
'android',
|
||||
@@ -16,13 +22,11 @@ module.exports = {
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'bskyweb',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.js', '*.mjs', '*.ts', '*.tsx'],
|
||||
rules: {
|
||||
semi: [2, 'never'],
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.20'
|
||||
go-version: '1.21'
|
||||
- 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.20'
|
||||
go-version: '1.21'
|
||||
- name: Dummy JS File
|
||||
run: touch bskyweb/static/js/blah.js
|
||||
- name: Lint
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Yarn install
|
||||
run: yarn
|
||||
run: yarn --frozen-lockfile
|
||||
- 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
|
||||
run: yarn --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: |
|
||||
yarn test --forceExit
|
||||
|
||||
+7
-1
@@ -96,4 +96,10 @@ ios/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.*
|
||||
.env.*
|
||||
|
||||
# Firebase (Android) Google services
|
||||
google-services.json
|
||||
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.20-bullseye AS build-env
|
||||
FROM golang:1.21-bullseye AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
@@ -13,6 +13,7 @@ ENV GODEBUG="netdns=go"
|
||||
ENV GOOS="linux"
|
||||
ENV GOARCH="amd64"
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT="loopvar"
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2023 Bluesky PBLLC
|
||||
Copyright 2023 Bluesky PBC
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
# Bluesky Social App
|
||||
|
||||
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).
|
||||
Welcome friends! This is the codebase for the Bluesky Social app.
|
||||
|
||||
Get the app itself:
|
||||
|
||||
- **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)**
|
||||
|
||||
Links:
|
||||
## Development Resources
|
||||
|
||||
- [Build instructions](./docs/build.md)
|
||||
- [ATProto repo](https://github.com/bluesky-social/atproto)
|
||||
- [ATProto docs](https://atproto.com)
|
||||
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).
|
||||
|
||||
## Rules & guidelines
|
||||
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
|
||||
|
||||
---
|
||||
The [Build Instructions](./docs/build.md) are a good place to get started with the app itself.
|
||||
|
||||
ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
||||
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:
|
||||
|
||||
---
|
||||
- [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:**
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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"
|
||||
|
||||
+80
-1
@@ -55,7 +55,7 @@ async function main() {
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
@@ -70,6 +70,82 @@ 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')
|
||||
|
||||
@@ -426,6 +502,9 @@ async function main() {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, createServer, sleep} from '../util'
|
||||
|
||||
describe('Composer', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users')
|
||||
await createServer('?users')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
})
|
||||
|
||||
it('Login', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ 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('welcomeScreen'))).toBeVisible()
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Home screen', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&follows&posts')
|
||||
await createServer('?users&follows&posts')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('Login', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
/**
|
||||
* This test is being skipped until we can resolve the detox crash issue
|
||||
* with the side drawer.
|
||||
*/
|
||||
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('invite-codes', () => {
|
||||
let service: string
|
||||
@@ -12,17 +17,13 @@ describe('invite-codes', () => {
|
||||
|
||||
it('I can fetch invite codes', 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('menuItemInviteCodes')).tap()
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eOpenInviteCodesModal')).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('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
})
|
||||
|
||||
it('I can create a new account with the invite code', async () => {
|
||||
@@ -42,19 +43,22 @@ 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('welcomeScreen'))).toBeVisible()
|
||||
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 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 element(by.id('e2eSignOut')).tap()
|
||||
await loginAsAlice()
|
||||
await waitFor(element(by.id('homeScreen')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
await expect(element(by.id('invitedUser'))).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* 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')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
describe('Mute lists', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&follows&labels')
|
||||
await createServer('?users&follows&labels')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
@@ -13,10 +12,8 @@ describe('Mute lists', () => {
|
||||
|
||||
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 loginAsAlice()
|
||||
await element(by.id('e2eGotoModeration')).tap()
|
||||
await element(by.id('mutelistsBtn')).tap()
|
||||
await expect(element(by.id('list-Muted Users'))).toBeVisible()
|
||||
await element(by.id('list-Muted Users')).tap()
|
||||
@@ -114,7 +111,8 @@ describe('Mute lists', () => {
|
||||
|
||||
it('Shows the mutelist on my profile', async () => {
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector-3')).tap()
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await element(by.id('list-Bad Ppl')).tap()
|
||||
})
|
||||
|
||||
@@ -140,22 +138,13 @@ describe('Mute lists', () => {
|
||||
})
|
||||
|
||||
it('Can report a mute list', async () => {
|
||||
await element(by.id('bottomBarHomeBtn')).tap()
|
||||
// Last test leaves us in the list view so we are going back 1 screen to the lists list screen
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
// then to the moderation screen
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
// then to the home screen
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
// then open the drawer to go to settings
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('e2eGotoSettings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await login(service, 'bob.test', 'hunter2')
|
||||
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('reportListBtn')).tap()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, createServer, sleep} from '../util'
|
||||
|
||||
describe('Profile screen', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&posts&feeds')
|
||||
await createServer('?users&posts&feeds')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
@@ -13,13 +12,15 @@ describe('Profile screen', () => {
|
||||
|
||||
it('Login and navigate to my profile', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
})
|
||||
|
||||
it('Can see feeds', async () => {
|
||||
await element(by.id('selector-3')).tap()
|
||||
await expect(element(by.id('feed-alices feed'))).toBeVisible()
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Search screen', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users')
|
||||
await createServer('?users')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
})
|
||||
|
||||
it('Login', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
})
|
||||
|
||||
it('Navigate to another user profile via autocomplete', async () => {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, createServer, sleep} from '../util'
|
||||
|
||||
describe('Self-labeling', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users')
|
||||
await createServer('?users')
|
||||
await openApp({
|
||||
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||
})
|
||||
})
|
||||
|
||||
it('Login', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
})
|
||||
|
||||
@@ -20,7 +19,7 @@ describe('Self-labeling', () => {
|
||||
await element(by.id('composeFAB')).tap()
|
||||
await element(by.id('composerTextInput')).typeText('Post with an image')
|
||||
await element(by.id('openGalleryBtn')).tap()
|
||||
await sleep(1e3)
|
||||
await sleep(3e3)
|
||||
await element(by.id('labelsBtn')).tap()
|
||||
await element(by.id('pornLabelBtn')).tap()
|
||||
await element(by.id('confirmBtn')).tap()
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Shell', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users')
|
||||
await createServer('?users')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('Login', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
})
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
|
||||
|
||||
describe('Thread muting', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&follows')
|
||||
await createServer('?users&follows')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('Login, create a thread, and log out', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
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 login(service, 'bob', 'hunter2')
|
||||
await loginAsBob()
|
||||
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 login(service, 'alice', 'hunter2')
|
||||
|
||||
await loginAsAlice()
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
const bobNotifs = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
@@ -50,14 +42,10 @@ describe('Thread muting', () => {
|
||||
await waitFor(element(by.id('viewHeaderDrawerBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
})
|
||||
|
||||
it('Login, reply to the thread twice, and log out', async () => {
|
||||
await login(service, 'bob', 'hunter2')
|
||||
await loginAsBob()
|
||||
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector-1')).tap()
|
||||
@@ -74,13 +62,10 @@ 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 login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
const bobNotifs = by.id('feedItem-by-bob.test')
|
||||
@@ -93,7 +78,7 @@ describe('Thread muting', () => {
|
||||
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
|
||||
.atIndex(0)
|
||||
.tap()
|
||||
await element(by.text('Mute thread')).tap()
|
||||
await element(by.text('Unmute thread')).tap()
|
||||
|
||||
// TODO
|
||||
// the swipe down to trigger PTR isnt working and I dont want to block on this
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Thread screen', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&follows&thread')
|
||||
await createServer('?users&follows&thread')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('Login & navigate to thread', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await loginAsAlice()
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
await element(by.id('feedItem-by-bob.test')).atIndex(0).tap()
|
||||
await expect(
|
||||
|
||||
@@ -69,6 +69,14 @@ 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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const Slider = {}
|
||||
@@ -0,0 +1,10 @@
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
Native: {
|
||||
ReactNativeTracing: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
})),
|
||||
ReactNavigationInstrumentation: jest.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -175,6 +175,7 @@ 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),
|
||||
@@ -183,7 +184,8 @@ describe('ago', () => {
|
||||
const outputs = [
|
||||
new Date(1671461038).toLocaleDateString(),
|
||||
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
|
||||
'0s',
|
||||
'now',
|
||||
'10s',
|
||||
'10m',
|
||||
'1h',
|
||||
'1d',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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
@@ -0,0 +1,114 @@
|
||||
module.exports = function () {
|
||||
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
|
||||
return {
|
||||
expo: {
|
||||
name: 'Bluesky',
|
||||
slug: 'bluesky',
|
||||
scheme: 'bluesky',
|
||||
owner: 'blueskysocial',
|
||||
version: '1.55.0',
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
splash: {
|
||||
image: './assets/cloud-splash.png',
|
||||
resizeMode: 'cover',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
infoPlist: {
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content.',
|
||||
NSMicrophoneUsageDescription:
|
||||
'Used for posts and other kinds of content.',
|
||||
NSPhotoLibraryAddUsageDescription:
|
||||
'Used to save images to your library.',
|
||||
NSPhotoLibraryUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content',
|
||||
},
|
||||
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'dark-content',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: 44,
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Bluesky",
|
||||
"slug": "bluesky",
|
||||
"scheme": "bluesky",
|
||||
"owner": "blueskysocial",
|
||||
"version": "1.45.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": 32,
|
||||
"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": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0",
|
||||
"kotlinVersion": "1.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-updates",
|
||||
{
|
||||
"username": "blueskysocial"
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "55bd077a-d905-4184-9c7f-94789ba0f302"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"postPublish": [
|
||||
{
|
||||
"file": "sentry-expo/upload-sourcemaps",
|
||||
"config": {
|
||||
"organization": "blueskyweb",
|
||||
"project": "react-native"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -1,7 +1,23 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
const isTestEnv = process.env.NODE_ENV === 'test'
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
@@ -30,5 +46,10 @@ module.exports = function (api) {
|
||||
],
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
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
@@ -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.20+.
|
||||
Install golang. We are generally using v1.21+.
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
|
||||
@@ -34,22 +34,11 @@ func run(args []string) {
|
||||
Action: serve,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
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"},
|
||||
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"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mailmodo-api-key",
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -38,9 +37,7 @@ type Server struct {
|
||||
func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
pdsHost := cctx.String("pds-host")
|
||||
atpHandle := cctx.String("handle")
|
||||
atpPassword := cctx.String("password")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
mailmodoAPIKey := cctx.String("mailmodo-api-key")
|
||||
mailmodoListName := cctx.String("mailmodo-list-name")
|
||||
|
||||
@@ -50,28 +47,12 @@ func serve(cctx *cli.Context) error {
|
||||
// Mailmodo client.
|
||||
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
|
||||
|
||||
// create a new session
|
||||
// TODO: does this work with no auth at all?
|
||||
// create a new session (no auth)
|
||||
xrpcc := &xrpc.Client{
|
||||
Client: cliutil.NewHttpClient(),
|
||||
Host: pdsHost,
|
||||
Auth: &xrpc.AuthInfo{
|
||||
Handle: atpHandle,
|
||||
},
|
||||
Host: appviewHost,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -110,6 +91,11 @@ 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",
|
||||
@@ -125,8 +111,23 @@ func serve(cctx *cli.Context) error {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
}))
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
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")
|
||||
},
|
||||
}))
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
@@ -137,7 +138,6 @@ func serve(cctx *cli.Context) error {
|
||||
//
|
||||
// configure routes
|
||||
//
|
||||
|
||||
// static files
|
||||
staticHandler := http.FileServer(func() http.FileSystem {
|
||||
if debug {
|
||||
@@ -150,21 +150,35 @@ 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("/moderation", server.WebGeneric)
|
||||
@@ -172,8 +186,11 @@ func serve(cctx *cli.Context) error {
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
GOLOG_LOG_LEVEL=info
|
||||
ATP_PDS_HOST=http://localhost:2583
|
||||
ATP_AUTH_HANDLE="admin.test"
|
||||
ATP_AUTH_PASSWORD="admin"
|
||||
ATP_APPVIEW_HOST=http://localhost:2584
|
||||
|
||||
+1
-3
@@ -1,4 +1,2 @@
|
||||
GOLOG_LOG_LEVEL=info
|
||||
ATP_PDS_HOST=https://bsky.social
|
||||
ATP_AUTH_HANDLE=TODO
|
||||
ATP_AUTH_PASSWORD=TODO
|
||||
ATP_APPVIEW_HOST=https://api.bsky.app
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/bluesky-social/social-app/bskyweb
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319
|
||||
|
||||
@@ -31,6 +31,7 @@ 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=
|
||||
@@ -240,6 +241,7 @@ 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=
|
||||
|
||||
@@ -39,6 +39,32 @@
|
||||
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 */
|
||||
@@ -49,12 +75,6 @@
|
||||
-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 {
|
||||
@@ -71,6 +91,9 @@
|
||||
*[data-word-wrap] {
|
||||
word-break: break-word;
|
||||
}
|
||||
*[data-stable-gutters] {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
/* ProseMirror */
|
||||
.ProseMirror {
|
||||
@@ -93,9 +116,9 @@
|
||||
.ProseMirror .mention {
|
||||
color: #0085ff;
|
||||
}
|
||||
.ProseMirror a {
|
||||
.ProseMirror a,
|
||||
.ProseMirror .autolink {
|
||||
color: #0085ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* OLLIE: TODO -- this is not accessible */
|
||||
/* Remove focus state on inputs */
|
||||
@@ -109,6 +132,44 @@
|
||||
.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">
|
||||
|
||||
+16
-11
@@ -8,12 +8,14 @@
|
||||
- brew tap wix/brew
|
||||
- brew install applesimutils
|
||||
- After initial setup:
|
||||
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes
|
||||
- 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
|
||||
- Start the dev servers
|
||||
- `git clone git@github.com:bluesky-social/atproto.git`
|
||||
- `cd atproto`
|
||||
- `yarn`
|
||||
- `cd packages/dev-env && yarn start`
|
||||
- `pnpm i`
|
||||
- `pnpm build`
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
- Run the dev app
|
||||
- iOS: `yarn ios`
|
||||
- Android: `yarn android`
|
||||
@@ -26,20 +28,22 @@
|
||||
- Run once: `yarn e2e:build`
|
||||
- Each test run: `yarn e2e:run`
|
||||
- Tips
|
||||
- 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
|
||||
- Make sure to copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- If the android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- 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 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).)
|
||||
|
||||
### 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!
|
||||
### 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.
|
||||
|
||||
## Go-Server Build
|
||||
|
||||
@@ -117,6 +121,7 @@ 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/)
|
||||
|
||||
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/)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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`.
|
||||
@@ -9,22 +9,37 @@
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "medium"
|
||||
"resourceClass": "m-large"
|
||||
},
|
||||
"channel": "development"
|
||||
},
|
||||
"development-device": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "m-large"
|
||||
},
|
||||
"channel": "development"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "medium"
|
||||
"resourceClass": "m-large"
|
||||
},
|
||||
"channel": "preview"
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"resourceClass": "medium"
|
||||
"resourceClass": "m-large"
|
||||
},
|
||||
"channel": "production"
|
||||
},
|
||||
"dev-android-apk": {
|
||||
"developmentClient": true,
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/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}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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:
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/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 "$@"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-redis-and-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
# ./with-test-redis-and-db.sh redis-cli -h localhost -p 6380 ping
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test redis_test" main "$@"
|
||||
@@ -74,3 +74,14 @@ 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(),
|
||||
},
|
||||
}))
|
||||
|
||||
+121
-69
@@ -1,7 +1,7 @@
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestPds as DevEnvTestPDS, TestNetworkNoAppView} from '@atproto/dev-env'
|
||||
import {TestNetwork} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
@@ -18,14 +18,59 @@ export interface TestPDS {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
class StringIdGenerator {
|
||||
_nextId = [0]
|
||||
constructor(
|
||||
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
) {}
|
||||
|
||||
next() {
|
||||
const r = []
|
||||
for (const char of this._nextId) {
|
||||
r.unshift(this._chars[char])
|
||||
}
|
||||
this._increment()
|
||||
return r.join('')
|
||||
}
|
||||
|
||||
_increment() {
|
||||
for (let i = 0; i < this._nextId.length; i++) {
|
||||
const val = ++this._nextId[i]
|
||||
if (val >= this._chars.length) {
|
||||
this._nextId[i] = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
this._nextId.push(0)
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
while (true) {
|
||||
yield this.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||
): Promise<TestPDS> {
|
||||
const port = await getPort()
|
||||
const port2 = await getPort(port + 1)
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const {pds, plc} = await TestNetworkNoAppView.create({
|
||||
pds: {port, publicUrl: pdsUrl, inviteRequired},
|
||||
const id = ids.next()
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
publicUrl: pdsUrl,
|
||||
inviteRequired,
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
},
|
||||
plc: {port: port2},
|
||||
})
|
||||
|
||||
@@ -35,10 +80,10 @@ export async function createServer(
|
||||
|
||||
return {
|
||||
pdsUrl,
|
||||
mocker: new Mocker(pds, pdsUrl, pic),
|
||||
mocker: new Mocker(testNet, pdsUrl, pic),
|
||||
async close() {
|
||||
await pds.server.destroy()
|
||||
await plc.server.destroy()
|
||||
await testNet.pds.server.destroy()
|
||||
await testNet.plc.server.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -48,13 +93,25 @@ class Mocker {
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(
|
||||
public pds: DevEnvTestPDS,
|
||||
public testNet: TestNetwork,
|
||||
public service: string,
|
||||
public pic: Uint8Array,
|
||||
) {
|
||||
this.agent = new BskyAgent({service})
|
||||
}
|
||||
|
||||
get pds() {
|
||||
return this.testNet.pds
|
||||
}
|
||||
|
||||
get bsky() {
|
||||
return this.testNet.bsky
|
||||
}
|
||||
|
||||
get plc() {
|
||||
return this.testNet.plc
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// deterministic date generator
|
||||
// we use this to ensure the mock dataset is always the same
|
||||
@@ -73,11 +130,7 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -212,24 +265,34 @@ class Mocker {
|
||||
return await agent.like(uri, cid)
|
||||
}
|
||||
|
||||
async createFeed(user: string) {
|
||||
async createFeed(user: string, rkey: string, posts: string[]) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
const fg1Uri = AtUri.make(
|
||||
const fgUri = AtUri.make(
|
||||
this.users[user].did,
|
||||
'app.bsky.feed.generator',
|
||||
'alice-favs',
|
||||
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: fg1Uri.rkey},
|
||||
{repo: this.users[user].did, rkey},
|
||||
{
|
||||
did: 'did:web:fake.com',
|
||||
displayName: 'alices feed',
|
||||
did: fg1.did,
|
||||
displayName: rkey,
|
||||
description: 'all my fav stuff',
|
||||
avatar: avatarRes.data.blob,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -242,11 +305,7 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -257,24 +316,21 @@ class Mocker {
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
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(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -289,43 +345,39 @@ class Mocker {
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
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()
|
||||
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(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
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(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
// 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
|
||||
|
||||
+67
-48
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.45.0",
|
||||
"version": "1.55.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
"postinstall": "patch-package",
|
||||
"prebuild": "expo prebuild",
|
||||
"prebuild": "expo prebuild --clean",
|
||||
"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": "jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "jest --watchAll",
|
||||
@@ -18,31 +19,38 @@
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh 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"
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
"perf:test": "maestro test",
|
||||
"perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "flashlight report .perf/results.json",
|
||||
"perf:measure": "flashlight measure",
|
||||
"build:apk": "eas build -p android --profile dev-android-apk"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.6.3",
|
||||
"@atproto/api": "^0.6.21",
|
||||
"@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": "^18.0.1",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@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.4.7",
|
||||
"@gorhom/bottom-sheet": "^4.5.1",
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.2.0",
|
||||
"@notifee/react-native": "^7.4.0",
|
||||
"@react-native-async-storage/async-storage": "^1.17.6",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@react-native-async-storage/async-storage": "1.18.2",
|
||||
"@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": "6.7.3",
|
||||
"@react-native-community/datetimepicker": "7.2.0",
|
||||
"@react-native-menu/menu": "^0.8.0",
|
||||
"@react-native-picker/picker": "2.4.10",
|
||||
"@react-navigation/bottom-tabs": "^6.5.7",
|
||||
"@react-navigation/drawer": "^6.6.2",
|
||||
"@react-navigation/native": "^6.1.6",
|
||||
@@ -51,44 +59,49 @@
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "4.13.0",
|
||||
"@sentry/react-native": "5.10.0",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tiptap/core": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-hard-break": "^2.0.3",
|
||||
"@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",
|
||||
"email-validator": "^2.0.4",
|
||||
"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",
|
||||
"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",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"graphemer": "^1.4.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
@@ -97,6 +110,7 @@
|
||||
"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",
|
||||
@@ -107,17 +121,17 @@
|
||||
"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.71.8",
|
||||
"react-native": "0.72.5",
|
||||
"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.9.0",
|
||||
"react-native-gesture-handler": "^2.12.1",
|
||||
"react-native-get-random-values": "^1.8.0",
|
||||
"react-native-haptic-feedback": "^1.14.0",
|
||||
"react-native-image-crop-picker": "^0.38.1",
|
||||
@@ -125,33 +139,34 @@
|
||||
"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.3.0",
|
||||
"react-native-reanimated": "^3.4.2",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-safe-area-context": "^4.4.1",
|
||||
"react-native-screens": "^3.20.0",
|
||||
"react-native-safe-area-context": "4.6.3",
|
||||
"react-native-screens": "~3.22.0",
|
||||
"react-native-splash-screen": "^3.3.0",
|
||||
"react-native-svg": "13.4.0",
|
||||
"react-native-svg": "13.9.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.18.11",
|
||||
"react-native-web": "~0.19.6",
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~6.1.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.3",
|
||||
"@atproto/pds": "^0.2.0-beta.2",
|
||||
"@atproto/dev-env": "^0.2.5",
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@did-plc/server": "^0.0.1",
|
||||
"@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",
|
||||
@@ -163,11 +178,12 @@
|
||||
"@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",
|
||||
@@ -176,32 +192,35 @@
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"babel-plugin-react-native-web": "^0.18.12",
|
||||
"detox": "^20.1.2",
|
||||
"detox": "^20.13.0",
|
||||
"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": "^48.0.2",
|
||||
"jest-expo": "^49.0.0",
|
||||
"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": "^4.4.4",
|
||||
"typescript": "^5.1.3",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18"
|
||||
"@types/react": "^18",
|
||||
"**/zeed-dom": "0.10.9"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
@@ -250,6 +269,6 @@
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "yarn eslint --ext .js,.jsx,.ts,.tsx --fix"
|
||||
"*{.js,.jsx,.ts,.tsx}": "yarn eslint --fix"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
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 }],
|
||||
@@ -0,0 +1,12 @@
|
||||
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 };
|
||||
@@ -0,0 +1,44 @@
|
||||
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),
|
||||
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
|
||||
index 27d4cb3..fd71f47 100644
|
||||
--- a/node_modules/metro-transform-worker/src/index.js
|
||||
+++ b/node_modules/metro-transform-worker/src/index.js
|
||||
@@ -190,6 +190,10 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
let dependencyMapName = "";
|
||||
let dependencies;
|
||||
let wrappedAst;
|
||||
+ const minify =
|
||||
+ options.minify &&
|
||||
+ options.unstable_transformProfile !== "hermes-canary" &&
|
||||
+ options.unstable_transformProfile !== "hermes-stable";
|
||||
|
||||
// If the module to transform is a script (meaning that is not part of the
|
||||
// dependency graph and it code will just be prepended to the bundle modules),
|
||||
@@ -229,19 +233,20 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
if (config.unstable_disableModuleWrapping === true) {
|
||||
wrappedAst = ast;
|
||||
} else {
|
||||
+ let moduleFactoryName;
|
||||
+ if (options.dev && !minify) {
|
||||
+ moduleFactoryName = file.filename;
|
||||
+ }
|
||||
({ ast: wrappedAst } = JsFileWrapping.wrapModule(
|
||||
ast,
|
||||
importDefault,
|
||||
importAll,
|
||||
dependencyMapName,
|
||||
- config.globalPrefix
|
||||
+ config.globalPrefix,
|
||||
+ moduleFactoryName
|
||||
));
|
||||
}
|
||||
}
|
||||
- const minify =
|
||||
- options.minify &&
|
||||
- options.unstable_transformProfile !== "hermes-canary" &&
|
||||
- options.unstable_transformProfile !== "hermes-stable";
|
||||
const reserved = [];
|
||||
if (config.unstable_dependencyMapReservedName != null) {
|
||||
reserved.push(config.unstable_dependencyMapReservedName);
|
||||
@@ -1,16 +1,7 @@
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
|
||||
index 1c8f8e0..090bda5 100644
|
||||
index 9dca6a5..090bda5 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
|
||||
@@ -256,7 +256,7 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
|
||||
|
||||
- (void)textViewDidChange:(__unused UITextView *)textView
|
||||
{
|
||||
- if (_ignoreNextTextInputCall) {
|
||||
+ if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
_ignoreNextTextInputCall = NO;
|
||||
return;
|
||||
}
|
||||
@@ -266,11 +266,10 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
|
||||
|
||||
- (void)textViewDidChangeSelection:(__unused UITextView *)textView
|
||||
@@ -23,4 +14,4 @@ index 1c8f8e0..090bda5 100644
|
||||
- _lastStringStateWasUpdatedWith = _backedTextInputView.attributedText;
|
||||
[self textViewProbablyDidChangeSelection];
|
||||
}
|
||||
|
||||
|
||||
@@ -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..fbbf19f 100644
|
||||
index ab0fc7f..1ace752 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..fbbf19f 100644
|
||||
|
||||
@property(nonatomic, strong) UIPageViewController *reactPageViewController;
|
||||
@property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
|
||||
@@ -80,6 +80,10 @@
|
||||
@@ -80,6 +80,10 @@ - (void)didMoveToWindow {
|
||||
[self setupInitialController];
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ index ab0fc7f..fbbf19f 100644
|
||||
if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
|
||||
[self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
|
||||
}
|
||||
@@ -463,4 +467,21 @@
|
||||
@@ -463,4 +467,21 @@ - (NSString *)determineScrollDirection:(UIScrollView *)scrollView {
|
||||
- (BOOL)isLtrLayout {
|
||||
return [_layoutDirection isEqualToString:@"ltr"];
|
||||
}
|
||||
+
|
||||
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||
+ if (otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ if (!_overdrag && otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [p velocityInView:self];
|
||||
+ if (self.currentIndex == 0 && velocity.x > 0) {
|
||||
|
||||
+28
-31
@@ -1,45 +1,39 @@
|
||||
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 {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import * as view from './view/index'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell'
|
||||
import * as notifee from 'lib/notifee'
|
||||
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 {handleLink} from './Navigation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
const App = observer(() => {
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
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)
|
||||
})
|
||||
notifications.init(store)
|
||||
store.onSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
})
|
||||
@@ -51,17 +45,20 @@ const App = observer(() => {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+25
-19
@@ -1,23 +1,27 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as view from './view/index'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell/index'
|
||||
import {ToastContainer} from './view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
|
||||
const App = observer(() => {
|
||||
const App = observer(function AppImpl() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
@@ -30,18 +34,20 @@ const App = observer(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
<ToastContainer />
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
<ToastContainer />
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+105
-54
@@ -1,5 +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,
|
||||
@@ -33,6 +34,10 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from './state'
|
||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
@@ -40,9 +45,9 @@ import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ModerationScreen} from './view/screens/Moderation'
|
||||
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'
|
||||
@@ -63,10 +68,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 {getRoutingInstrumentation} from 'lib/sentry'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -89,42 +92,42 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="NotFound"
|
||||
component={NotFoundScreen}
|
||||
getComponent={() => NotFoundScreen}
|
||||
options={{title: title('Not Found')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Moderation"
|
||||
component={ModerationScreen}
|
||||
getComponent={() => ModerationScreen}
|
||||
options={{title: title('Moderation')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMuteLists"
|
||||
component={ModerationMuteListsScreen}
|
||||
getComponent={() => ModerationMuteListsScreen}
|
||||
options={{title: title('Mute Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMutedAccounts"
|
||||
component={ModerationMutedAccounts}
|
||||
getComponent={() => ModerationMutedAccounts}
|
||||
options={{title: title('Muted Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationBlockedAccounts"
|
||||
component={ModerationBlockedAccounts}
|
||||
getComponent={() => ModerationBlockedAccounts}
|
||||
options={{title: title('Blocked Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DiscoverFeeds"
|
||||
component={DiscoverFeedsScreen}
|
||||
options={{title: title('Discover Feeds')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
getComponent={() => SettingsScreen}
|
||||
options={{title: title('Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LanguageSettings"
|
||||
getComponent={() => LanguageSettingsScreen}
|
||||
options={{title: title('Language Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
getComponent={() => ProfileScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`@${route.params.name}`),
|
||||
animation: 'none',
|
||||
@@ -132,93 +135,103 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollowers"
|
||||
component={ProfileFollowersScreen}
|
||||
getComponent={() => ProfileFollowersScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People following @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollows"
|
||||
component={ProfileFollowsScreen}
|
||||
getComponent={() => ProfileFollowsScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People followed by @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
component={ProfileListScreen}
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title('Mute List')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
component={PostThreadScreen}
|
||||
getComponent={() => PostThreadScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostLikedBy"
|
||||
component={PostLikedByScreen}
|
||||
getComponent={() => PostLikedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostRepostedBy"
|
||||
component={PostRepostedByScreen}
|
||||
getComponent={() => PostRepostedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CustomFeed"
|
||||
component={CustomFeedScreen}
|
||||
getComponent={() => CustomFeedScreen}
|
||||
options={{title: title('Feed')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CustomFeedLikedBy"
|
||||
component={CustomFeedLikedByScreen}
|
||||
getComponent={() => CustomFeedLikedByScreen}
|
||||
options={{title: title('Liked by')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
component={DebugScreen}
|
||||
getComponent={() => DebugScreen}
|
||||
options={{title: title('Debug')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Log"
|
||||
component={LogScreen}
|
||||
getComponent={() => LogScreen}
|
||||
options={{title: title('Log')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Support"
|
||||
component={SupportScreen}
|
||||
getComponent={() => SupportScreen}
|
||||
options={{title: title('Support')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PrivacyPolicy"
|
||||
component={PrivacyPolicyScreen}
|
||||
getComponent={() => PrivacyPolicyScreen}
|
||||
options={{title: title('Privacy Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="TermsOfService"
|
||||
component={TermsOfServiceScreen}
|
||||
getComponent={() => TermsOfServiceScreen}
|
||||
options={{title: title('Terms of Service')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CommunityGuidelines"
|
||||
component={CommunityGuidelinesScreen}
|
||||
getComponent={() => CommunityGuidelinesScreen}
|
||||
options={{title: title('Community Guidelines')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CopyrightPolicy"
|
||||
component={CopyrightPolicyScreen}
|
||||
getComponent={() => CopyrightPolicyScreen}
|
||||
options={{title: title('Copyright Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AppPasswords"
|
||||
component={AppPasswords}
|
||||
getComponent={() => AppPasswords}
|
||||
options={{title: title('App Passwords')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SavedFeeds"
|
||||
component={SavedFeeds}
|
||||
getComponent={() => 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')}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -234,26 +247,31 @@ function TabsNavigator() {
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
initialRouteName="HomeTab"
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
component={NotificationsTabNavigator}
|
||||
getComponent={() => NotificationsTabNavigator}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MyProfileTab"
|
||||
getComponent={() => MyProfileTabNavigator}
|
||||
/>
|
||||
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeTabNavigator() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
|
||||
return (
|
||||
<HomeTab.Navigator
|
||||
screenOptions={{
|
||||
@@ -263,7 +281,7 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" component={HomeScreen} />
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -280,7 +298,7 @@ function SearchTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<SearchTab.Screen name="Search" component={SearchScreen} />
|
||||
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
|
||||
{commonScreens(SearchTab as typeof HomeTab)}
|
||||
</SearchTab.Navigator>
|
||||
)
|
||||
@@ -297,7 +315,7 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -316,14 +334,14 @@ function NotificationsTabNavigator() {
|
||||
}}>
|
||||
<NotificationsTab.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
getComponent={() => NotificationsScreen}
|
||||
/>
|
||||
{commonScreens(NotificationsTab as typeof HomeTab)}
|
||||
</NotificationsTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
const MyProfileTabNavigator = observer(() => {
|
||||
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
return (
|
||||
@@ -338,10 +356,9 @@ const MyProfileTabNavigator = observer(() => {
|
||||
<MyProfileTab.Screen
|
||||
name="MyProfile"
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
component={ProfileScreen}
|
||||
getComponent={() => ProfileScreen}
|
||||
initialParams={{
|
||||
name: store.me.did,
|
||||
hideBackButton: true,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
@@ -353,9 +370,11 @@ const MyProfileTabNavigator = observer(() => {
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = observer(() => {
|
||||
const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||
const pal = usePalette('default')
|
||||
const unreadCountLabel = useStores().me.notifications.unreadCountLabel
|
||||
const store = useStores()
|
||||
const unreadCountLabel = store.me.notifications.unreadCountLabel
|
||||
|
||||
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
||||
return (
|
||||
<Flat.Navigator
|
||||
@@ -368,22 +387,22 @@ const FlatNavigator = observer(() => {
|
||||
}}>
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title('Home')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
getComponent={() => SearchScreen}
|
||||
options={{title: title('Search')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
component={FeedsScreen}
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title('Feeds')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
getComponent={() => NotificationsScreen}
|
||||
options={{title: title('Notifications')}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
|
||||
@@ -447,6 +466,14 @@ 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()
|
||||
@@ -468,9 +495,21 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
params?: AllNavigatorParams[K],
|
||||
) {
|
||||
if (navigationRef.isReady()) {
|
||||
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
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),
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
|
||||
@@ -548,6 +587,18 @@ 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,
|
||||
|
||||
+43
-11
@@ -1,5 +1,12 @@
|
||||
import React, {ReactNode, createContext, useContext, useMemo} from 'react'
|
||||
import {TextStyle, useColorScheme, ViewStyle} from 'react-native'
|
||||
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 {darkTheme, defaultTheme} from './themes'
|
||||
|
||||
export type ColorScheme = 'light' | 'dark'
|
||||
@@ -86,19 +93,44 @@ 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 colorSchemeFromRN = useColorScheme()
|
||||
const colorScheme = useColorScheme_FIXED()
|
||||
const themeValue = getTheme(theme === 'system' ? colorScheme : theme)
|
||||
|
||||
// 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={themeValue}>{children}</ThemeContext.Provider>
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {ScreenEvent, TrackEvent} from './types'
|
||||
const segmentClient = createClient({
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
trackAppLifecycleEvents: false,
|
||||
proxy: 'https://api.events.bsky.app/v1',
|
||||
})
|
||||
|
||||
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
|
||||
@@ -50,10 +51,10 @@ export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
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()
|
||||
|
||||
@@ -15,7 +15,7 @@ const segmentClient = createClient(
|
||||
{
|
||||
integrations: {
|
||||
'Segment.io': {
|
||||
apiHost: 'api.evt.bsky.app/v1',
|
||||
apiHost: 'api.events.bsky.app/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -46,10 +46,10 @@ export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
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()
|
||||
|
||||
@@ -56,8 +56,12 @@ interface TrackPropertiesMap {
|
||||
}
|
||||
// PROFILE HEADER events
|
||||
'ProfileHeader:EditProfileButtonClicked': {}
|
||||
'ProfileHeader:FollowersButtonClicked': {}
|
||||
'ProfileHeader:FollowsButtonClicked': {}
|
||||
'ProfileHeader:FollowersButtonClicked': {
|
||||
handle: string
|
||||
}
|
||||
'ProfileHeader:FollowsButtonClicked': {
|
||||
handle: string
|
||||
}
|
||||
'ProfileHeader:ShareButtonClicked': {}
|
||||
'ProfileHeader:MuteAccountButtonClicked': {}
|
||||
'ProfileHeader:UnmuteAccountButtonClicked': {}
|
||||
@@ -67,6 +71,8 @@ interface TrackPropertiesMap {
|
||||
'ProfileHeader:UnblockAccountButtonClicked': {}
|
||||
'ProfileHeader:FollowButtonClicked': {}
|
||||
'ProfileHeader:UnfollowButtonClicked': {}
|
||||
'ProfileHeader:SuggestedFollowsOpened': {}
|
||||
'ProfileHeader:SuggestedFollowFollowed': {}
|
||||
'ViewHeader:MenuButtonClicked': {}
|
||||
// SETTINGS events
|
||||
'Settings:SwitchAccountButtonClicked': {}
|
||||
@@ -86,6 +92,8 @@ interface TrackPropertiesMap {
|
||||
'MobileShell:SearchButtonPressed': {}
|
||||
'MobileShell:NotificationsButtonPressed': {}
|
||||
'MobileShell:FeedsButtonPressed': {}
|
||||
// NOTIFICATIONS events
|
||||
'Notificatons:OpenApp': {}
|
||||
// LISTS events
|
||||
'Lists:onRefresh': {}
|
||||
'Lists:onEndReached': {}
|
||||
@@ -120,6 +128,9 @@ interface TrackPropertiesMap {
|
||||
// ONBOARDING events
|
||||
'Onboarding:Begin': {}
|
||||
'Onboarding:Complete': {}
|
||||
'Onboarding:Skipped': {}
|
||||
'Onboarding:Reset': {}
|
||||
'Onboarding:SuggestedFollowFollowed': {}
|
||||
}
|
||||
|
||||
interface ScreenPropertiesMap {
|
||||
|
||||
@@ -8,23 +8,29 @@
|
||||
* version of the app.
|
||||
*/
|
||||
|
||||
import {useState, useCallback} from 'react'
|
||||
import {useState, useCallback, useEffect} from 'react'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import * as Storage from 'lib/storage'
|
||||
|
||||
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
|
||||
const [enabled, setEnabled] = useState<boolean>(isEnabled())
|
||||
const [enabled, setEnabled] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
if (await isEnabled()) {
|
||||
setEnabled(true)
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
localStorage.setItem('set-header-x-appview-proxy', 'yes')
|
||||
Storage.saveString('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
setEnabled(true)
|
||||
} else {
|
||||
localStorage.removeItem('set-header-x-appview-proxy')
|
||||
Storage.remove('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
setEnabled(false)
|
||||
}
|
||||
@@ -34,30 +40,21 @@ export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
|
||||
}
|
||||
|
||||
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (enabled) {
|
||||
localStorage.setItem('set-header-x-appview-proxy', 'yes')
|
||||
Storage.saveString('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('set-header-x-appview-proxy')
|
||||
Storage.remove('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDebugHeader(agent: BskyAgent) {
|
||||
if (!isWeb) {
|
||||
return
|
||||
}
|
||||
if (isEnabled()) {
|
||||
export async function applyDebugHeader(agent: BskyAgent) {
|
||||
if (await isEnabled()) {
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return localStorage.getItem('set-header-x-appview-proxy') === 'yes'
|
||||
async function isEnabled() {
|
||||
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
|
||||
}
|
||||
|
||||
+71
-19
@@ -4,6 +4,7 @@ import {
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {FeedSourceInfo} from './feed/types'
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
@@ -64,6 +65,11 @@ export class FeedViewPostsSlice {
|
||||
)
|
||||
}
|
||||
|
||||
get source(): FeedSourceInfo | undefined {
|
||||
return this.items.find(item => '__source' in item && !!item.__source)
|
||||
?.__source as FeedSourceInfo
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
@@ -91,6 +97,23 @@ 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 FeedTuner {
|
||||
@@ -105,22 +128,32 @@ export class FeedTuner {
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
tunerFns: FeedTunerFn[] = [],
|
||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
||||
dryRun: false,
|
||||
maintainOrder: false,
|
||||
},
|
||||
): FeedViewPostsSlice[] {
|
||||
let slices: FeedViewPostsSlice[] = []
|
||||
|
||||
// arrange the posts into thread slices
|
||||
for (let i = feed.length - 1; i >= 0; i--) {
|
||||
const item = feed[i]
|
||||
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]
|
||||
|
||||
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
|
||||
@@ -156,9 +189,11 @@ export class FeedTuner {
|
||||
}
|
||||
}
|
||||
|
||||
for (const slice of slices) {
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
if (!dryRun) {
|
||||
for (const slice of slices) {
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,20 +254,34 @@ export class FeedTuner {
|
||||
return slices
|
||||
}
|
||||
|
||||
static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
|
||||
static thresholdRepliesOnly({
|
||||
userDid,
|
||||
minLikes,
|
||||
followedOnly,
|
||||
}: {
|
||||
userDid: string
|
||||
minLikes: number
|
||||
followedOnly: boolean
|
||||
}) {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] => {
|
||||
// remove any replies without at least repliesThreshold likes
|
||||
// remove any replies without at least minLikes likes
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isFullThread || !slices[i].isReply) {
|
||||
const slice = slices[i]
|
||||
if (slice.isFullThread || !slice.isReply) {
|
||||
continue
|
||||
}
|
||||
|
||||
const item = slices[i].rootItem
|
||||
const item = slice.rootItem
|
||||
const isRepost = Boolean(item.reason)
|
||||
if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
|
||||
if (isRepost) {
|
||||
continue
|
||||
}
|
||||
if ((item.post.likeCount || 0) < minLikes) {
|
||||
slices.splice(i, 1)
|
||||
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +327,10 @@ export class FeedTuner {
|
||||
|
||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||
if (item.reply) {
|
||||
if (AppBskyFeedDefs.isPostView(item.reply.parent)) {
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
||||
) {
|
||||
return item.reply.parent.author.did === item.post.author.did
|
||||
? item.reply.parent.uri
|
||||
: undefined
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetAuthorFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetCustomFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetActorLikes.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {feedUriToHref} from 'lib/strings/url-helpers'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
|
||||
|
||||
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 rootStore: RootStoreModel) {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
|
||||
this.feedCursor = 0
|
||||
this.itemCursor = 0
|
||||
this.sampleCursor = 0
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
// we capture here to ensure the data has loaded
|
||||
this._captureFeedsIfNeeded()
|
||||
|
||||
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 ? 'fake' : 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)
|
||||
}
|
||||
|
||||
_captureFeedsIfNeeded() {
|
||||
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
|
||||
return
|
||||
}
|
||||
if (this.customFeeds.length === 0) {
|
||||
this.customFeeds = shuffle(
|
||||
this.rootStore.me.savedFeeds.all.map(
|
||||
feed =>
|
||||
new MergeFeedSource_Custom(
|
||||
this.rootStore,
|
||||
feed.uri,
|
||||
feed.displayName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
sourceInfo: FeedSourceInfo | undefined
|
||||
cursor: string | undefined = undefined
|
||||
queue: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
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.rootStore.agent.getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(
|
||||
res.data.feed,
|
||||
this.rootStore.preferences.getFeedTuners('home'),
|
||||
{
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
},
|
||||
)
|
||||
res.data.feed = slices.map(slice => slice.rootItem)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
minDate: Date
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedUri: string,
|
||||
public feedDisplayName: string,
|
||||
) {
|
||||
super(rootStore)
|
||||
this.sourceInfo = {
|
||||
displayName: feedDisplayName,
|
||||
uri: feedUriToHref(feedUri),
|
||||
}
|
||||
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
|
||||
}
|
||||
|
||||
protected async _getFeed(
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
})
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
// -prf
|
||||
if (limit && res.data.feed.length > limit) {
|
||||
res.data.feed = res.data.feed.slice(0, limit)
|
||||
}
|
||||
// filter out older posts
|
||||
res.data.feed = res.data.feed.filter(
|
||||
post => new Date(post.post.indexedAt) > this.minDate,
|
||||
)
|
||||
// attach source info
|
||||
for (const post of res.data.feed) {
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
export interface FeedAPIResponse {
|
||||
cursor?: string
|
||||
feed: AppBskyFeedDefs.FeedViewPost[]
|
||||
}
|
||||
|
||||
export interface FeedAPI {
|
||||
reset(): void
|
||||
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
|
||||
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
|
||||
}
|
||||
|
||||
export interface FeedSourceInfo {
|
||||
uri: string
|
||||
displayName: string
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trim()},
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
@@ -133,10 +133,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
aspectRatio: {width, height},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
}
|
||||
+12
-12
@@ -79,12 +79,19 @@ export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
// TODO: remove this when the test suite no longer relies on it
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
return {
|
||||
pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
pinned: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
saved: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
}
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
// staging
|
||||
@@ -100,16 +107,8 @@ export async function DEFAULT_FEEDS(
|
||||
} else {
|
||||
// production
|
||||
return {
|
||||
pinned: [
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
],
|
||||
saved: [
|
||||
PROD_DEFAULT_FEED('bsky-team'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
pinned: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
saved: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,3 +147,4 @@ export const HITSLOP_10 = createHitslop(10)
|
||||
export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
} from '@atproto/api'
|
||||
|
||||
export function isEmbedByEmbedder(
|
||||
embed: AppBskyFeedDefs.PostView['embed'],
|
||||
did: string,
|
||||
): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
|
||||
return embed.record.author.did === did
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record.record)
|
||||
) {
|
||||
return embed.record.record.author.did === did
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {AccountData} from 'state/models/session'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useAccountSwitcher(): [
|
||||
boolean,
|
||||
(v: boolean) => void,
|
||||
(acct: AccountData) => Promise<void>,
|
||||
] {
|
||||
const {track} = useAnalytics()
|
||||
|
||||
const store = useStores()
|
||||
const [isSwitching, setIsSwitching] = useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (acct: AccountData) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
setIsSwitching(true)
|
||||
const success = await store.session.resumeSession(acct)
|
||||
store.shell.closeAllActiveElements()
|
||||
if (success) {
|
||||
resetNavigation()
|
||||
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
|
||||
} else {
|
||||
Toast.show('Sorry! We need you to enter your password.')
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
store.session.clear()
|
||||
}
|
||||
},
|
||||
[track, setIsSwitching, navigation, store],
|
||||
)
|
||||
|
||||
return [isSwitching, setIsSwitching, onPressSwitchAccount]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// does nothing in native
|
||||
export const useAuxClick = () => {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {useEffect} from 'react'
|
||||
|
||||
// This is the handler for the middle mouse button click on the feed.
|
||||
// Normally, we would do this via `onAuxClick` handler on each link element
|
||||
// However, that handler is not supported on react-native-web and there are some
|
||||
// discrepancies between various browsers (i.e: safari doesn't trigger it and routes through click event)
|
||||
// So, this temporary alternative is meant to bridge the gap in an efficient way until the support improves.
|
||||
export const useAuxClick = () => {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||
useEffect(() => {
|
||||
// On the web, it should always be there but in case it gets accidentally included in native builds
|
||||
const wrapperEl = document?.body
|
||||
|
||||
// Safari already handles auxclick event as click+metaKey so we need to avoid doing this there in case it becomes recursive
|
||||
if (wrapperEl && !isSafari) {
|
||||
const handleAuxClick = (e: MouseEvent & {target: HTMLElement}) => {
|
||||
// Only handle the middle mouse button click
|
||||
// Only handle if the clicked element itself or one of its ancestors is a link
|
||||
if (
|
||||
e.button !== 1 ||
|
||||
e.target.closest('a') ||
|
||||
e.target.tagName === 'A'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// On the original element, trigger a click event with metaKey set to true so that it triggers
|
||||
// the browser's default behavior of opening the link in a new tab
|
||||
e.target.dispatchEvent(
|
||||
new MouseEvent('click', {metaKey: true, bubbles: true}),
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-ignore For web only
|
||||
wrapperEl.addEventListener('auxclick', handleAuxClick)
|
||||
|
||||
return () => {
|
||||
// @ts-ignore For web only
|
||||
wrapperEl?.removeEventListener('auxclick', handleAuxClick)
|
||||
}
|
||||
}
|
||||
}, [isSafari])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
|
||||
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
const store = useStores()
|
||||
const state = store.me.follows.getFollowState(profile.did)
|
||||
|
||||
return {
|
||||
state,
|
||||
following: state === FollowState.Following,
|
||||
toggle: React.useCallback(async () => {
|
||||
if (state === FollowState.Following) {
|
||||
try {
|
||||
await store.agent.deleteFollow(
|
||||
store.me.follows.getFollowUri(profile.did),
|
||||
)
|
||||
store.me.follows.removeFollow(profile.did)
|
||||
return {
|
||||
state: FollowState.NotFollowing,
|
||||
following: false,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to delete follow', e)
|
||||
throw e
|
||||
}
|
||||
} else if (state === FollowState.NotFollowing) {
|
||||
try {
|
||||
const res = await store.agent.follow(profile.did)
|
||||
store.me.follows.addFollow(profile.did, {
|
||||
followRecordUri: res.uri,
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
})
|
||||
return {
|
||||
state: FollowState.Following,
|
||||
following: true,
|
||||
}
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed to create follow', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: FollowState.Unknown,
|
||||
following: false,
|
||||
}
|
||||
}, [store, profile, state]),
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,60 @@
|
||||
import React from 'react'
|
||||
import {autorun} from 'mobx'
|
||||
import {useStores} from 'state/index'
|
||||
import {Animated} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function useMinimalShellMode() {
|
||||
const store = useStores()
|
||||
const minimalShellInterp = useAnimatedValue(0)
|
||||
const footerMinimalShellTransform = {
|
||||
transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
|
||||
}
|
||||
const minimalShellInterp = useSharedValue(0)
|
||||
const footerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const headerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const fabMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
} else {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
}
|
||||
return autorun(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
minimalShellInterp.value = withTiming(1, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
} else {
|
||||
minimalShellInterp.value = withTiming(0, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [minimalShellInterp, store.shell.minimalShellMode])
|
||||
|
||||
return {footerMinimalShellTransform}
|
||||
return {
|
||||
footerMinimalShellTransform,
|
||||
headerMinimalShellTransform,
|
||||
fabMinimalShellTransform,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@ import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from './useWebMediaQueries'
|
||||
|
||||
const DY_LIMIT_UP = isDesktopWeb ? 30 : 10
|
||||
const DY_LIMIT_DOWN = isDesktopWeb ? 150 : 10
|
||||
const Y_LIMIT = 10
|
||||
|
||||
const useDeviceLimits = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return {
|
||||
dyLimitUp: isDesktop ? 30 : 10,
|
||||
dyLimitDown: isDesktop ? 150 : 10,
|
||||
}
|
||||
}
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
@@ -18,6 +24,8 @@ export function useOnMainScroll(
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
|
||||
|
||||
return [
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -25,15 +33,11 @@ export function useOnMainScroll(
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (
|
||||
!store.shell.minimalShellMode &&
|
||||
dy > DY_LIMIT_DOWN &&
|
||||
y > Y_LIMIT
|
||||
) {
|
||||
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(dy < DY_LIMIT_UP * -1 || y <= Y_LIMIT)
|
||||
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
@@ -50,7 +54,7 @@ export function useOnMainScroll(
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store, isScrolledDown],
|
||||
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
|
||||
@@ -29,9 +29,14 @@ export function usePhotoLibraryPermission() {
|
||||
|
||||
if (res?.granted) {
|
||||
return true
|
||||
} else if (!res || res?.status === 'undetermined' || res?.canAskAgain) {
|
||||
const updatedRes = await requestPermission()
|
||||
return updatedRes?.granted
|
||||
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
|
||||
const {canAskAgain, granted, status} = await requestPermission()
|
||||
|
||||
if (!canAskAgain && status === 'undetermined') {
|
||||
openPermissionAlert('photo library')
|
||||
}
|
||||
|
||||
return granted
|
||||
} else {
|
||||
openPermissionAlert('photo library')
|
||||
return false
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
export function useWebMediaQueries() {
|
||||
const isDesktop = useMediaQuery({
|
||||
query: '(min-width: 1230px)',
|
||||
})
|
||||
return {isDesktop}
|
||||
const isDesktop = useMediaQuery({minWidth: 1300})
|
||||
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300})
|
||||
const isMobile = useMediaQuery({maxWidth: 800})
|
||||
const isTabletOrMobile = isMobile || isTablet
|
||||
const isTabletOrDesktop = isDesktop || isTablet
|
||||
if (isNative) {
|
||||
return {
|
||||
isMobile: true,
|
||||
isTablet: false,
|
||||
isTabletOrMobile: true,
|
||||
isTabletOrDesktop: false,
|
||||
isDesktop: false,
|
||||
}
|
||||
}
|
||||
return {isMobile, isTablet, isTabletOrMobile, isTabletOrDesktop, isDesktop}
|
||||
}
|
||||
|
||||
+8
-56
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
|
||||
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
@@ -884,45 +884,7 @@ export function HandIcon({
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox="0 0 22 22"
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="none">
|
||||
<Path
|
||||
d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Circle cx="10" cy="12" r="2" fill="currentColor" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIcon({
|
||||
export function HashtagIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
@@ -934,26 +896,16 @@ export function SatelliteDishIcon({
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 22 22"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 30 30"
|
||||
strokeWidth={strokeWidth}
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" />
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
<Path d="M2 10H28" strokeLinecap="round" />
|
||||
<Path d="M2 20H28" strokeLinecap="round" />
|
||||
<Path d="M11 3L9 27" strokeLinecap="round" />
|
||||
<Path d="M21 3L19 27" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ILLEGAL_LABEL_GROUP,
|
||||
ALWAYS_FILTER_LABEL_GROUP,
|
||||
ALWAYS_WARN_LABEL_GROUP,
|
||||
UNKNOWN_LABEL_GROUP,
|
||||
} from './const'
|
||||
import {
|
||||
Label,
|
||||
LabelValGroup,
|
||||
ModerationBehaviorCode,
|
||||
ModerationBehavior,
|
||||
PostModeration,
|
||||
ProfileModeration,
|
||||
PostLabelInfo,
|
||||
ProfileLabelInfo,
|
||||
} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| {$type: string; [k: string]: unknown}
|
||||
|
||||
export function getLabelValueGroup(labelVal: string): LabelValGroup {
|
||||
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
|
||||
for (id in CONFIGURABLE_LABEL_GROUPS) {
|
||||
if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ILLEGAL_LABEL_GROUP
|
||||
}
|
||||
if (ALWAYS_FILTER_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ALWAYS_FILTER_LABEL_GROUP
|
||||
}
|
||||
if (ALWAYS_WARN_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ALWAYS_WARN_LABEL_GROUP
|
||||
}
|
||||
if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) {
|
||||
return CONFIGURABLE_LABEL_GROUPS[id]
|
||||
}
|
||||
}
|
||||
return UNKNOWN_LABEL_GROUP
|
||||
}
|
||||
|
||||
export function getPostModeration(
|
||||
store: RootStoreModel,
|
||||
postInfo: PostLabelInfo,
|
||||
): PostModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
postInfo.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
postInfo.profileLabels,
|
||||
)
|
||||
const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
postInfo.isBlocking ||
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(postPref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (postInfo.isBlocking) {
|
||||
return {
|
||||
avatar,
|
||||
list: hide('Post from an account you blocked.'),
|
||||
thread: hide('Post from an account you blocked.'),
|
||||
view: warn('Post from an account you blocked.'),
|
||||
}
|
||||
}
|
||||
if (postInfo.isBlockedBy) {
|
||||
return {
|
||||
avatar,
|
||||
list: hide('Post from an account that has blocked you.'),
|
||||
thread: hide('Post from an account that has blocked you.'),
|
||||
view: warn('Post from an account that has blocked you.'),
|
||||
}
|
||||
}
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
thread: hide(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
thread: hide(profilePref.desc.warning),
|
||||
view: warn(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (postPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(postPref.desc.warning),
|
||||
thread: hide(postPref.desc.warning),
|
||||
view: warn(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// muting
|
||||
if (postInfo.isMuted) {
|
||||
let msg = 'Post from an account you muted.'
|
||||
if (postInfo.mutedByList) {
|
||||
msg = `Muted by ${postInfo.mutedByList.name}`
|
||||
}
|
||||
return {
|
||||
avatar,
|
||||
list: isMute(hide(msg)),
|
||||
thread: isMute(warn(msg)),
|
||||
view: isMute(warn(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
// warning cases
|
||||
if (postPref.pref === 'warn') {
|
||||
if (postPref.desc.isAdultImagery) {
|
||||
return {
|
||||
avatar,
|
||||
list: warnImages(postPref.desc.warning),
|
||||
thread: warnImages(postPref.desc.warning),
|
||||
view: warnImages(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(postPref.desc.warning),
|
||||
thread: warnContent(postPref.desc.warning),
|
||||
view: warnContent(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(accountPref.desc.warning),
|
||||
thread: warnContent(accountPref.desc.warning),
|
||||
view: warnContent(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: show(),
|
||||
thread: show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePostModerations(
|
||||
moderations: PostModeration[],
|
||||
): PostModeration {
|
||||
const merged: PostModeration = {
|
||||
avatar: {warn: false, blur: false},
|
||||
list: show(),
|
||||
thread: show(),
|
||||
view: show(),
|
||||
}
|
||||
for (const mod of moderations) {
|
||||
if (mod.list.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.list = mod.list
|
||||
}
|
||||
if (mod.thread.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.thread = mod.thread
|
||||
}
|
||||
if (mod.view.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.view = mod.view
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function getProfileModeration(
|
||||
store: RootStoreModel,
|
||||
profileInfo: ProfileLabelInfo,
|
||||
): ProfileModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
profileInfo.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
profileInfo.profileLabels,
|
||||
)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
profileInfo.isBlocking ||
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
view: hide(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
view: hide(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// warn cases
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list:
|
||||
profileInfo.isBlocking || profileInfo.isBlockedBy
|
||||
? hide('Blocked account')
|
||||
: warn(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
// we don't warn for this
|
||||
// if (profilePref.pref === 'warn') {
|
||||
// return {
|
||||
// avatar,
|
||||
// list: warn(profilePref.desc.warning),
|
||||
// view: warn(profilePref.desc.warning),
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: profileInfo.isBlocking ? hide('Blocked account') : show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileViewBasicLabelInfo(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
): ProfileLabelInfo {
|
||||
return {
|
||||
accountLabels: filterAccountLabels(profile.labels),
|
||||
profileLabels: filterProfileLabels(profile.labels),
|
||||
isMuted: profile.viewer?.muted || false,
|
||||
isBlocking: !!profile.viewer?.blocking || false,
|
||||
isBlockedBy: !!profile.viewer?.blockedBy || false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmbedLabels(embed?: Embed): Label[] {
|
||||
if (!embed) {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return embed.record.labels || []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function getEmbedMuted(embed?: Embed): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.muted
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getEmbedMutedByList(
|
||||
embed?: Embed,
|
||||
): AppBskyGraphDefs.ListViewBasic | undefined {
|
||||
if (!embed) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return embed.record.author.viewer?.mutedByList
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getEmbedBlocking(embed?: Embed): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.blocking
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getEmbedBlockedBy(embed?: Embed): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.blockedBy
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function filterAccountLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(
|
||||
label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterProfileLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(label =>
|
||||
label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
function show() {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Show,
|
||||
}
|
||||
}
|
||||
|
||||
function hidePostNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
thread: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideProfileNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideNoOverride(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
noOverride: true,
|
||||
}
|
||||
}
|
||||
|
||||
function hide(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warn(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Warn,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warnContent(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnContent,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function isMute(behavior: ModerationBehavior): ModerationBehavior {
|
||||
behavior.isMute = true
|
||||
return behavior
|
||||
}
|
||||
|
||||
function warnImages(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnImages,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export type Label = ComAtprotoLabelDefs.Label
|
||||
@@ -16,54 +16,3 @@ export interface LabelValGroup {
|
||||
warning: string
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface PostLabelInfo {
|
||||
postLabels: Label[]
|
||||
accountLabels: Label[]
|
||||
profileLabels: Label[]
|
||||
isMuted: boolean
|
||||
mutedByList?: AppBskyGraphDefs.ListViewBasic
|
||||
isBlocking: boolean
|
||||
isBlockedBy: boolean
|
||||
}
|
||||
|
||||
export interface ProfileLabelInfo {
|
||||
accountLabels: Label[]
|
||||
profileLabels: Label[]
|
||||
isMuted: boolean
|
||||
isBlocking: boolean
|
||||
isBlockedBy: boolean
|
||||
}
|
||||
|
||||
export enum ModerationBehaviorCode {
|
||||
Show,
|
||||
Hide,
|
||||
Warn,
|
||||
WarnContent,
|
||||
WarnImages,
|
||||
}
|
||||
|
||||
export interface ModerationBehavior {
|
||||
behavior: ModerationBehaviorCode
|
||||
isMute?: boolean
|
||||
noOverride?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface AvatarModeration {
|
||||
warn: boolean
|
||||
blur: boolean
|
||||
}
|
||||
|
||||
export interface PostModeration {
|
||||
avatar: AvatarModeration
|
||||
list: ModerationBehavior
|
||||
thread: ModerationBehavior
|
||||
view: ModerationBehavior
|
||||
}
|
||||
|
||||
export interface ProfileModeration {
|
||||
avatar: AvatarModeration
|
||||
list: ModerationBehavior
|
||||
view: ModerationBehavior
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export async function getLinkMeta(
|
||||
`${LINK_META_PROXY(
|
||||
store.session.currentSession?.service || '',
|
||||
)}${encodeURIComponent(url)}`,
|
||||
{signal: controller.signal},
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import notifee, {EventType} from '@notifee/react-native'
|
||||
import {AppBskyEmbedImages, AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {sanitizeDisplayName} from './strings/display-names'
|
||||
import {resetToTab} from '../Navigation'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onUnreadNotifications(count => notifee.setBadgeCount(count))
|
||||
store.onPushNotification(displayNotificationFromModel)
|
||||
store.onSessionLoaded(() => {
|
||||
// request notifications permission once the user has logged in
|
||||
notifee.requestPermission()
|
||||
})
|
||||
notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
|
||||
store.log.debug('Notifee foreground event', {type})
|
||||
if (type === EventType.PRESS) {
|
||||
store.log.debug('User pressed a notifee, opening notifications')
|
||||
resetToTab('NotificationsTab')
|
||||
}
|
||||
})
|
||||
notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
|
||||
}
|
||||
|
||||
export function displayNotification(
|
||||
title: string,
|
||||
body?: string,
|
||||
image?: string,
|
||||
) {
|
||||
const opts: {title: string; body?: string; ios?: any} = {title}
|
||||
if (body) {
|
||||
opts.body = enforceLen(body, 70, true)
|
||||
}
|
||||
if (image) {
|
||||
opts.ios = {
|
||||
attachments: [{url: image}],
|
||||
}
|
||||
}
|
||||
return notifee.displayNotification(opts)
|
||||
}
|
||||
|
||||
export function displayNotificationFromModel(
|
||||
notification: NotificationsFeedItemModel,
|
||||
) {
|
||||
let author = sanitizeDisplayName(
|
||||
notification.author.displayName || notification.author.handle,
|
||||
)
|
||||
let title: string
|
||||
let body: string = ''
|
||||
if (notification.isLike) {
|
||||
title = `${author} liked your post`
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isRepost) {
|
||||
title = `${author} reposted your post`
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isMention) {
|
||||
title = `${author} mentioned you`
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isReply) {
|
||||
title = `${author} replied to your post`
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isFollow) {
|
||||
title = 'New follower!'
|
||||
body = `${author} has followed you`
|
||||
} else if (notification.isCustomFeedLike) {
|
||||
title = `${author} liked your custom feed`
|
||||
body = `${new AtUri(notification.subjectUri).rkey}`
|
||||
} else {
|
||||
return
|
||||
}
|
||||
let image
|
||||
if (
|
||||
AppBskyEmbedImages.isView(
|
||||
notification.additionalPost?.thread?.post.embed,
|
||||
) &&
|
||||
notification.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
) {
|
||||
image = notification.additionalPost.thread.post.embed.images[0].thumb
|
||||
}
|
||||
return displayNotification(title, body, image)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {RootStoreModel} from '../../state'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
? 'did:web:api.staging.bsky.dev'
|
||||
: 'did:web:api.bsky.app'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
|
||||
|
||||
store.onSessionLoaded(async () => {
|
||||
// request notifications permission once the user has logged in
|
||||
const perms = await Notifications.getPermissionsAsync()
|
||||
if (!perms.granted) {
|
||||
await Notifications.requestPermissionsAsync()
|
||||
}
|
||||
|
||||
// register the push token with the server
|
||||
const token = await getPushToken()
|
||||
if (token) {
|
||||
try {
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
token: token.data,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
store.log.debug('Notifications: Sent push token (init)', {
|
||||
type: token.type,
|
||||
token: token.data,
|
||||
})
|
||||
} catch (error) {
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
|
||||
// listens for new changes to the push token
|
||||
// In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
|
||||
Notifications.addPushTokenListener(async ({data: t, type}) => {
|
||||
store.log.debug('Notifications: Push token changed', {t, type})
|
||||
if (t) {
|
||||
try {
|
||||
await store.agent.api.app.bsky.notification.registerPush({
|
||||
serviceDid: SERVICE_DID(store.session.data?.service),
|
||||
platform: devicePlatform,
|
||||
token: t,
|
||||
appId: 'xyz.blueskyweb.app',
|
||||
})
|
||||
store.log.debug('Notifications: Sent push token (event)', {
|
||||
type,
|
||||
token: t,
|
||||
})
|
||||
} catch (error) {
|
||||
store.log.error('Notifications: Failed to set push token', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
Notifications.addNotificationReceivedListener(event => {
|
||||
store.log.debug('Notifications: received', event)
|
||||
if (event.request.trigger.type === 'push') {
|
||||
// refresh notifications in the background
|
||||
store.me.notifications.syncQueue()
|
||||
// handle payload-based deeplinks
|
||||
let payload
|
||||
if (isIOS) {
|
||||
payload = event.request.trigger.payload
|
||||
} else {
|
||||
// TODO: handle android payload deeplink
|
||||
}
|
||||
if (payload) {
|
||||
store.log.debug('Notifications: received payload', payload)
|
||||
// TODO: deeplink notif here
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// handle notifications that are tapped on
|
||||
const sub = Notifications.addNotificationResponseReceivedListener(
|
||||
response => {
|
||||
store.log.debug(
|
||||
'Notifications: response received',
|
||||
response.actionIdentifier,
|
||||
)
|
||||
if (
|
||||
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
|
||||
) {
|
||||
store.log.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
store.me.notifications.refresh() // refresh notifications
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
sub.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function getPushToken() {
|
||||
return Notifications.getDevicePushTokenAsync()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient()
|
||||
@@ -1,8 +1,19 @@
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {BackHandler} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
})
|
||||
// only register back handler on android, otherwise it throws an error
|
||||
if (isAndroid) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
backHandler.remove()
|
||||
}
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export type CommonNavigatorParams = {
|
||||
ModerationMuteLists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
DiscoverFeeds: undefined
|
||||
Settings: undefined
|
||||
LanguageSettings: undefined
|
||||
Profile: {name: string; hideBackButton?: boolean}
|
||||
ProfileFollowers: {name: string}
|
||||
ProfileFollows: {name: string}
|
||||
@@ -29,6 +29,8 @@ export type CommonNavigatorParams = {
|
||||
CopyrightPolicy: undefined
|
||||
AppPasswords: undefined
|
||||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -13,8 +13,14 @@ Sentry.init({
|
||||
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
|
||||
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
|
||||
environment: __DEV__ ? 'development' : 'production', // Set the environment
|
||||
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
|
||||
enableAutoPerformanceTracking: true, // Enable auto performance tracking
|
||||
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
|
||||
_experiments: {
|
||||
// The sampling rate for profiling is relative to TracesSampleRate.
|
||||
// In this case, we'll capture profiles for 50% of transactions.
|
||||
profilesSampleRate: 0.5,
|
||||
},
|
||||
integrations: isNative
|
||||
? [
|
||||
new Sentry.Native.ReactNativeTracing({
|
||||
|
||||
@@ -6,6 +6,8 @@ import {describeModerationCause} from '../moderation'
|
||||
// \u2714 = ✔
|
||||
// \u2611 = ☑
|
||||
const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
|
||||
const CONTROL_CHARS_RE =
|
||||
/[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g
|
||||
|
||||
export function sanitizeDisplayName(
|
||||
str: string,
|
||||
@@ -15,7 +17,7 @@ export function sanitizeDisplayName(
|
||||
return `⚠${describeModerationCause(moderation.cause, 'account').name}`
|
||||
}
|
||||
if (typeof str === 'string') {
|
||||
return str.replace(CHECK_MARKS_RE, '').trim()
|
||||
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export function makeValidHandle(str: string): string {
|
||||
str = str.slice(0, 20)
|
||||
}
|
||||
str = str.toLowerCase()
|
||||
return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
|
||||
return str.replace(/^[^a-z0-9]+/g, '').replace(/[^a-z0-9-]/g, '')
|
||||
}
|
||||
|
||||
export function createFullHandle(name: string, domain: string): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user