diff options
77 files changed, 1891 insertions, 1534 deletions
diff --git a/.detoxrc.js b/.detoxrc.js index 1e41165da..906620430 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -41,7 +41,7 @@ module.exports = { simulator: { type: 'ios.simulator', device: { - type: 'iPhone 15', + type: 'iPhone 15 Pro', }, }, attached: { diff --git a/.gitignore b/.gitignore index 66658f8e4..0bd7d137f 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,7 @@ ios/ .env.* # Firebase (Android) Google services -google-services.json \ No newline at end of file +google-services.json + +# Performance results (Flashlight) +.perf/ \ No newline at end of file diff --git a/README.md b/README.md index 08e7aba28..4f7d00ebb 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Get the app itself: 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). -There is a small about of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. +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/builds.md) are a good place to get started with the app itself. +The [Build Instructions](./docs/build.md) are a good place to get started with the app itself. The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at: diff --git a/__e2e__/maestro/scroll.yaml b/__e2e__/maestro/scroll.yaml new file mode 100644 index 000000000..2d32793eb --- /dev/null +++ b/__e2e__/maestro/scroll.yaml @@ -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" + diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 6613f54d0..482df6ef6 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -502,6 +502,9 @@ async function main() { createdAt: new Date().toISOString(), }, ) + + // flush caches + await server.mocker.testNet.processAll() } } console.log('Ready') diff --git a/__e2e__/tests/shell.test.ts b/__e2e__/tests/shell.test.skip.ts index 69619dd81..69619dd81 100644 --- a/__e2e__/tests/shell.test.ts +++ b/__e2e__/tests/shell.test.skip.ts diff --git a/__mocks__/sentry-expo.js b/__mocks__/sentry-expo.js new file mode 100644 index 000000000..e735c48c5 --- /dev/null +++ b/__mocks__/sentry-expo.js @@ -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(), + }, +})) diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 3055a9ef6..8bb52ed40 100644 --- a/__tests__/lib/strings/url-helpers.test.ts +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -27,6 +27,42 @@ describe('linkRequiresWarning', () => { ['http://site.pages', 'http://site.pages.dev', true], ['http://site.pages.dev', 'site.pages', true], ['http://site.pages', 'site.pages.dev', true], + ['http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false], + ['https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false], + ['http://bsky.app/', 'bluesky', false], + ['https://bsky.app/', 'bluesky', false], + [ + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + false, + ], + [ + 'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + false, + ], + [ + 'http://bsky.app/', + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + false, + ], + [ + 'https://bsky.app/', + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + false, + ], + [ + 'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + 'https://google.com', + true, + ], + [ + 'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', + 'https://google.com', + true, + ], + ['http://bsky.app/', 'https://google.com', true], + ['https://bsky.app/', 'https://google.com', true], // bad uri inputs, default to true ['', '', true], diff --git a/app.config.js b/app.config.js index a1477a8ae..e5d7fdf41 100644 --- a/app.config.js +++ b/app.config.js @@ -6,7 +6,7 @@ module.exports = function () { slug: 'bluesky', scheme: 'bluesky', owner: 'blueskysocial', - version: '1.52.0', + version: '1.55.0', runtimeVersion: { policy: 'appVersion', }, @@ -43,7 +43,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, android: { - versionCode: 40, + versionCode: 44, adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', backgroundColor: '#ffffff', diff --git a/babel.config.js b/babel.config.js index 598e2a567..0baec0c3c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,20 @@ 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: { + // 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 +43,10 @@ module.exports = function (api) { ], 'react-native-reanimated/plugin', // NOTE: this plugin MUST be last ], + env: { + production: { + plugins: ['transform-remove-console'], + }, + }, } } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index d5d864069..5be96ce0e 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -91,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", @@ -106,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. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..8af163a8d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,14 @@ +# Testing instructions + +### Using Maestro E2E tests +1. Install Maestro by following [these instuctions](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 auomated 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`. \ No newline at end of file diff --git a/jest/dev-infra/_common.sh b/jest/dev-infra/_common.sh new file mode 100755 index 000000000..0d66653c8 --- /dev/null +++ b/jest/dev-infra/_common.sh @@ -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} +} diff --git a/jest/dev-infra/docker-compose.yaml b/jest/dev-infra/docker-compose.yaml new file mode 100644 index 000000000..3d582c18b --- /dev/null +++ b/jest/dev-infra/docker-compose.yaml @@ -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: diff --git a/jest/dev-infra/with-test-db.sh b/jest/dev-infra/with-test-db.sh new file mode 100755 index 000000000..cc083491a --- /dev/null +++ b/jest/dev-infra/with-test-db.sh @@ -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 "$@" diff --git a/jest/dev-infra/with-test-redis-and-db.sh b/jest/dev-infra/with-test-redis-and-db.sh new file mode 100755 index 000000000..c2b0c75ff --- /dev/null +++ b/jest/dev-infra/with-test-redis-and-db.sh @@ -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 "$@" diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 2629be2cc..5d6bd4f1f 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -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(), + }, +})) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 37ad824a0..bc3692600 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -1,7 +1,7 @@ import net from 'net' import path from 'path' import fs from 'fs' -import {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 testNet = 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}, }) @@ -48,7 +93,7 @@ class Mocker { users: Record<string, TestUser> = {} constructor( - public testNet: TestNetworkNoAppView, + public testNet: TestNetwork, public service: string, public pic: Uint8Array, ) { @@ -59,6 +104,10 @@ class Mocker { return this.testNet.pds } + get bsky() { + return this.testNet.bsky + } + get plc() { return this.testNet.plc } @@ -81,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', }, ) @@ -260,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', }, ) @@ -275,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) { @@ -307,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> { diff --git a/metro.config.js b/metro.config.js index b1714479f..a49d95f9a 100644 --- a/metro.config.js +++ b/metro.config.js @@ -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 diff --git a/package.json b/package.json index a886bfcd0..3a91528cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.51.0", + "version": "1.55.0", "private": true, "scripts": { "prepare": "is-ci || husky install", @@ -11,6 +11,7 @@ "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,14 +19,19 @@ "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", + "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.20", + "@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", @@ -53,7 +59,7 @@ "@segment/analytics-react": "^1.0.0-rc1", "@segment/analytics-react-native": "^2.10.1", "@segment/sovran-react-native": "^0.4.5", - "@sentry/react-native": "5.5.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", @@ -71,6 +77,7 @@ "@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", @@ -148,7 +155,7 @@ "react-native-web-linear-gradient": "^1.1.2", "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", - "sentry-expo": "~7.0.0", + "sentry-expo": "~7.0.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", @@ -186,7 +193,7 @@ "babel-loader": "^9.1.2", "babel-plugin-module-resolver": "^5.0.0", "babel-plugin-react-native-web": "^0.18.12", - "detox": "^20.11.3", + "detox": "^20.13.0", "eslint": "^8.19.0", "eslint-plugin-detox": "^1.0.0", "eslint-plugin-ft-flow": "^2.0.3", @@ -213,7 +220,8 @@ "webpack-dev-server": "^4.11.1" }, "resolutions": { - "@types/react": "^18" + "@types/react": "^18", + "**/zeed-dom": "0.10.9" }, "jest": { "preset": "jest-expo/ios", diff --git a/patches/@sentry+react-native+5.5.0.patch b/patches/@sentry+react-native+5.10.0.patch index 5ff4ddaba..2962aa44c 100644 --- a/patches/@sentry+react-native+5.5.0.patch +++ b/patches/@sentry+react-native+5.10.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js -index 7e0b4cd..3fd7406 100644 +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'; @@ -12,3 +12,4 @@ index 7e0b4cd..3fd7406 100644 + } catch (e) {} } //# sourceMappingURL=ignorerequirecyclelogs.js.map +\ No newline at end of file diff --git a/patches/react-native-pager-view+6.1.4.patch b/patches/react-native-pager-view+6.1.4.patch index adee2533f..d6b4178ab 100644 --- a/patches/react-native-pager-view+6.1.4.patch +++ b/patches/react-native-pager-view+6.1.4.patch @@ -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) { diff --git a/src/App.native.tsx b/src/App.native.tsx index f99e976ce..f4298c461 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -2,7 +2,6 @@ import 'react-native-url-polyfill/auto' 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' @@ -15,7 +14,6 @@ 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 {QueryClientProvider} from '@tanstack/react-query' import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' @@ -34,15 +32,6 @@ const App = observer(function AppImpl() { setRootStore(store) analytics.init(store) notifications.init(store) - SplashScreen.hideAsync() - Linking.getInitialURL().then((url: string | null) => { - if (url) { - handleLink(url) - } - }) - Linking.addEventListener('url', ({url}) => { - handleLink(url) - }) store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') }) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 97612c9ec..52235ad75 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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, @@ -91,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="Settings" - component={SettingsScreen} + getComponent={() => SettingsScreen} options={{title: title('Settings')}} /> <Stack.Screen name="LanguageSettings" - component={LanguageSettingsScreen} + getComponent={() => LanguageSettingsScreen} options={{title: title('Language Settings')}} /> <Stack.Screen name="Profile" - component={ProfileScreen} + getComponent={() => ProfileScreen} options={({route}) => ({ title: title(`@${route.params.name}`), animation: 'none', @@ -134,101 +135,101 @@ 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" - component={PreferencesHomeFeed} + getComponent={() => PreferencesHomeFeed} options={{title: title('Home Feed Preferences')}} /> <Stack.Screen name="PreferencesThreads" - component={PreferencesThreads} + getComponent={() => PreferencesThreads} options={{title: title('Threads Preferences')}} /> </> @@ -253,14 +254,17 @@ function TabsNavigator() { 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> ) } @@ -277,7 +281,7 @@ function HomeTabNavigator() { animationDuration: 250, contentStyle, }}> - <HomeTab.Screen name="Home" component={HomeScreen} /> + <HomeTab.Screen name="Home" getComponent={() => HomeScreen} /> {commonScreens(HomeTab)} </HomeTab.Navigator> ) @@ -294,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> ) @@ -311,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> ) @@ -330,7 +334,7 @@ function NotificationsTabNavigator() { }}> <NotificationsTab.Screen name="Notifications" - component={NotificationsScreen} + getComponent={() => NotificationsScreen} /> {commonScreens(NotificationsTab as typeof HomeTab)} </NotificationsTab.Navigator> @@ -352,7 +356,7 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { <MyProfileTab.Screen name="MyProfile" // @ts-ignore // TODO: fix this broken type in ProfileScreen - component={ProfileScreen} + getComponent={() => ProfileScreen} initialParams={{ name: store.me.did, }} @@ -383,22 +387,22 @@ const FlatNavigator = observer(function FlatNavigatorImpl() { }}> <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)} @@ -462,6 +466,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onReady={() => { + SplashScreen.hideAsync() // Register the navigation container with the Sentry instrumentation (only works on native) if (isNative) { const routingInstrumentation = getRoutingInstrumentation() @@ -483,9 +488,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') { diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx index d1eb50f8a..b3db9149c 100644 --- a/src/lib/analytics/analytics.tsx +++ b/src/lib/analytics/analytics.tsx @@ -51,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() diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx index db9d86e3c..78bd9b42b 100644 --- a/src/lib/analytics/analytics.web.tsx +++ b/src/lib/analytics/analytics.web.tsx @@ -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() diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index e28a0e884..475d165d3 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -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, + } } diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 3c27d8639..106d2ca31 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined { export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) - if (!labelDomain) { - return true - } + let urip try { - const urip = new URL(uri) - return labelDomain !== urip.hostname + urip = new URL(uri) } catch { return true } + + if (urip.hostname === 'bsky.app') { + // if this is a link to internal content, + // warn if it represents itself as a URL to another app + if ( + labelDomain && + labelDomain !== 'bsky.app' && + isPossiblyAUrl(labelDomain) + ) { + return true + } + return false + } else { + // if this is a link to external content, + // warn if the label doesnt match the target + if (!labelDomain) { + return true + } + return labelDomain !== urip.hostname + } } function labelToDomain(label: string): string | undefined { diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 07079b5af..e1e8af509 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -5,6 +5,7 @@ import { moderateProfile, } from '@atproto/api' import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' const MAX_SYNC_PAGES = 10 const SYNC_TTL = 60e3 * 10 // 10 minutes @@ -56,7 +57,7 @@ export class MyFollowsCache { * Syncs a subset of the user's follows * for performance reasons, caps out at 1000 follows */ - async syncIfNeeded() { + syncIfNeeded = bundleAsync(async () => { if (this.lastSync > Date.now() - SYNC_TTL) { return } @@ -81,7 +82,7 @@ export class MyFollowsCache { } this.lastSync = Date.now() - } + }) getFollowState(did: string): FollowState { if (typeof this.byDid[did] === 'undefined') { diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index ae4f29105..d46cced75 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -116,6 +116,7 @@ export class PostsFeedItemModel { }, () => this.rootStore.agent.deleteLike(url), ) + track('Post:Unlike') } else { // like await updateDataOptimistically( @@ -129,11 +130,10 @@ export class PostsFeedItemModel { this.post.viewer!.like = res.uri }, ) + track('Post:Like') } } catch (error) { this.rootStore.log.error('Failed to toggle like', error) - } finally { - track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like') } } @@ -141,6 +141,7 @@ export class PostsFeedItemModel { this.post.viewer = this.post.viewer || {} try { if (this.post.viewer?.repost) { + // unrepost const url = this.post.viewer.repost await updateDataOptimistically( this.post, @@ -150,7 +151,9 @@ export class PostsFeedItemModel { }, () => this.rootStore.agent.deleteRepost(url), ) + track('Post:Unrepost') } else { + // repost await updateDataOptimistically( this.post, () => { @@ -162,11 +165,10 @@ export class PostsFeedItemModel { this.post.viewer!.repost = res.uri }, ) + track('Post:Repost') } } catch (error) { this.rootStore.log.error('Failed to toggle repost', error) - } finally { - track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost') } } @@ -174,13 +176,13 @@ export class PostsFeedItemModel { try { if (this.isThreadMuted) { this.rootStore.mutedThreads.uris.delete(this.rootUri) + track('Post:ThreadUnmute') } else { this.rootStore.mutedThreads.uris.add(this.rootUri) + track('Post:ThreadMute') } } catch (error) { this.rootStore.log.error('Failed to toggle thread mute', error) - } finally { - track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute') } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 8a7a4c851..186e61cf6 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -25,13 +25,13 @@ export class MeModel { savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache - invites: ComAtprotoServerDefs.InviteCode[] | null = [] + invites: ComAtprotoServerDefs.InviteCode[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() lastNotifsUpdate = Date.now() get invitesAvailable() { - return this.invites?.filter(isInviteAvailable).length || null + return this.invites.filter(isInviteAvailable).length } constructor(public rootStore: RootStoreModel) { @@ -180,9 +180,7 @@ export class MeModel { } catch (e) { this.rootStore.log.error('Failed to fetch user invite codes', e) } - if (this.invites) { - await this.rootStore.invitedUsers.fetch(this.invites) - } + await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index 10aef0ff4..c26f9b87c 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> { async crop() { try { // NOTE - // on ios, react-native-image-cropper gives really bad quality + // on ios, react-native-image-crop-picker gives really bad quality // without specifying width and height. on android, however, the // crop stretches incorrectly if you do specify it. these are // both separate bugs in the library. we deal with that by diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts new file mode 100644 index 000000000..ec0eca40d --- /dev/null +++ b/src/state/models/ui/reminders.e2e.ts @@ -0,0 +1,24 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' + +export class Reminders { + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + {serialize: false, hydrate: false}, + {autoBind: true}, + ) + } + + serialize() { + return {} + } + + hydrate(_v: unknown) {} + + get shouldRequestEmailConfirmation() { + return false + } + + setEmailConfirmationRequested() {} +} diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts index 60dbf5d88..c650de004 100644 --- a/src/state/models/ui/reminders.ts +++ b/src/state/models/ui/reminders.ts @@ -3,10 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {toHashCode} from 'lib/strings/helpers' -const DAY = 60e3 * 24 * 1 // 1 day (ms) - export class Reminders { - lastEmailConfirm: Date = new Date() + lastEmailConfirm: Date | null = null constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -45,6 +43,10 @@ export class Reminders { if (this.rootStore.onboarding.isActive) { return false } + // only prompt once + if (this.lastEmailConfirm) { + return false + } const today = new Date() // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when @@ -53,9 +55,7 @@ export class Reminders { if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { return false } - // only ask once a day at most, but because of the bucketing - // this will be more like weekly - return Number(today) - Number(this.lastEmailConfirm) > DAY + return true } setEmailConfirmationRequested() { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index c5d094ea5..2810129f6 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -187,16 +187,19 @@ export const TextInput = forwardRef(function TextInputImpl( const textDecorated = useMemo(() => { let i = 0 - return Array.from(richtext.segments()).map(segment => ( - <Text - key={i++} - style={[ - !segment.facet ? pal.text : pal.link, - styles.textInputFormatting, - ]}> - {segment.text} - </Text> - )) + return Array.from(richtext.segments()).map(segment => { + const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) + return ( + <Text + key={i++} + style={[ + segment.facet && !isTag ? pal.link : pal.text, + styles.textInputFormatting, + ]}> + {segment.text} + </Text> + ) + }) }, [richtext, pal.link, pal.text]) return ( diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx new file mode 100644 index 000000000..725106d59 --- /dev/null +++ b/src/view/com/feeds/FeedPage.tsx @@ -0,0 +1,210 @@ +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useIsFocused} from '@react-navigation/native' +import {useAnalytics} from '@segment/analytics-react-native' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {ComposeIcon2} from 'lib/icons' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import React from 'react' +import {FlatList, View} from 'react-native' +import {useStores} from 'state/index' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' +import {Feed} from '../posts/Feed' +import {TextLink} from '../util/Link' +import {FAB} from '../util/fab/FAB' +import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' +import useAppState from 'react-native-appstate-hook' + +export const FeedPage = observer(function FeedPageImpl({ + testID, + isPageFocused, + feed, + renderEmptyState, + renderEndOfFeed, +}: { + testID?: string + feed: PostsFeedModel + isPageFocused: boolean + renderEmptyState: () => JSX.Element + renderEndOfFeed?: () => JSX.Element +}) { + const store = useStores() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) + const {screen, track} = useAnalytics() + const headerOffset = useHeaderOffset() + const scrollElRef = React.useRef<FlatList>(null) + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) + const isScreenFocused = useIsFocused() + const hasNew = feed.hasNewLatest && !feed.isRefreshing + + React.useEffect(() => { + // called on first load + if (!feed.hasLoaded && isPageFocused) { + feed.setup() + } + }, [isPageFocused, feed]) + + const doPoll = React.useCallback( + (knownActive = false) => { + if ( + (!knownActive && appState !== 'active') || + !isScreenFocused || + !isPageFocused + ) { + return + } + if (feed.isLoading) { + return + } + store.log.debug('HomeScreen: Polling for new posts') + feed.checkForLatest() + }, + [appState, isScreenFocused, isPageFocused, store, feed], + ) + + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + resetMainScroll() + }, [headerOffset, resetMainScroll]) + + const onSoftReset = React.useCallback(() => { + if (isPageFocused) { + scrollToTop() + feed.refresh() + } + }, [isPageFocused, scrollToTop, feed]) + + // fires when page within screen is activated/deactivated + // - check for latest + React.useEffect(() => { + if (!isPageFocused || !isScreenFocused) { + return + } + + const softResetSub = store.onScreenSoftReset(onSoftReset) + const feedCleanup = feed.registerListeners() + const pollInterval = setInterval(doPoll, POLL_FREQ) + + screen('Feed') + store.log.debug('HomeScreen: Updating feed') + feed.checkForLatest() + + return () => { + clearInterval(pollInterval) + softResetSub.remove() + feedCleanup() + } + }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + + const onPressCompose = React.useCallback(() => { + track('HomeScreen:PressCompose') + store.shell.openComposer({}) + }, [store, track]) + + const onPressTryAgain = React.useCallback(() => { + feed.refresh() + }, [feed]) + + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + feed.refresh() + }, [feed, scrollToTop]) + + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> + } + onPress={() => store.emitScreenSoftReset()} + /> + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + accessibilityLabel="Feed Preferences" + accessibilityHint="" + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, store, hasNew]) + + return ( + <View testID={testID} style={s.h100pct}> + <Feed + testID={testID ? `${testID}-feed` : undefined} + key="default" + feed={feed} + scrollElRef={scrollElRef} + onPressTryAgain={onPressTryAgain} + onScroll={onMainScroll} + scrollEventThrottle={100} + renderEmptyState={renderEmptyState} + renderEndOfFeed={renderEndOfFeed} + ListHeaderComponent={ListHeaderComponent} + headerOffset={headerOffset} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) +}) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index 553a4a2e7..7c7ad0616 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,9 +1,8 @@ -import React, {MutableRefObject, useState} from 'react' +import React, {useState} from 'react' import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' import Animated, { - measure, runOnJS, useAnimatedRef, useAnimatedStyle, @@ -12,11 +11,7 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated' -import { - GestureDetector, - Gesture, - GestureType, -} from 'react-native-gesture-handler' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import { createTransform, @@ -39,16 +34,16 @@ const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (isZoomed: boolean) => void - pinchGestureRef: MutableRefObject<GestureType | undefined> isScrollViewBeingDragged: boolean } const ImageItem = ({ imageSrc, + onTap, onZoom, onRequestClose, isScrollViewBeingDragged, - pinchGestureRef, }: Props) => { const [isScaled, setIsScaled] = useState(false) const [isLoaded, setIsLoaded] = useState(false) @@ -140,28 +135,7 @@ const ImageItem = ({ return [dx, dy] } - // This is a hack. - // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. - // However, there is no great reliable way to coordinate this yet in RGNH. - // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. - const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { - if (isScrollViewBeingDragged) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - const measurement = measure(containerRef) - if (!measurement || measurement.pageX !== 0) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - // Fail this "fake" gesture so that the gestures after it can proceed. - manager.fail() - }) - const pinch = Gesture.Pinch() - .withRef(pinchGestureRef) .onStart(e => { pinchOrigin.value = { x: e.focalX - SCREEN.width / 2, @@ -255,6 +229,10 @@ const ImageItem = ({ panTranslation.value = {x: 0, y: 0} }) + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + const doubleTap = Gesture.Tap() .numberOfTaps(2) .onEnd(e => { @@ -318,22 +296,27 @@ const ImageItem = ({ } }) + const composedGesture = isScrollViewBeingDragged + ? // If the parent is not at rest, provide a no-op gesture. + Gesture.Manual() + : Gesture.Exclusive( + dismissSwipePan, + Gesture.Simultaneous(pinch, pan), + doubleTap, + singleTap, + ) + const isLoading = !isLoaded || !imageDimensions return ( <Animated.View ref={containerRef} style={styles.container}> {isLoading && ( <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> )} - <GestureDetector - gesture={Gesture.Exclusive( - consumeHScroll, - dismissSwipePan, - Gesture.Simultaneous(pinch, pan), - doubleTap, - )}> + <GestureDetector gesture={composedGesture}> <AnimatedImage - source={imageSrc} contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} accessibilityHint="" diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 598b18ed2..f73f355ac 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,162 +6,162 @@ * */ -import React, {MutableRefObject, useCallback, useRef, useState} from 'react' +import React, {useState} from 'react' -import { - Animated, - Dimensions, - ScrollView, - StyleSheet, - View, - NativeScrollEvent, - NativeSyntheticEvent, - NativeTouchEvent, - TouchableWithoutFeedback, -} from 'react-native' +import {Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' -import {GestureType} from 'react-native-gesture-handler' +import Animated, { + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' -const DOUBLE_TAP_DELAY = 300 const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_VELOCITY = 1 const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const MIN_ZOOM = 2 -const MAX_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 +const MIN_DOUBLE_TAP_SCALE = 2 type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject<GestureType> isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -let lastTapTS: number | null = null - -const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const scrollViewRef = useRef<ScrollView>(null) +const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { + const scrollViewRef = useAnimatedRef<Animated.ScrollView>() + const translationY = useSharedValue(0) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const [scrollValueY] = useState(() => new Animated.Value(0)) - const maxScrollViewZoom = MAX_SCALE / (scale || 1) + const maxZoomScale = imageDimensions + ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + : 1 - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.5, 1, 0.5], + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + translationY.value, + [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + [0.5, 1, 0.5], + ), + } }) - const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - const onScrollEndDrag = useCallback( - ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const currentScaled = nativeEvent?.zoomScale > 1 - - onZoom(currentScaled) - setScaled(currentScaled) - - if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - onRequestClose() + const scrollHandler = useAnimatedScrollHandler({ + onScroll(e) { + const nextIsScaled = e.zoomScale > 1 + translationY.value = nextIsScaled ? 0 : e.contentOffset.y + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onRequestClose, onZoom], - ) + onEndDrag(e) { + const velocityY = e.velocity?.y ?? 0 + const nextIsScaled = e.zoomScale > 1 + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) + } + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { + runOnJS(onRequestClose)() + } + }, + }) + + function handleZoom(nextIsScaled: boolean) { + onZoom(nextIsScaled) + setScaled(nextIsScaled) + } - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + function handleDoubleTap(absoluteX: number, absoluteY: number) { + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } - if (nativeEvent?.zoomScale > 1) { - return + const willZoom = !scaled + if (willZoom) { + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + absoluteX, + absoluteY, + ) } - scrollValueY.setValue(offsetY) + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + ...nextZoomRect, // This rect is in screen coordinates + animated: true, + }) } - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent<NativeTouchEvent>) => { - const nowTS = new Date().getTime() - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() - - if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { - let nextZoomRect = { - x: 0, - y: 0, - width: SCREEN.width, - height: SCREEN.height, - } + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) - const willZoom = !scaled - if (willZoom) { - const {pageX, pageY} = event.nativeEvent - nextZoomRect = getZoomRectAfterDoubleTap( - imageDimensions, - pageX, - pageY, - ) - } + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + const {absoluteX, absoluteY} = e + runOnJS(handleDoubleTap)(absoluteX, absoluteY) + }) - // @ts-ignore - scrollResponderRef?.scrollResponderZoomTo({ - ...nextZoomRect, // This rect is in screen coordinates - animated: true, - }) - } else { - lastTapTS = nowTS - } - }, - [imageDimensions, scaled], - ) + const composedGesture = Gesture.Exclusive(doubleTap, singleTap) return ( - <View> - <ScrollView + <GestureDetector gesture={composedGesture}> + <Animated.ScrollView + // @ts-ignore Something's up with the types here ref={scrollViewRef} style={styles.listItem} pinchGestureEnabled showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - maximumZoomScale={maxScrollViewZoom} + maximumZoomScale={maxZoomScale} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={true} - onScroll={onScroll} - onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1}> + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && <ImageLoading />} - <TouchableWithoutFeedback - onPress={handleDoubleTap} - accessibilityRole="image" + <AnimatedImage + contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} + style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} - accessibilityHint=""> - <AnimatedImage - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={() => setLoaded(true)} - /> - </TouchableWithoutFeedback> - </ScrollView> - </View> + accessibilityHint="" + onLoad={() => setLoaded(true)} + /> + </Animated.ScrollView> + </GestureDetector> ) } const styles = StyleSheet.create({ + imageScrollContainer: { + height: SCREEN.height, + }, listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + width: SCREEN.width, + height: SCREEN.height, }, - imageScrollContainer: { - height: SCREEN_HEIGHT, + image: { + width: SCREEN.width, + height: SCREEN.height, }, }) @@ -191,7 +191,7 @@ const getZoomRectAfterDoubleTap = ( const zoom = Math.max( imageAspect / screenAspect, screenAspect / imageAspect, - MIN_ZOOM, + MIN_DOUBLE_TAP_SCALE, ) // Unlike in the Android version, we don't constrain the *max* zoom level here. // Instead, this is done in the ScrollView props so that it constraints pinch too. @@ -253,61 +253,4 @@ const getZoomRectAfterDoubleTap = ( } } -const getImageStyles = ( - image: ImageDimensions | null, - translate: {readonly x: number; readonly y: number} | undefined, - scale?: number, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - const transform = [] - if (translate) { - transform.push({translateX: translate.x}) - transform.push({translateY: translate.y}) - } - if (scale) { - // @ts-ignore TODO - is scale incorrect? might need to remove -prf - transform.push({scale}, {perspective: new Animated.Value(1000)}) - } - return { - width: image.width, - height: image.height, - transform, - } -} - -const getImageTransform = ( - image: ImageDimensions | null, - screen: ImageDimensions, -) => { - if (!image?.width || !image?.height) { - return [] as const - } - - const wScale = screen.width / image.width - const hScale = screen.height / image.height - const scale = Math.min(wScale, hScale) - const {x, y} = getImageTranslate(image, screen) - - return [{x, y}, scale] as const -} - -const getImageTranslate = ( - image: ImageDimensions, - screen: ImageDimensions, -): {x: number; y: number} => { - const getTranslateForAxis = (axis: 'x' | 'y'): number => { - const imageSize = axis === 'x' ? image.width : image.height - const screenSize = axis === 'x' ? screen.width : screen.height - - return (screenSize - imageSize) / 2 - } - - return { - x: getTranslateForAxis('x'), - y: getTranslateForAxis('y'), - } -} - export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 898b00c78..16688b820 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,15 +1,14 @@ // default implementation fallback for web -import React, {MutableRefObject} from 'react' +import React from 'react' import {View} from 'react-native' -import {GestureType} from 'react-native-gesture-handler' import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject<GestureType | undefined> isScrollViewBeingDragged: boolean } diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts index 7f0851af3..cb46fd0d9 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => { // eslint-disable-next-line @typescript-eslint/no-shadow const getImageDimensions = (image: ImageSource): Promise<Dimensions> => { return new Promise(resolve => { - if (typeof image === 'number') { - const cacheKey = `${image}` - let imageDimensions = imageDimensionsCache.get(cacheKey) - - if (!imageDimensions) { - const {width, height} = Image.resolveAssetSource(image) - imageDimensions = {width, height} - imageDimensionsCache.set(cacheKey, imageDimensions) - } - - resolve(imageDimensions) - - return - } - - // @ts-ignore if (image.uri) { const source = image as ImageURISource - const cacheKey = source.uri as string - const imageDimensions = imageDimensionsCache.get(cacheKey) - if (imageDimensions) { resolve(imageDimensions) } else { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index bc2a8a448..b6835793d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -8,121 +8,72 @@ // Original code copied and simplified from the link below as the codebase is currently not maintained: // https://github.com/jobtoday/react-native-image-viewing -import React, { - ComponentType, - createRef, - useCallback, - useRef, - useMemo, - useState, -} from 'react' -import { - Animated, - Dimensions, - NativeSyntheticEvent, - NativeScrollEvent, - StyleSheet, - View, - VirtualizedList, - ModalProps, - Platform, -} from 'react-native' -import {ModalsContainer} from '../../modals/Modal' +import React, {ComponentType, useCallback, useMemo, useState} from 'react' +import {StyleSheet, View, Platform} from 'react-native' import ImageItem from './components/ImageItem/ImageItem' import ImageDefaultHeader from './components/ImageDefaultHeader' import {ImageSource} from './@types' -import {ScrollView, GestureType} from 'react-native-gesture-handler' +import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import PagerView from 'react-native-pager-view' type Props = { images: ImageSource[] - keyExtractor?: (imageSrc: ImageSource, index: number) => string - imageIndex: number + initialImageIndex: number visible: boolean onRequestClose: () => void - presentationStyle?: ModalProps['presentationStyle'] - animationType?: ModalProps['animationType'] backgroundColor?: string HeaderComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}> } const DEFAULT_BG_COLOR = '#000' -const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const INITIAL_POSITION = {x: 0, y: 0} -const ANIMATION_CONFIG = { - duration: 200, - useNativeDriver: true, -} function ImageViewing({ images, - keyExtractor, - imageIndex, + initialImageIndex, visible, onRequestClose, backgroundColor = DEFAULT_BG_COLOR, HeaderComponent, FooterComponent, }: Props) { - const imageList = useRef<VirtualizedList<ImageSource>>(null) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) - const [opacity, setOpacity] = useState(1) - const [currentImageIndex, setImageIndex] = useState(imageIndex) - const [headerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) - const [footerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) - - const toggleBarsVisible = (isVisible: boolean) => { - if (isVisible) { - Animated.parallel([ - Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - ]).start() - } else { - Animated.parallel([ - Animated.timing(headerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: -300, - }), - Animated.timing(footerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: 300, - }), - ]).start() - } - } - - const onRequestCloseEnhanced = () => { - setOpacity(0) - onRequestClose() - setTimeout(() => setOpacity(1), 0) - } - - const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const { - nativeEvent: { - contentOffset: {x: scrollX}, + const [imageIndex, setImageIndex] = useState(initialImageIndex) + const [showControls, setShowControls] = useState(true) + + const animatedHeaderStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(showControls ? 0 : -30), + }, + ], + })) + const animatedFooterStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(showControls ? 0 : 30), }, - } = event + ], + })) - if (SCREEN.width) { - const nextIndex = Math.round(scrollX / SCREEN.width) - setImageIndex(nextIndex < 0 ? 0 : nextIndex) - } - } + const onTap = useCallback(() => { + setShowControls(show => !show) + }, []) - const onZoom = (nextIsScaled: boolean) => { - toggleBarsVisible(!nextIsScaled) - setIsScaled(false) - } + const onZoom = useCallback((nextIsScaled: boolean) => { + setIsScaled(nextIsScaled) + if (nextIsScaled) { + setShowControls(false) + } + }, []) const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -131,100 +82,54 @@ function ImageViewing({ return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area }, []) - const onLayout = useCallback(() => { - if (imageIndex) { - imageList.current?.scrollToIndex({index: imageIndex, animated: false}) - } - }, [imageList, imageIndex]) - - // This is a hack. - // RNGH doesn't have an easy way to express that pinch of individual items - // should "steal" all pinches from the scroll view. So we're keeping a ref - // to all pinch gestures so that we may give them to <ScrollView waitFor={...}>. - const [pinchGestureRefs] = useState(new Map()) - for (let imageSrc of images) { - if (!pinchGestureRefs.get(imageSrc)) { - pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>()) - } - } - if (!visible) { return null } - const headerTransform = headerTranslate.getTranslateTransform() - const footerTransform = footerTranslate.getTranslateTransform() return ( <SafeAreaView style={styles.screen} - onLayout={onLayout} edges={edges} aria-modal accessibilityViewIsModal> - <ModalsContainer /> - <View style={[styles.container, {opacity, backgroundColor}]}> - <Animated.View style={[styles.header, {transform: headerTransform}]}> + <View style={[styles.container, {backgroundColor}]}> + <Animated.View style={[styles.header, animatedHeaderStyle]}> {typeof HeaderComponent !== 'undefined' ? ( React.createElement(HeaderComponent, { - imageIndex: currentImageIndex, + imageIndex, }) ) : ( - <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} /> + <ImageDefaultHeader onRequestClose={onRequestClose} /> )} </Animated.View> - <VirtualizedList - ref={imageList} - data={images} - horizontal - pagingEnabled - scrollEnabled={!isScaled || isDragging} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - getItem={(_, index) => images[index]} - getItemCount={() => images.length} - getItemLayout={(_, index) => ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, - index, - })} - renderItem={({item: imageSrc}) => ( - <ImageItem - onZoom={onZoom} - imageSrc={imageSrc} - onRequestClose={onRequestCloseEnhanced} - pinchGestureRef={pinchGestureRefs.get(imageSrc)} - isScrollViewBeingDragged={isDragging} - /> - )} - renderScrollComponent={props => ( - <ScrollView - {...props} - waitFor={Array.from(pinchGestureRefs.values())} - /> - )} - onScrollBeginDrag={() => { - setIsDragging(true) - }} - onScrollEndDrag={() => { - setIsDragging(false) - }} - onMomentumScrollEnd={e => { + <PagerView + scrollEnabled={!isScaled} + initialPage={initialImageIndex} + onPageSelected={e => { + setImageIndex(e.nativeEvent.position) setIsScaled(false) - onScroll(e) }} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } - /> + onPageScrollStateChanged={e => { + setIsDragging(e.nativeEvent.pageScrollState !== 'idle') + }} + overdrag={true} + style={styles.pager}> + {images.map(imageSrc => ( + <View key={imageSrc.uri}> + <ImageItem + onTap={onTap} + onZoom={onZoom} + imageSrc={imageSrc} + onRequestClose={onRequestClose} + isScrollViewBeingDragged={isDragging} + /> + </View> + ))} + </PagerView> {typeof FooterComponent !== 'undefined' && ( - <Animated.View style={[styles.footer, {transform: footerTransform}]}> + <Animated.View style={[styles.footer, animatedFooterStyle]}> {React.createElement(FooterComponent, { - imageIndex: currentImageIndex, + imageIndex, })} </Animated.View> )} @@ -236,11 +141,18 @@ function ImageViewing({ const styles = StyleSheet.create({ screen: { position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, }, container: { flex: 1, backgroundColor: '#000', }, + pager: { + flex: 1, + }, header: { position: 'absolute', width: '100%', @@ -257,7 +169,12 @@ const styles = StyleSheet.create({ }) const EnhancedImageViewing = (props: Props) => ( - <ImageViewing key={props.imageIndex} {...props} /> + <ImageViewing key={props.initialImageIndex} {...props} /> ) +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true, stiffness: 300}) +} + export default EnhancedImageViewing diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index ad66dce32..92c30f491 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() { return ( <ImageView images={[{uri: opts.profileView.avatar || ''}]} - imageIndex={0} + initialImageIndex={0} visible onRequestClose={onClose} FooterComponent={LightboxFooter} @@ -37,7 +37,7 @@ export const Lightbox = observer(function Lightbox() { return ( <ImageView images={opts.images.map(img => ({...img}))} - imageIndex={opts.index} + initialImageIndex={opts.index} visible onRequestClose={onClose} FooterComponent={LightboxFooter} diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index c92dabdca..012570556 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -1,11 +1,5 @@ import React, {useState} from 'react' -import { - ActivityIndicator, - KeyboardAvoidingView, - SafeAreaView, - StyleSheet, - View, -} from 'react-native' +import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView, TextInput} from './util' import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' @@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) { } return ( - <KeyboardAvoidingView - behavior="padding" - style={[pal.view, styles.container]}> - <SafeAreaView style={s.flex1}> - <ScrollView - testID="changeEmailModal" - style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.InputEmail ? 'Change Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} - {stage === Stages.Done ? 'Email Updated' : ''} - </Text> - </View> - - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - {stage === Stages.InputEmail ? ( - <>Enter your new email address below.</> - ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to your previous address,{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> - ) : ( - <> - Your email has been updated but not verified. As a next step, - please verify your new email. - </> - )} + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + testID="changeEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.InputEmail ? 'Change Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} + {stage === Stages.Done ? 'Email Updated' : ''} </Text> + </View> - {stage === Stages.InputEmail && ( - <TextInput - testID="emailInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="alice@mail.com" - placeholderTextColor={pal.colors.textLight} - value={email} - onChangeText={setEmail} - accessible={true} - accessibilityLabel="Email" - accessibilityHint="" - autoCapitalize="none" - autoComplete="email" - autoCorrect={false} - /> - )} - {stage === Stages.ConfirmCode && ( - <TextInput - testID="confirmCodeInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="XXXXX-XXXXX" - placeholderTextColor={pal.colors.textLight} - value={confirmationCode} - onChangeText={setConfirmationCode} - accessible={true} - accessibilityLabel="Confirmation code" - accessibilityHint="" - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.InputEmail ? ( + <>Enter your new email address below.</> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to your previous address,{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + <> + Your email has been updated but not verified. As a next step, + please verify your new email. + </> )} + </Text> - {error ? ( - <ErrorMessage message={error} style={styles.error} /> - ) : undefined} + {stage === Stages.InputEmail && ( + <TextInput + testID="emailInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="alice@mail.com" + placeholderTextColor={pal.colors.textLight} + value={email} + onChangeText={setEmail} + accessible={true} + accessibilityLabel="Email" + accessibilityHint="" + autoCapitalize="none" + autoComplete="email" + autoCorrect={false} + /> + )} + {stage === Stages.ConfirmCode && ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + )} - <View style={[styles.btnContainer]}> - {isProcessing ? ( - <View style={styles.btn}> - <ActivityIndicator color="#fff" /> - </View> - ) : ( - <View style={{gap: 6}}> - {stage === Stages.InputEmail && ( - <Button - testID="requestChangeBtn" - type="primary" - onPress={onRequestChange} - accessibilityLabel="Request Change" - accessibilityHint="" - label="Request Change" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - {stage === Stages.ConfirmCode && ( - <Button - testID="confirmBtn" - type="primary" - onPress={onConfirm} - accessibilityLabel="Confirm Change" - accessibilityHint="" - label="Confirm Change" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - {stage === Stages.Done && ( - <Button - testID="verifyBtn" - type="primary" - onPress={onVerify} - accessibilityLabel="Verify New Email" - accessibilityHint="" - label="Verify New Email" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.InputEmail && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestChange} + accessibilityLabel="Request Change" + accessibilityHint="" + label="Request Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ConfirmCode && ( <Button - testID="cancelBtn" - type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm Change" accessibilityHint="" - label="Cancel" + label="Confirm Change" labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> - </View> - )} - </View> - </ScrollView> - </SafeAreaView> - </KeyboardAvoidingView> + )} + {stage === Stages.Done && ( + <Button + testID="verifyBtn" + type="primary" + onPress={onVerify} + accessibilityLabel="Verify New Email" + accessibilityHint="" + label="Verify New Email" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Cancel" + accessibilityHint="" + label="Cancel" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> ) }) const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: isWeb ? 0 : 40, - }, titleSection: { paddingTop: isWeb ? 0 : 4, paddingBottom: isWeb ? 14 : 10, diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx index 3f3cfc5f0..4a440afeb 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -18,7 +18,7 @@ import {ListModel} from 'state/models/content/list' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' -import {UserAvatar} from '../util/UserAvatar' +import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' @@ -148,7 +148,7 @@ export function Component({ )} <Text style={[styles.label, pal.text]}>List Avatar</Text> <View style={[styles.avi, {borderColor: pal.colors.background}]}> - <UserAvatar + <EditableUserAvatar type="list" size={80} avatar={avatar} diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 620aad9fc..58d0857ad 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -20,7 +20,7 @@ import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' import {compressIfNeeded} from 'lib/media/manip' import {UserBanner} from '../util/UserBanner' -import {UserAvatar} from '../util/UserAvatar' +import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' @@ -153,7 +153,7 @@ export function Component({ onSelectNewBanner={onSelectNewBanner} /> <View style={[styles.avi, {borderColor: pal.colors.background}]}> - <UserAvatar + <EditableUserAvatar size={80} avatar={userAvatar} onSelectNewAvatar={onSelectNewAvatar} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 0cb0c56aa..09cfd4de7 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -26,33 +26,6 @@ export function Component({}: {}) { store.shell.closeModal() }, [store]) - if (store.me.invites === null) { - return ( - <View style={[styles.container, pal.view]} testID="inviteCodesModal"> - <Text type="title-xl" style={[styles.title, pal.text]}> - Error - </Text> - <Text type="lg" style={[styles.description, pal.text]}> - An error occurred while loading invite codes. - </Text> - <View style={styles.flex1} /> - <View - style={[ - styles.btnContainer, - isTabletOrDesktop && styles.btnContainerDesktop, - ]}> - <Button - type="primary" - label="Done" - style={styles.btn} - labelStyle={styles.btnLabel} - onPress={onClose} - /> - </View> - </View> - ) - } - if (store.me.invites.length === 0) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 4f3f424a3..1fe1299d7 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,11 +1,12 @@ import React, {useRef, useEffect} from 'react' import {StyleSheet} from 'react-native' -import {SafeAreaView} from 'react-native-safe-area-context' +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' +import {timeout} from 'lib/async/timeout' import {navigate} from '../../../Navigation' import once from 'lodash.once' @@ -36,11 +37,13 @@ import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' const DEFAULT_SNAPPOINTS = ['90%'] +const HANDLE_HEIGHT = 24 export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') + const safeAreaInsets = useSafeAreaInsets() const activeModal = store.shell.activeModals[store.shell.activeModals.length - 1] @@ -53,12 +56,16 @@ export const ModalsContainer = observer(function ModalsContainer() { navigateOnce('Profile', {name: activeModal.did}) } } - const onBottomSheetChange = (snapPoint: number) => { + const onBottomSheetChange = async (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { - // ensure we navigate to Profile and close the modal - navigateOnce('Profile', {name: activeModal.did}) + await navigateOnce('Profile', {name: activeModal.did}) + // There is no particular callback for when the view has actually been presented. + // This delay gives us a decent chance the navigation has flushed *and* images have loaded. + // It's acceptable because the data is already being fetched + it usually takes longer anyway. + // TODO: Figure out why avatar/cover don't always show instantly from cache. + await timeout(200) store.shell.closeModal() } } @@ -75,6 +82,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name]) + let needsSafeTopInset = false let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element if (activeModal?.name === 'confirm') { @@ -86,6 +94,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'profile-preview') { snapPoints = ProfilePreviewModal.snapPoints element = <ProfilePreviewModal.Component {...activeModal} /> + needsSafeTopInset = true // Need to align with the target profile screen. } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = <ServerInputModal.Component {...activeModal} /> @@ -164,10 +173,13 @@ export const ModalsContainer = observer(function ModalsContainer() { ) } + const topInset = needsSafeTopInset ? safeAreaInsets.top - HANDLE_HEIGHT : 0 return ( <BottomSheet ref={bottomSheetRef} snapPoints={snapPoints} + topInset={topInset} + handleHeight={HANDLE_HEIGHT} index={store.shell.isModalActive ? 0 : -1} enablePanDownToClose android_keyboardInputMode="adjustResize" diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index 225a3972b..dad02aa5e 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -9,7 +9,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' import {InfoCircleIcon} from 'lib/icons' import {useNavigationState} from '@react-navigation/native' -import {isIOS} from 'platform/detection' import {s} from 'lib/styles' export const snapPoints = [520, '100%'] @@ -36,11 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({ return ( <View testID="profilePreview" style={[pal.view, s.flex1]}> - <View - style={[ - styles.headerWrapper, - isLoading && isIOS && styles.headerPositionAdjust, - ]}> + <View style={[styles.headerWrapper]}> <ProfileHeader view={model} hideBackButton @@ -70,10 +65,6 @@ const styles = StyleSheet.create({ headerWrapper: { height: 440, }, - headerPositionAdjust: { - // HACK align the header for the profilescreen transition -prf - paddingTop: 23, - }, hintWrapper: { height: 80, }, diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 0a626a4ef..9fe8811b0 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -1,7 +1,6 @@ import React, {useState} from 'react' import { ActivityIndicator, - KeyboardAvoidingView, Pressable, SafeAreaView, StyleSheet, @@ -82,169 +81,163 @@ export const Component = observer(function Component({ } return ( - <KeyboardAvoidingView - behavior="padding" - style={[pal.view, styles.container]}> - <SafeAreaView style={s.flex1}> - <ScrollView - testID="verifyEmailModal" - style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> - {stage === Stages.Reminder && <ReminderIllustration />} - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} - {stage === Stages.Email ? 'Verify Your Email' : ''} - </Text> - </View> - - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - {stage === Stages.Reminder ? ( - <> - Your email has not yet been verified. This is an important - security step which we recommend. - </> - ) : stage === Stages.Email ? ( - <> - This is important in case you ever need to change your email or - reset your password. - </> - ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> - ) : ( - '' - )} + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + testID="verifyEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + {stage === Stages.Reminder && <ReminderIllustration />} + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} + {stage === Stages.Email ? 'Verify Your Email' : ''} </Text> + </View> - {stage === Stages.Email ? ( + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.Reminder ? ( <> - <View style={styles.emailContainer}> - <FontAwesomeIcon - icon="envelope" - color={pal.colors.text} - size={16} - /> - <Text - type="xl-medium" - style={[pal.text, s.flex1, {minWidth: 0}]}> - {store.session.currentSession?.email || ''} - </Text> - </View> - <Pressable - accessibilityRole="link" - accessibilityLabel="Change my email" - accessibilityHint="" - onPress={onEmailIncorrect} - style={styles.changeEmailLink}> - <Text type="lg" style={pal.link}> - Change - </Text> - </Pressable> + Your email has not yet been verified. This is an important + security step which we recommend. + </> + ) : stage === Stages.Email ? ( + <> + This is important in case you ever need to change your email or + reset your password. </> ) : stage === Stages.ConfirmCode ? ( - <TextInput - testID="confirmCodeInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="XXXXX-XXXXX" - placeholderTextColor={pal.colors.textLight} - value={confirmationCode} - onChangeText={setConfirmationCode} - accessible={true} - accessibilityLabel="Confirmation code" + <> + An email has been sent to{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + '' + )} + </Text> + + {stage === Stages.Email ? ( + <> + <View style={styles.emailContainer}> + <FontAwesomeIcon + icon="envelope" + color={pal.colors.text} + size={16} + /> + <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> + {store.session.currentSession?.email || ''} + </Text> + </View> + <Pressable + accessibilityRole="link" + accessibilityLabel="Change my email" accessibilityHint="" - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> - ) : undefined} + onPress={onEmailIncorrect} + style={styles.changeEmailLink}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Pressable> + </> + ) : stage === Stages.ConfirmCode ? ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + ) : undefined} - {error ? ( - <ErrorMessage message={error} style={styles.error} /> - ) : undefined} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} - <View style={[styles.btnContainer]}> - {isProcessing ? ( - <View style={styles.btn}> - <ActivityIndicator color="#fff" /> - </View> - ) : ( - <View style={{gap: 6}}> - {stage === Stages.Reminder && ( + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.Reminder && ( + <Button + testID="getStartedBtn" + type="primary" + onPress={() => setStage(Stages.Email)} + accessibilityLabel="Get Started" + accessibilityHint="" + label="Get Started" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Email && ( + <> <Button - testID="getStartedBtn" + testID="sendEmailBtn" type="primary" - onPress={() => setStage(Stages.Email)} - accessibilityLabel="Get Started" + onPress={onSendEmail} + accessibilityLabel="Send Confirmation Email" accessibilityHint="" - label="Get Started" - labelContainerStyle={{justifyContent: 'center', padding: 4}} + label="Send Confirmation Email" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} labelStyle={[s.f18]} /> - )} - {stage === Stages.Email && ( - <> - <Button - testID="sendEmailBtn" - type="primary" - onPress={onSendEmail} - accessibilityLabel="Send Confirmation Email" - accessibilityHint="" - label="Send Confirmation Email" - labelContainerStyle={{ - justifyContent: 'center', - padding: 4, - }} - labelStyle={[s.f18]} - /> - <Button - testID="haveCodeBtn" - type="default" - accessibilityLabel="I have a code" - accessibilityHint="" - label="I have a confirmation code" - labelContainerStyle={{ - justifyContent: 'center', - padding: 4, - }} - labelStyle={[s.f18]} - onPress={() => setStage(Stages.ConfirmCode)} - /> - </> - )} - {stage === Stages.ConfirmCode && ( <Button - testID="confirmBtn" - type="primary" - onPress={onConfirm} - accessibilityLabel="Confirm" + testID="haveCodeBtn" + type="default" + accessibilityLabel="I have a code" accessibilityHint="" - label="Confirm" - labelContainerStyle={{justifyContent: 'center', padding: 4}} + label="I have a confirmation code" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} labelStyle={[s.f18]} + onPress={() => setStage(Stages.ConfirmCode)} /> - )} + </> + )} + {stage === Stages.ConfirmCode && ( <Button - testID="cancelBtn" - type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel={ - stage === Stages.Reminder ? 'Not right now' : 'Cancel' - } + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm" accessibilityHint="" - label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + label="Confirm" labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> - </View> - )} - </View> - </ScrollView> - </SafeAreaView> - </KeyboardAvoidingView> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel={ + stage === Stages.Reminder ? 'Not right now' : 'Cancel' + } + accessibilityHint="" + label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> ) }) @@ -274,10 +267,6 @@ function ReminderIllustration() { } const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: isWeb ? 0 : 40, - }, titleSection: { paddingTop: isWeb ? 0 : 4, paddingBottom: isWeb ? 14 : 10, diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index 1104c0a39..0fb371fe4 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -77,6 +77,8 @@ export function Component({}: {}) { keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} + onSubmitEditing={onPressSignup} + enterKeyHint="done" accessible={true} accessibilityLabel="Email" accessibilityHint="Input your email to get on the Bluesky waitlist" diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index c5959cf4c..8e35201d1 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -100,7 +100,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to wide"> <RectWideIcon size={24} - style={as === AspectRatio.Wide ? s.blue3 : undefined} + style={as === AspectRatio.Wide ? s.blue3 : pal.text} /> </TouchableOpacity> <TouchableOpacity @@ -110,7 +110,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to tall"> <RectTallIcon size={24} - style={as === AspectRatio.Tall ? s.blue3 : undefined} + style={as === AspectRatio.Tall ? s.blue3 : pal.text} /> </TouchableOpacity> <TouchableOpacity @@ -120,7 +120,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to square"> <SquareIcon size={24} - style={as === AspectRatio.Square ? s.blue3 : undefined} + style={as === AspectRatio.Square ? s.blue3 : pal.text} /> </TouchableOpacity> </View> diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 02aa623cc..dc91bd296 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,13 +1,14 @@ import React, {useMemo} from 'react' -import {Animated, StyleSheet} from 'react-native' +import {StyleSheet} from 'react-native' +import Animated from 'react-native-reanimated' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -31,26 +32,12 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( [store.me.savedFeeds.pinnedFeedNames], ) const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [ - {translateX: '-50%'}, - {translateY: Animated.multiply(interp, -100)}, - ], - } + const {headerMinimalShellTransform} = useMinimalShellMode() return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf - <Animated.View style={[pal.view, styles.tabBar, transform]}> + <Animated.View + style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> <TabBar key={items.join(',')} {...props} @@ -65,7 +52,8 @@ const styles = StyleSheet.create({ tabBar: { position: 'absolute', zIndex: 1, - left: '50%', + // @ts-ignore Web only -prf + left: 'calc(50% - 299px)', width: 598, top: 0, flexDirection: 'row', diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index e39e2dd68..d8579badc 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,11 +1,10 @@ import React, {useMemo} from 'react' -import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -13,27 +12,17 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const store = useStores() const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) + const {headerMinimalShellTransform} = useMinimalShellMode() const onPressAvi = React.useCallback(() => { store.shell.openDrawer() @@ -44,8 +33,19 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( [store.me.savedFeeds.pinnedFeedNames], ) + const tabBarKey = useMemo(() => { + return items.join(',') + }, [items]) + return ( - <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}> + <Animated.View + style={[ + pal.view, + pal.border, + styles.tabBar, + headerMinimalShellTransform, + store.shell.minimalShellMode && styles.disabled, + ]}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity @@ -81,8 +81,11 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </View> </View> <TabBar - key={items.join(',')} - {...props} + key={tabBarKey} + onPressSelected={props.onPressSelected} + selectedPage={props.selectedPage} + onSelect={props.onSelect} + testID={props.testID} items={items} indicatorColor={pal.colors.link} /> @@ -113,4 +116,7 @@ const styles = StyleSheet.create({ title: { fontSize: 21, }, + disabled: { + pointerEvents: 'none', + }, }) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 319d28f95..8614bdf64 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -64,6 +64,7 @@ export function TabBar({ ) const styles = isDesktop || isTablet ? desktopStyles : mobileStyles + return ( <View testID={testID} style={[pal.view, styles.outer]}> <DraggableScrollView diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index b095fe07b..74883f82a 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -45,7 +45,7 @@ export const Feed = observer(function Feed({ onPressTryAgain?: () => void onScroll?: OnScrollCb scrollEventThrottle?: number - renderEmptyState?: () => JSX.Element + renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number @@ -96,7 +96,7 @@ export const Feed = observer(function Feed({ }, [feed, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded) return + if (!feed.hasLoaded || !feed.hasMore) return track('Feed:onEndReached') try { @@ -116,10 +116,7 @@ export const Feed = observer(function Feed({ const renderItem = React.useCallback( ({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> + return renderEmptyState() } else if (item === ERROR_ITEM) { return ( <ErrorMessage @@ -181,7 +178,7 @@ export const Feed = observer(function Feed({ scrollEventThrottle={scrollEventThrottle} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} removeClippedSubviews={true} contentOffset={{x: 0, y: headerOffset * -1}} extraData={extraData} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index baf95af6c..5514bf98e 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -60,14 +60,14 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ if (!view || !view.hasLoaded) { return ( <View style={pal.view}> - <LoadingPlaceholder width="100%" height={120} /> + <LoadingPlaceholder width="100%" height={153} /> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> <LoadingPlaceholder width={80} height={80} style={styles.br40} /> </View> <View style={styles.content}> <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={100} height={31} style={styles.br50} /> + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> </View> <View> <Text type="title-2xl" style={[pal.text, styles.title]}> @@ -132,20 +132,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, [store, view]) const onPressToggleFollow = React.useCallback(() => { - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', - ) view?.toggleFollowing().then( () => { setShowSuggestedFollows(Boolean(view.viewer.following)) - Toast.show( `${ view.viewer.following ? 'Following' : 'No longer following' } ${sanitizeDisplayName(view.displayName || view.handle)}`, ) + track( + view.viewer.following + ? 'ProfileHeader:FollowButtonClicked' + : 'ProfileHeader:UnfollowButtonClicked', + ) }, err => store.log.error('Failed to toggle follow', err), ) diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 41e4022d5..cf759ddd1 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View, StyleSheet, ScrollView, Pressable} from 'react-native' +import {View, StyleSheet, Pressable, ScrollView} from 'react-native' import Animated, { useSharedValue, withTiming, @@ -26,6 +26,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {Link} from 'view/com/util/Link' import {useAnalytics} from 'lib/analytics/analytics' +import {isWeb} from 'platform/detection' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -100,7 +101,6 @@ export function ProfileHeaderSuggestedFollows({ backgroundColor: pal.viewLight.backgroundColor, height: '100%', paddingTop: INNER_PADDING / 2, - paddingBottom: INNER_PADDING, }}> <View style={{ @@ -130,11 +130,15 @@ export function ProfileHeaderSuggestedFollows({ </View> <ScrollView - horizontal - showsHorizontalScrollIndicator={false} + horizontal={true} + showsHorizontalScrollIndicator={isWeb} + persistentScrollbar={true} + scrollIndicatorInsets={{bottom: 0}} + scrollEnabled={true} contentContainerStyle={{ alignItems: 'flex-start', paddingLeft: INNER_PADDING / 2, + paddingBottom: INNER_PADDING, }}> {isLoading ? ( <> @@ -223,9 +227,9 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({ const onPress = React.useCallback(async () => { try { - const {following} = await toggle() + const {following: isFollowing} = await toggle() - if (following) { + if (isFollowing) { track('ProfileHeader:SuggestedFollowFollowed') } } catch (e: any) { diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 6915d3e08..35524bcc6 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,5 +1,4 @@ -import React, {ComponentProps, useMemo} from 'react' -import {observer} from 'mobx-react-lite' +import React, {ComponentProps, memo, useMemo} from 'react' import { Linking, GestureResponderEvent, @@ -50,7 +49,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { anchorNoUnderline?: boolean } -export const Link = observer(function Link({ +export const Link = memo(function Link({ testID, style, href, @@ -136,7 +135,7 @@ export const Link = observer(function Link({ ) }) -export const TextLink = observer(function TextLink({ +export const TextLink = memo(function TextLink({ testID, type = 'md', style, @@ -236,7 +235,7 @@ interface DesktopWebTextLinkProps extends TextProps { accessibilityHint?: string title?: string } -export const DesktopWebTextLink = observer(function DesktopWebTextLink({ +export const DesktopWebTextLink = memo(function DesktopWebTextLink({ testID, type = 'md', style, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d24e47499..fbc0b5e11 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -23,14 +23,18 @@ interface BaseUserAvatarProps { type?: Type size: number avatar?: string | null - moderation?: ModerationUI } interface UserAvatarProps extends BaseUserAvatarProps { - onSelectNewAvatar?: (img: RNImage | null) => void + moderation?: ModerationUI +} + +interface EditableUserAvatarProps extends BaseUserAvatarProps { + onSelectNewAvatar: (img: RNImage | null) => void } interface PreviewableUserAvatarProps extends BaseUserAvatarProps { + moderation?: ModerationUI did: string handle: string } @@ -106,8 +110,65 @@ export function UserAvatar({ size, avatar, moderation, - onSelectNewAvatar, }: UserAvatarProps) { + const pal = usePalette('default') + + const aviStyle = useMemo(() => { + if (type === 'algo' || type === 'list') { + return { + width: size, + height: size, + borderRadius: size > 32 ? 8 : 3, + } + } + return { + width: size, + height: size, + borderRadius: Math.floor(size / 2), + } + }, [type, size]) + + const alert = useMemo(() => { + if (!moderation?.alert) { + return null + } + return ( + <View style={[styles.alertIconContainer, pal.view]}> + <FontAwesomeIcon + icon="exclamation-circle" + style={styles.alertIcon} + size={Math.floor(size / 3)} + /> + </View> + ) + }, [moderation?.alert, size, pal]) + + return avatar && + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( + <View style={{width: size, height: size}}> + <HighPriorityImage + testID="userAvatarImage" + style={aviStyle} + contentFit="cover" + source={{uri: avatar}} + blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} + /> + {alert} + </View> + ) : ( + <View style={{width: size, height: size}}> + <DefaultAvatar type={type} size={size} /> + {alert} + </View> + ) +} + +export function EditableUserAvatar({ + type = 'user', + size, + avatar, + onSelectNewAvatar, +}: EditableUserAvatarProps) { const store = useStores() const pal = usePalette('default') const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -146,7 +207,7 @@ export function UserAvatar({ return } - onSelectNewAvatar?.( + onSelectNewAvatar( await openCamera(store, { width: 1000, height: 1000, @@ -186,7 +247,7 @@ export function UserAvatar({ path: item.path, }) - onSelectNewAvatar?.(croppedImage) + onSelectNewAvatar(croppedImage) }, }, !!avatar && { @@ -203,7 +264,7 @@ export function UserAvatar({ web: 'trash', }, onPress: async () => { - onSelectNewAvatar?.(null) + onSelectNewAvatar(null) }, }, ].filter(Boolean) as DropdownItem[], @@ -216,23 +277,7 @@ export function UserAvatar({ ], ) - const alert = useMemo(() => { - if (!moderation?.alert) { - return null - } - return ( - <View style={[styles.alertIconContainer, pal.view]}> - <FontAwesomeIcon - icon="exclamation-circle" - style={styles.alertIcon} - size={Math.floor(size / 3)} - /> - </View> - ) - }, [moderation?.alert, size, pal]) - - // onSelectNewAvatar is only passed as prop on the EditProfile component - return onSelectNewAvatar ? ( + return ( <NativeDropdown testID="changeAvatarBtn" items={dropdownItems} @@ -256,23 +301,6 @@ export function UserAvatar({ /> </View> </NativeDropdown> - ) : avatar && - !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( - <View style={{width: size, height: size}}> - <HighPriorityImage - testID="userAvatarImage" - style={aviStyle} - contentFit="cover" - source={{uri: avatar}} - blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} - /> - {alert} - </View> - ) : ( - <View style={{width: size, height: size}}> - <DefaultAvatar type={type} size={size} /> - {alert} - </View> ) } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 164028708..ec459b4eb 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,16 +1,17 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {CenteredView} from './Views' import {Text} from './text/Text' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -149,30 +150,8 @@ const Container = observer(function ContainerImpl({ hideOnScroll: boolean showBorder?: boolean }) { - const store = useStores() const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - if (store.shell.minimalShellMode) { - Animated.timing(interp, { - toValue: 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } else { - Animated.timing(interp, { - toValue: 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } + const {headerMinimalShellTransform} = useMinimalShellMode() if (!hideOnScroll) { return ( @@ -195,7 +174,7 @@ const Container = observer(function ContainerImpl({ styles.headerFloating, pal.view, pal.border, - transform, + headerMinimalShellTransform, showBorder && styles.border, ]}> {children} diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index 6c0e4c6cc..935d93033 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -144,8 +144,6 @@ export function Selector({ items: string[] onSelect?: (index: number) => void }) { - const [height, setHeight] = useState(0) - const pal = usePalette('default') const borderColor = useColorSchemeStyle( {borderColor: colors.black}, @@ -160,22 +158,13 @@ export function Selector({ <View style={{ width: '100%', - position: 'relative', - overflow: 'hidden', - height, backgroundColor: pal.colors.background, }}> <ScrollView testID="selector" horizontal - showsHorizontalScrollIndicator={false} - style={{position: 'absolute'}}> - <View - style={[pal.view, styles.outer]} - onLayout={e => { - const {height: layoutHeight} = e.nativeEvent.layout - setHeight(layoutHeight || 60) - }}> + showsHorizontalScrollIndicator={false}> + <View style={[pal.view, styles.outer]}> {items.map((item, i) => { const selected = i === selectedIndex return ( diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 6c96eef2c..5b1d5d888 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,13 +1,13 @@ import React, {ComponentProps} from 'react' import {observer} from 'mobx-react-lite' -import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native' +import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {gradients} from 'lib/styles' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lib/numbers' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' export interface FABProps extends ComponentProps<typeof TouchableWithoutFeedback> { @@ -21,28 +21,30 @@ export const FABInner = observer(function FABInnerImpl({ ...props }: FABProps) { const insets = useSafeAreaInsets() - const {isTablet} = useWebMediaQueries() - const store = useStores() - const interp = useAnimatedValue(0) - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 0 : 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = isTablet - ? undefined - : { - transform: [{translateY: Animated.multiply(interp, -44)}], - } - const size = isTablet ? styles.sizeLarge : styles.sizeRegular - const right = isTablet ? 50 : 24 - const bottom = isTablet ? 50 : clamp(insets.bottom, 15, 60) + 15 + const {isMobile, isTablet} = useWebMediaQueries() + const {fabMinimalShellTransform} = useMinimalShellMode() + + const size = React.useMemo(() => { + return isTablet ? styles.sizeLarge : styles.sizeRegular + }, [isTablet]) + const tabletSpacing = React.useMemo(() => { + return isTablet + ? {right: 50, bottom: 50} + : { + right: 24, + bottom: clamp(insets.bottom, 15, 60) + 15, + } + }, [insets.bottom, isTablet]) + return ( <TouchableWithoutFeedback testID={testID} {...props}> - <Animated.View style={[styles.outer, size, {right, bottom}, transform]}> + <Animated.View + style={[ + styles.outer, + size, + tabletSpacing, + isMobile && fabMinimalShellTransform, + ]}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 679f71c99..094b0c56c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -23,19 +23,19 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onLongPress, }) => { const image = images[index] - return ( - <View> + <View style={styles.fullWidth}> <Pressable onPress={onPress ? () => onPress(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} + style={styles.fullWidth} accessibilityRole="button" accessibilityLabel={image.alt || 'Image'} accessibilityHint=""> <Image source={{uri: image.thumb}} - style={imageStyle} + style={[styles.image, imageStyle]} accessible={true} accessibilityLabel={image.alt} accessibilityHint="" @@ -54,14 +54,21 @@ export const GalleryItem: FC<GalleryItemProps> = ({ } const styles = StyleSheet.create({ + fullWidth: { + flex: 1, + }, + image: { + flex: 1, + borderRadius: 4, + }, altContainer: { backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', - left: 6, - bottom: 6, + left: 8, + bottom: 8, }, alt: { color: 'white', diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 4c0901304..4aa6f28de 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,13 +1,5 @@ -import React, {useMemo, useState} from 'react' -import { - LayoutChangeEvent, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {ImageStyle} from 'expo-image' -import {Dimensions} from 'lib/media/types' +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {AppBskyEmbedImages} from '@atproto/api' import {GalleryItem} from './Gallery' @@ -20,21 +12,11 @@ interface ImageLayoutGridProps { } export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { - const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() - - const onLayout = (evt: LayoutChangeEvent) => { - const {width, height} = evt.nativeEvent.layout - setContainerInfo({ - width, - height, - }) - } - return ( - <View style={style} onLayout={onLayout}> - {containerInfo ? ( - <ImageLayoutGridInner {...props} containerInfo={containerInfo} /> - ) : undefined} + <View style={style}> + <View style={styles.container}> + <ImageLayoutGridInner {...props} /> + </View> </View> ) } @@ -44,70 +26,80 @@ interface ImageLayoutGridInnerProps { onPress?: (index: number) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void - containerInfo: Dimensions } -function ImageLayoutGridInner({ - containerInfo, - ...props -}: ImageLayoutGridInnerProps) { +function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const count = props.images.length - const size1 = useMemo<ImageStyle>(() => { - if (count === 3) { - const size = (containerInfo.width - 10) / 3 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } else { - const size = (containerInfo.width - 5) / 2 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } - }, [count, containerInfo]) - const size2 = React.useMemo<ImageStyle>(() => { - if (count === 3) { - const size = ((containerInfo.width - 10) / 3) * 2 + 5 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } else { - const size = (containerInfo.width - 5) / 2 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } - }, [count, containerInfo]) switch (count) { case 2: return ( <View style={styles.flexRow}> - <GalleryItem index={0} {...props} imageStyle={size1} /> - <GalleryItem index={1} {...props} imageStyle={size1} /> + <View style={styles.smallItem}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> </View> ) + case 3: return ( <View style={styles.flexRow}> - <GalleryItem index={0} {...props} imageStyle={size2} /> - <View style={styles.flexColumn}> - <GalleryItem index={1} {...props} imageStyle={size1} /> - <GalleryItem index={2} {...props} imageStyle={size1} /> + <View style={{flex: 2, aspectRatio: 1}}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={{flex: 1}}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> + </View> </View> </View> ) + case 4: return ( - <View style={styles.flexRow}> - <View style={styles.flexColumn}> - <GalleryItem index={0} {...props} imageStyle={size1} /> - <GalleryItem index={2} {...props} imageStyle={size1} /> + <> + <View style={styles.flexRow}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> + </View> </View> - <View style={styles.flexColumn}> - <GalleryItem index={1} {...props} imageStyle={size1} /> - <GalleryItem index={3} {...props} imageStyle={size1} /> + <View style={styles.flexRow}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={3} imageStyle={styles.image} /> + </View> </View> - </View> + </> ) + default: return null } } +// This is used to compute margins (rather than flexbox gap) due to Yoga bugs: +// https://github.com/facebook/yoga/issues/1418 +const IMAGE_GAP = 5 + const styles = StyleSheet.create({ - flexRow: {flexDirection: 'row', gap: 5}, - flexColumn: {flexDirection: 'column', gap: 5}, + container: { + marginHorizontal: -IMAGE_GAP / 2, + marginVertical: -IMAGE_GAP / 2, + }, + flexRow: {flexDirection: 'row'}, + smallItem: {flex: 1, aspectRatio: 1}, + image: { + margin: IMAGE_GAP / 2, + }, }) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index f5d12ce2c..b16a42396 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -2,14 +2,14 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {colors} from 'lib/styles' import {HITSLOP_20} from 'lib/constants' -import {isWeb} from 'platform/detection' -import {clamp} from 'lib/numbers' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' +const AnimatedTouchableOpacity = + Animated.createAnimatedComponent(TouchableOpacity) export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ onPress, @@ -19,26 +19,20 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ onPress: () => void label: string showIndicator: boolean - minimalShellMode?: boolean // NOTE not used on mobile -prf }) { - const store = useStores() const pal = usePalette('default') - const {isDesktop, isTablet} = useWebMediaQueries() - const safeAreaInsets = useSafeAreaInsets() - const minMode = store.shell.minimalShellMode - const bottom = isTablet - ? 50 - : (minMode || isDesktop ? 16 : 60) + - (isWeb ? 20 : clamp(safeAreaInsets.bottom, 15, 60)) + const {isDesktop, isTablet, isMobile} = useWebMediaQueries() + const {fabMinimalShellTransform} = useMinimalShellMode() + return ( - <TouchableOpacity + <AnimatedTouchableOpacity style={[ styles.loadLatest, isDesktop && styles.loadLatestDesktop, isTablet && styles.loadLatestTablet, pal.borderDark, pal.view, - {bottom}, + isMobile && fabMinimalShellTransform, ]} onPress={onPress} hitSlop={HITSLOP_20} @@ -47,7 +41,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ accessibilityHint=""> <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} - </TouchableOpacity> + </AnimatedTouchableOpacity> ) }) @@ -66,15 +60,11 @@ const styles = StyleSheet.create({ }, loadLatestTablet: { // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-282px)', + left: 'calc(50vw - 282px)', }, loadLatestDesktop: { // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-382px)', + left: 'calc(50vw - 382px)', }, indicator: { position: 'absolute', diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 8560ad445..ad47e9f9b 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,33 +1,22 @@ import React from 'react' -import {FlatList, View, useWindowDimensions} from 'react-native' -import {useFocusEffect, useIsFocused} from '@react-navigation/native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {useWindowDimensions} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' -import useAppState from 'react-native-appstate-hook' import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {TextLink} from 'view/com/util/Link' -import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' -import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {FAB} from '../com/util/fab/FAB' import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s, colors} from 'lib/styles' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {ComposeIcon2} from 'lib/icons' +import {FeedPage} from 'view/com/feeds/FeedPage' -const POLL_FREQ = 30e3 // 30sec +export const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired( @@ -98,7 +87,9 @@ export const HomeScreen = withAuthRequired( (props: RenderTabBarFnProps) => { return ( <FeedsTabBar - {...props} + key="FEEDS_TAB_BAR" + selectedPage={props.selectedPage} + onSelect={props.onSelect} testID="homeScreenFeedTabs" onPressSelected={onPressSelected} /> @@ -111,10 +102,6 @@ export const HomeScreen = withAuthRequired( return <FollowingEmptyState /> }, []) - const renderFollowingEndOfFeed = React.useCallback(() => { - return <FollowingEndOfFeed /> - }, []) - const renderCustomFeedEmptyState = React.useCallback(() => { return <CustomFeedEmptyState /> }, []) @@ -132,7 +119,7 @@ export const HomeScreen = withAuthRequired( isPageFocused={selectedPage === 0} feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={renderFollowingEndOfFeed} + renderEndOfFeed={FollowingEndOfFeed} /> {customFeeds.map((f, index) => { return ( @@ -150,196 +137,7 @@ export const HomeScreen = withAuthRequired( }), ) -const FeedPage = observer(function FeedPageImpl({ - testID, - isPageFocused, - feed, - renderEmptyState, - renderEndOfFeed, -}: { - testID?: string - feed: PostsFeedModel - isPageFocused: boolean - renderEmptyState?: () => JSX.Element - renderEndOfFeed?: () => JSX.Element -}) { - const store = useStores() - const pal = usePalette('default') - const {isDesktop} = useWebMediaQueries() - const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) - const {screen, track} = useAnalytics() - const headerOffset = useHeaderOffset() - const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) - const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - store.log.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, store, feed], - ) - - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: -headerOffset}) - resetMainScroll() - }, [headerOffset, resetMainScroll]) - - const onSoftReset = React.useCallback(() => { - if (isPageFocused) { - scrollToTop() - feed.refresh() - } - }, [isPageFocused, scrollToTop, feed]) - - // fires when page within screen is activated/deactivated - // - check for latest - React.useEffect(() => { - if (!isPageFocused || !isScreenFocused) { - return - } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - - screen('Feed') - store.log.debug('HomeScreen: Updating feed') - feed.checkForLatest() - - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) - - const onPressCompose = React.useCallback(() => { - track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) - - const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) - - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) - - const ListHeaderComponent = React.useCallback(() => { - if (isDesktop) { - return ( - <View - style={[ - pal.view, - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 18, - paddingVertical: 12, - }, - ]}> - <TextLink - type="title-lg" - href="/" - style={[pal.text, {fontWeight: 'bold'}]} - text={ - <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} - {hasNew && ( - <View - style={{ - top: -8, - backgroundColor: colors.blue3, - width: 8, - height: 8, - borderRadius: 4, - }} - /> - )} - </> - } - onPress={() => store.emitScreenSoftReset()} - /> - <TextLink - type="title-lg" - href="/settings/home-feed" - style={{fontWeight: 'bold'}} - accessibilityLabel="Feed Preferences" - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } - /> - </View> - ) - } - return <></> - }, [isDesktop, pal, store, hasNew]) - - return ( - <View testID={testID} style={s.h100pct}> - <Feed - testID={testID ? `${testID}-feed` : undefined} - key="default" - feed={feed} - scrollElRef={scrollElRef} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollEventThrottle={100} - renderEmptyState={renderEmptyState} - renderEndOfFeed={renderEndOfFeed} - ListHeaderComponent={ListHeaderComponent} - headerOffset={headerOffset} - /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new posts" - showIndicator={hasNew} - minimalShellMode={store.shell.minimalShellMode} - /> - )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) -}) - -function useHeaderOffset() { +export function useHeaderOffset() { const {isDesktop, isTablet} = useWebMediaQueries() const {fontScale} = useWindowDimensions() if (isDesktop) { diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 977401350..b00bfb765 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -156,7 +156,6 @@ export const NotificationsScreen = withAuthRequired( onPress={onPressLoadLatest} label="Load new notifications" showIndicator={hasNew} - minimalShellMode={true} /> )} </View> diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index f75222c1f..2112ec7d1 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -322,45 +322,37 @@ export const SettingsScreen = withAuthRequired( <View style={styles.spacer20} /> - {store.me.invitesAvailable !== null && ( - <> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Invite a Friend - </Text> - <TouchableOpacity - testID="inviteFriendBtn" - style={[ - styles.linkCard, - pal.view, - isSwitching && styles.dimmed, - ]} - onPress={isSwitching ? undefined : onPressInviteCodes} - accessibilityRole="button" - accessibilityLabel="Invite" - accessibilityHint="Opens invite code list"> - <View - style={[ - styles.iconContainer, - store.me.invitesAvailable > 0 ? primaryBg : pal.btn, - ]}> - <FontAwesomeIcon - icon="ticket" - style={ - (store.me.invitesAvailable > 0 - ? primaryText - : pal.text) as FontAwesomeIconStyle - } - /> - </View> - <Text - type="lg" - style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> - </> - )} + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Invite a Friend + </Text> + <TouchableOpacity + testID="inviteFriendBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={isSwitching ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel="Invite" + accessibilityHint="Opens invite code list"> + <View + style={[ + styles.iconContainer, + store.me.invitesAvailable > 0 ? primaryBg : pal.btn, + ]}> + <FontAwesomeIcon + icon="ticket" + style={ + (store.me.invitesAvailable > 0 + ? primaryText + : pal.text) as FontAwesomeIconStyle + } + /> + </View> + <Text + type="lg" + style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} available + </Text> + </TouchableOpacity> <View style={styles.spacer20} /> diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index de1b38b84..dc00d473d 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -9,6 +9,7 @@ import {TextLink} from 'view/com/util/Link' import {CenteredView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {HELP_DESK_URL} from 'lib/constants' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'> export const SupportScreen = (_props: Props) => { @@ -29,14 +30,13 @@ export const SupportScreen = (_props: Props) => { Support </Text> <Text style={[pal.text, s.p20]}> - If you need help, email us at{' '} + The support form has been moved. If you need help, please <TextLink - href="mailto:support@bsky.app" - text="support@bsky.app" + href={HELP_DESK_URL} + text=" click here" style={pal.link} />{' '} - with a description of your issue and information about how we can help - you. + or visit {HELP_DESK_URL} to get in touch with us. </Text> </CenteredView> </View> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 48341170c..51a846c4a 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -426,34 +426,32 @@ const InviteCodes = observer(function InviteCodesImpl({ store.shell.openModal({name: 'invite-codes'}) }, [store, track]) return ( - store.me.invitesAvailable !== null && ( - <TouchableOpacity - testID="menuItemInviteCodes" - style={[styles.inviteCodes, style]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <FontAwesomeIcon - icon="ticket" - style={[ - styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, - ]} - size={18} - /> - <Text - type="lg-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} - </Text> - </TouchableOpacity> - ) + <TouchableOpacity + testID="menuItemInviteCodes" + style={[styles.inviteCodes, style]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <FontAwesomeIcon + icon="ticket" + style={[ + styles.inviteCodesIcon, + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + ]} + size={18} + /> + <Text + type="lg-medium" + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} + </Text> + </TouchableOpacity> ) }) diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 4758c5e01..cfd4d46d0 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,10 +1,6 @@ import React, {ComponentProps} from 'react' -import { - Animated, - GestureResponderEvent, - TouchableOpacity, - View, -} from 'react-native' +import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' +import Animated from 'react-native-reanimated' import {StackActions} from '@react-navigation/native' import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -87,6 +83,7 @@ export const BottomBar = observer(function BottomBarImpl({ pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, footerMinimalShellTransform, + store.shell.minimalShellMode && styles.disabled, ]}> <Btn testID="bottomBarHomeBtn" diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index ae9381440..c175ed848 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -65,4 +65,7 @@ export const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 100, }, + disabled: { + pointerEvents: 'none', + }, }) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index e20214235..ebcc527a1 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -2,8 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {Animated} from 'react-native' import {useNavigationState} from '@react-navigation/native' +import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {getCurrentRoute, isTab} from 'lib/routes/helpers' import {styles} from './BottomBarStyles' diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index f0e986bf4..84d7d7854 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -7,7 +7,6 @@ import {DesktopSearch} from './Search' import {DesktopFeeds} from './Feeds' import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {s} from 'lib/styles' import {useStores} from 'state/index' @@ -90,41 +89,32 @@ const InviteCodes = observer(function InviteCodesImpl() { const onPress = React.useCallback(() => { store.shell.openModal({name: 'invite-codes'}) }, [store]) - return ( - <View style={[styles.separator, pal.border]}> - {store.me.invitesAvailable === null ? ( - <View style={[s.p10]}> - <LoadingPlaceholder width={186} height={32} style={[styles.br40]} /> - </View> - ) : ( - <TouchableOpacity - style={[styles.inviteCodes]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <FontAwesomeIcon - icon="ticket" - style={[ - styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, - ]} - size={16} - /> - <Text - type="md-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> - )} - </View> + <TouchableOpacity + style={[styles.inviteCodes, pal.border]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <FontAwesomeIcon + icon="ticket" + style={[ + styles.inviteCodesIcon, + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + ]} + size={16} + /> + <Text + type="md-medium" + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} available + </Text> + </TouchableOpacity> ) }) @@ -141,20 +131,16 @@ const styles = StyleSheet.create({ message: { paddingVertical: 18, - paddingHorizontal: 12, + paddingHorizontal: 10, }, messageLine: { marginBottom: 10, }, - separator: { - borderTopWidth: 1, - }, - br40: {borderRadius: 40}, - inviteCodes: { - paddingHorizontal: 12, - paddingVertical: 16, + borderTopWidth: 1, + paddingHorizontal: 16, + paddingVertical: 12, flexDirection: 'row', alignItems: 'center', }, diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 53a58c39d..caecea4a8 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() { ) const navigation = useNavigation<NavigationProp>() + // initial setup + React.useEffect(() => { + if (store.me.did) { + autocompleteView.setup() + } + }, [autocompleteView, store.me.did]) + const onChangeQuery = React.useCallback( (text: string) => { setQuery(text) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 3119715e9..b564f99f8 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -21,7 +21,10 @@ import {usePalette} from 'lib/hooks/usePalette' import * as backHandler from 'lib/routes/back-handler' import {RoutesContainer, TabsNavigator} from '../../Navigation' import {isStateAtTabRoot} from 'lib/routes/helpers' -import {SafeAreaProvider} from 'react-native-safe-area-context' +import { + SafeAreaProvider, + initialWindowMetrics, +} from 'react-native-safe-area-context' import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' const ShellInner = observer(function ShellInnerImpl() { @@ -87,7 +90,7 @@ export const Shell: React.FC = observer(function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( - <SafeAreaProvider style={pal.view}> + <SafeAreaProvider initialMetrics={initialWindowMetrics} style={pal.view}> <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} /> <RoutesContainer> diff --git a/yarn.lock b/yarn.lock index fffbff57e..906e84650 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,18 +47,19 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@^0.6.20": - version "0.6.20" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.20.tgz#3a7eda60d73a5d5b6938e2dd016c24a7ba180c83" - integrity sha512-+peoKgkaxbglXQg9qEZcZIvyWm39yj0+syV3TBDrz5cWK4OIsdOyYBg2iISy+jvB5RzEUMe2WvOojP6Nq34mOg== - dependencies: - "@atproto/common-web" "^0.2.1" - "@atproto/lexicon" "^0.2.2" - "@atproto/syntax" "^0.1.2" - "@atproto/xrpc" "^0.3.2" +"@atproto/api@^0.6.21": + version "0.6.21" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.21.tgz#6e5b00facf46f2556d9766290341aae7e6ef75c8" + integrity sha512-ZWVEnLhZ8nonkCVzeFgdUFZhTOUtPxvicZFuttvb2G2Q5u43RmJ5qXXZvox/S9XQEw7TubG6Jza1mesH7CjfVQ== + dependencies: + "@atproto/common-web" "^0.2.2" + "@atproto/lexicon" "^0.2.3" + "@atproto/syntax" "^0.1.3" + "@atproto/xrpc" "^0.3.3" multiformats "^9.9.0" tlds "^1.234.0" typed-emitter "^2.1.0" + zod "^3.21.4" "@atproto/bsky@^0.0.5": version "0.0.5" @@ -105,10 +106,10 @@ uint8arrays "3.0.0" zod "^3.21.4" -"@atproto/common-web@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.1.tgz#97412cb241321fc6c56a2b8c0b2416b3240caf50" - integrity sha512-5AoDKkKz7JhXSiicjhPihA/MJMlSuTQ9Aed9fflPuoTuT6C3aXbxaUZEcqqipSwlCfGpOzPmJmWJjMWWsYr2ew== +"@atproto/common-web@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.2.tgz#decc12584c84f3c34d077d1afe7442bfc21bcf6c" + integrity sha512-XWZHj82kWGdhm0y6e/DxLA5qK0LPHTozfPCH2ws1B/Qh9Hh5DD/gakvlIRT1FouwPM+hWcs8YHVJ8bjnehrhHA== dependencies: graphemer "^1.4.0" multiformats "^9.9.0" @@ -219,13 +220,13 @@ multiformats "^9.9.0" zod "^3.21.4" -"@atproto/lexicon@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.2.tgz#938a39482ff41c6a908f4ad43274adba595f3643" - integrity sha512-CvmjaSDavHMOJTuNYE8VjYhL7TVxBYV8QSWh2jHCpzfmj02DvVD9UBIfnoVv67POJkEtWXddjoV9beaIbaq/Xg== +"@atproto/lexicon@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.3.tgz#3f8ba24187d5628ec06b1bdbec90747f7cdc0948" + integrity sha512-1xUs0KNw4CopWI5HSlLYZ8UHW5nb6V7sldO5OPONiEVKjETrqqjfopezloYAIBNrekUNXwd1pbp05afkAxW5og== dependencies: - "@atproto/common-web" "^0.2.1" - "@atproto/syntax" "^0.1.2" + "@atproto/common-web" "^0.2.2" + "@atproto/syntax" "^0.1.3" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" zod "^3.21.4" @@ -297,12 +298,12 @@ dependencies: "@atproto/common-web" "^0.2.0" -"@atproto/syntax@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.2.tgz#417366d36b53ecf29d9d1f6e35179b1f3feef95b" - integrity sha512-n6VSuccMGouwftCvZBq9WNwI0qYCMOH/lTHSV+/dT232lX7pIrqisOlErUSBoOJ49B1Wxy1DjeeBS26ap9SsGQ== +"@atproto/syntax@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.3.tgz#5cafd5d82eee939fde06a2eacd11b264fb2f3b13" + integrity sha512-Xbw+Rx15puW8wZ/ro40nAQVc7ymPqcGOinVt8Jxi+lcY/1iKpID9a86E6ZOzvw0ncFKONwILYk1+xGeUT6OUNA== dependencies: - "@atproto/common-web" "^0.2.1" + "@atproto/common-web" "^0.2.2" "@atproto/xrpc-server@^0.3.1": version "0.3.1" @@ -329,12 +330,12 @@ "@atproto/lexicon" "^0.2.1" zod "^3.21.4" -"@atproto/xrpc@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.2.tgz#432a364be4b3bf8660a088a07dadecac10209763" - integrity sha512-D9jGjcFnEMHuGQ56v6+78uX3RiytKLrA5ITLq6shy0Qj6Zvt5MqV+/cTFuNPKrNCrnWOtHFeRQwMqyGhNS9qZQ== +"@atproto/xrpc@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.3.tgz#05f1c431ccd366e950637b93acca85faa249f52b" + integrity sha512-o0VUrUGu5Y/1F+ujZKIJYpuHdfXaIDacxuiq2IjwR2rbHXlefh+9FJy5XNkq4do+jMj7U+gSiPrgqaqLYbc9ng== dependencies: - "@atproto/lexicon" "^0.2.2" + "@atproto/lexicon" "^0.2.3" zod "^3.21.4" "@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4": @@ -3744,6 +3745,16 @@ "@sentry/utils" "7.52.1" tslib "^1.9.3" +"@sentry-internal/tracing@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.69.0.tgz#8d8eb740b72967b6ba3fdc0a5173aa55331b7d35" + integrity sha512-4BgeWZUj9MO6IgfO93C9ocP3+AdngqujF/+zB2rFdUe+y9S6koDyUC7jr9Knds/0Ta72N/0D6PwhgSCpHK8s0Q== + dependencies: + "@sentry/core" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + tslib "^2.4.1 || ^1.9.3" + "@sentry/browser@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.52.0.tgz#55d266c89ed668389ff687e5cc885c27016ea85c" @@ -3768,6 +3779,18 @@ "@sentry/utils" "7.52.1" tslib "^1.9.3" +"@sentry/browser@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.69.0.tgz#65427c90fb71c1775e2c1e38431efb7f4aec1e34" + integrity sha512-5ls+zu2PrMhHCIIhclKQsWX5u6WH0Ez5/GgrCMZTtZ1d70ukGSRUvpZG9qGf5Cw1ezS1LY+1HCc3whf8x8lyPw== + dependencies: + "@sentry-internal/tracing" "7.69.0" + "@sentry/core" "7.69.0" + "@sentry/replay" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + tslib "^2.4.1 || ^1.9.3" + "@sentry/cli@2.17.5": version "2.17.5" resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.17.5.tgz#d41e24893a843bcd41e14274044a7ddea9332824" @@ -3779,6 +3802,17 @@ proxy-from-env "^1.1.0" which "^2.0.2" +"@sentry/cli@2.20.7": + version "2.20.7" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.20.7.tgz#8f7f3f632c330cac6bd2278d820948163f3128a6" + integrity sha512-YaHKEUdsFt59nD8yLvuEGCOZ3/ArirL8GZ/66RkZ8wcD2wbpzOFbzo08Kz4te/Eo3OD5/RdW+1dPaOBgGbrXlA== + dependencies: + https-proxy-agent "^5.0.0" + node-fetch "^2.6.7" + progress "^2.0.3" + proxy-from-env "^1.1.0" + which "^2.0.2" + "@sentry/core@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.52.0.tgz#6c820ca48fe2f06bfd6b290044c96de2375f2ad4" @@ -3797,6 +3831,15 @@ "@sentry/utils" "7.52.1" tslib "^1.9.3" +"@sentry/core@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.69.0.tgz#ebbe01df573f438f8613107020a4e18eb9adca4d" + integrity sha512-V6jvK2lS8bhqZDMFUtvwe2XvNstFQf5A+2LMKCNBOV/NN6eSAAd6THwEpginabjet9dHsNRmMk7WNKvrUfQhZw== + dependencies: + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + tslib "^2.4.1 || ^1.9.3" + "@sentry/hub@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.52.0.tgz#ffc087d58c745d57108862faa0f701b15503dcc2" @@ -3807,6 +3850,16 @@ "@sentry/utils" "7.52.0" tslib "^1.9.3" +"@sentry/hub@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.69.0.tgz#3ef3b98e1810b05cb4fb37a861bd700ef592a2a9" + integrity sha512-71TQ7P5de9+cdW1ETGI9wgi2VNqfyWaM3cnUvheXaSjPRBrr6mhwoaSjo+GGsiwx97Ob9DESZEIhdzcLupzkFA== + dependencies: + "@sentry/core" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + tslib "^2.4.1 || ^1.9.3" + "@sentry/integrations@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.52.0.tgz#632aa5e54bdfdab910a24057c2072634a2670409" @@ -3827,6 +3880,30 @@ localforage "^1.8.1" tslib "^1.9.3" +"@sentry/integrations@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.69.0.tgz#04c0206d9436ec7b79971e3bde5d6e1e9194595f" + integrity sha512-FEFtFqXuCo9+L7bENZxFpEAlIODwHl6FyW/DwLfniy9jOXHU7BhP/oICLrFE5J7rh1gNY7N/8VlaiQr3hCnS/g== + dependencies: + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + localforage "^1.8.1" + tslib "^2.4.1 || ^1.9.3" + +"@sentry/react-native@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.10.0.tgz#b61861276fcb35e69dbe9c4e098ed7c88598f5d9" + integrity sha512-YuEZJ3tW5qZlFGFm2FoAZ9vw1fWnjrhMh1IHxo+nUHP3FvVgGkAd/PmSSbgPr2T3YLOIJNiyDdG031Qi7YvtGA== + dependencies: + "@sentry/browser" "7.69.0" + "@sentry/cli" "2.20.7" + "@sentry/core" "7.69.0" + "@sentry/hub" "7.69.0" + "@sentry/integrations" "7.69.0" + "@sentry/react" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + "@sentry/react-native@5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.5.0.tgz#b1283f68465b1772ad6059ebba149673cef33f2d" @@ -3863,6 +3940,17 @@ hoist-non-react-statics "^3.3.2" tslib "^1.9.3" +"@sentry/react@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.69.0.tgz#b9931ac590d8dad3390a9a03a516f1b1bd75615e" + integrity sha512-J+DciRRVuruf1nMmBOi2VeJkOLGeCb4vTOFmHzWTvRJNByZ0flyo8E/fyROL7+23kBq1YbcVY6IloUlH73hneQ== + dependencies: + "@sentry/browser" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + hoist-non-react-statics "^3.3.2" + tslib "^2.4.1 || ^1.9.3" + "@sentry/replay@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.52.0.tgz#4d78e88282d2c1044ea4b648a68d1b22173e810d" @@ -3881,6 +3969,15 @@ "@sentry/types" "7.52.1" "@sentry/utils" "7.52.1" +"@sentry/replay@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.69.0.tgz#d727f96292d2b7c25df022fa53764fd39910fcda" + integrity sha512-oUqWyBPFUgShdVvgJtV65EQH9pVDmoYVQMOu59JI6FHVeL3ald7R5Mvz6GaNLXsirvvhp0yAkcAd2hc5Xi6hDw== + dependencies: + "@sentry/core" "7.69.0" + "@sentry/types" "7.69.0" + "@sentry/utils" "7.69.0" + "@sentry/types@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.0.tgz#b7d5372f17355e3991cbe818ad567f3fe277cc6b" @@ -3891,6 +3988,11 @@ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.1.tgz#bcff6d0462d9b9b7b9ec31c0068fe02d44f25da2" integrity sha512-OMbGBPrJsw0iEXwZ2bJUYxewI1IEAU2e1aQGc0O6QW5+6hhCh+8HO8Xl4EymqwejjztuwStkl6G1qhK+Q0/Row== +"@sentry/types@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.69.0.tgz#012b8d90d270a473cc2a5cf58a56870542739292" + integrity sha512-zPyCox0mzitzU6SIa1KIbNoJAInYDdUpdiA+PoUmMn2hFMH1llGU/cS7f4w/mAsssTlbtlBi72RMnWUCy578bw== + "@sentry/utils@7.52.0": version "7.52.0" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.52.0.tgz#cacc36d905036ba7084c14965e964fc44239d7f0" @@ -3907,6 +4009,14 @@ "@sentry/types" "7.52.1" tslib "^1.9.3" +"@sentry/utils@7.69.0": + version "7.69.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.69.0.tgz#b7594e4eb2a88b9b25298770b841dd3f81bd2aa4" + integrity sha512-4eBixe5Y+0EGVU95R4NxH3jkkjtkE4/CmSZD4In8SCkWGSauogePtq6hyiLsZuP1QHdpPb9Kt0+zYiBb2LouBA== + dependencies: + "@sentry/types" "7.69.0" + tslib "^2.4.1 || ^1.9.3" + "@sideway/address@^4.1.3": version "4.1.4" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0" @@ -6532,6 +6642,11 @@ babel-plugin-transform-react-remove-prop-types@^0.4.24: resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== +babel-plugin-transform-remove-console@^6.9.4: + version "6.9.4" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780" + integrity sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg== + babel-preset-current-node-syntax@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" @@ -8135,10 +8250,10 @@ detect-port-alt@^1.1.6: address "^1.0.1" debug "^2.6.0" -detox@^20.11.3: - version "20.11.3" - resolved "https://registry.yarnpkg.com/detox/-/detox-20.11.3.tgz#56d5ea869977f5a747e1be0901b279ab953f8b7b" - integrity sha512-kdoRAtDLFxXpjt1QlniI+WryMtf7Y8mrZ33Ql8cTR9qoCS/CThi4pweYAQm8yUPqAv1ZtT3eIm3EzRwjEosgLA== +detox@^20.13.0: + version "20.13.0" + resolved "https://registry.yarnpkg.com/detox/-/detox-20.13.0.tgz#923111638dfdb16089eea4f07bf4f0b56468d097" + integrity sha512-p9MUcoHWFTqSDaoaN+/hnJYdzNYqdelUr/sxzy3zLoS/qehnVJv2yG9pYqz/+gKpJaMIpw2+TVw9imdAx5JpaA== dependencies: ajv "^8.6.3" bunyan "^1.8.12" @@ -16704,7 +16819,7 @@ send@0.18.0, send@^0.18.0: range-parser "~1.2.1" statuses "2.0.1" -sentry-expo@~7.0.0: +sentry-expo@~7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/sentry-expo/-/sentry-expo-7.0.1.tgz#025f0e90ab7f7cba1e00c892fabc027de21bc5bc" integrity sha512-8vmOy4R+qM1peQA9EP8rDGUMBhgMU1D5FyuWY9kfNGatmWuvEmlZpVgaXoXaNPIhPgf2TMrvQIlbqLHtTkoeSA== @@ -17890,7 +18005,7 @@ tslib@^1.8.1, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.4.1: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.4.1, "tslib@^2.4.1 || ^1.9.3": version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== @@ -19191,10 +19306,10 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zeed-dom@^0.9.19: - version "0.9.26" - resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.9.26.tgz#f0127d1024b34a1233a321bd6d0275b3ba998b30" - integrity sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ== +zeed-dom@0.10.9, zeed-dom@^0.9.19: + version "0.10.9" + resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.10.9.tgz#b3eb5d9b7cf1be17e1fb3a708379df5edce195be" + integrity sha512-qQQ7Wu7IJ3Vo/LjeKWj97A2Hi17di4ZdmgNZj6AWbDbpt3hvO4EMfjYVA2/2unLYT+XpmMq5fqaLqCeU7Im83A== dependencies: css-what "^6.1.0" |