diff options
115 files changed, 3151 insertions, 2873 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 c32d726e4..4f7d00ebb 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,33 @@ # Bluesky Social App -Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com). +Welcome friends! This is the codebase for the Bluesky Social app. + +Get the app itself: - **Web: [bsky.app](https://bsky.app)** - **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)** - **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)** -Links: +## Development Resources + +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 amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. + +The [Build Instructions](./docs/build.md) are a good place to get started with the app itself. -- [Build instructions](./docs/build.md) -- [ATProto repo](https://github.com/bluesky-social/atproto) -- [ATProto docs](https://atproto.com) +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: -## Rules & guidelines +- [Overview and Guides](https://atproto.com/guides/overview) +- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) đ Great place to ask questions +- [Protocol Specifications](https://atproto.com/specs/atp) +- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol) ---- +The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`. -âšī¸ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. +## Contributions ---- +> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. **Rules:** 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 51e95b1a1..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.51.0', + version: '1.55.0', runtimeVersion: { policy: 'appVersion', }, @@ -19,7 +19,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, ios: { - buildNumber: '5', + buildNumber: '1', supportsTablet: false, bundleIdentifier: 'xyz.blueskyweb.app', config: { @@ -43,7 +43,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, android: { - versionCode: 39, + 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 3f99aea60..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", @@ -35,7 +41,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.4.7", + "@gorhom/bottom-sheet": "^4.5.1", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.3.1", "@react-native-async-storage/async-storage": "1.18.2", @@ -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", @@ -120,7 +127,7 @@ "react-avatar-editor": "^13.0.0", "react-circular-progressbar": "^2.1.0", "react-dom": "^18.2.0", - "react-native": "0.72.4", + "react-native": "0.72.5", "react-native-appstate-hook": "^1.0.6", "react-native-draggable-flatlist": "^4.0.1", "react-native-drawer-layout": "^3.2.0", @@ -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+0.72.4.patch b/patches/react-native+0.72.5.patch index d640f6c9e..d640f6c9e 100644 --- a/patches/react-native+0.72.4.patch +++ b/patches/react-native+0.72.5.patch 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/api/index.ts b/src/lib/api/index.ts index 8a9389a18..f930bd7b1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -94,7 +94,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { | undefined let reply let rt = new RichText( - {text: opts.rawText.trim()}, + {text: opts.rawText.trimEnd()}, { cleanNewlines: true, }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a7949e6a..81a6d4e77 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS( serviceUrl: string, resolveHandle: (name: string) => Promise<string>, ) { + // TODO: remove this when the test suite no longer relies on it if (IS_LOCAL_DEV(serviceUrl)) { // local dev const aliceDid = await resolveHandle('alice.test') @@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [ - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('with-friends'), - ], - saved: [ - PROD_DEFAULT_FEED('bsky-team'), - PROD_DEFAULT_FEED('with-friends'), - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('hot-classic'), - ], + pinned: [PROD_DEFAULT_FEED('whats-hot')], + saved: [PROD_DEFAULT_FEED('whats-hot')], } } } diff --git a/src/lib/hooks/useFollowDid.ts b/src/lib/hooks/useFollowProfile.ts index 223adb047..6220daba8 100644 --- a/src/lib/hooks/useFollowDid.ts +++ b/src/lib/hooks/useFollowProfile.ts @@ -1,11 +1,11 @@ import React from 'react' - +import {AppBskyActorDefs} from '@atproto/api' import {useStores} from 'state/index' import {FollowState} from 'state/models/cache/my-follows' -export function useFollowDid({did}: {did: string}) { +export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) { const store = useStores() - const state = store.me.follows.getFollowState(did) + const state = store.me.follows.getFollowState(profile.did) return { state, @@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) { toggle: React.useCallback(async () => { if (state === FollowState.Following) { try { - await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) - store.me.follows.removeFollow(did) + await store.agent.deleteFollow( + store.me.follows.getFollowUri(profile.did), + ) + store.me.follows.removeFollow(profile.did) return { state: FollowState.NotFollowing, following: false, @@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) { } } else if (state === FollowState.NotFollowing) { try { - const res = await store.agent.follow(did) - store.me.follows.addFollow(did, res.uri) + const res = await store.agent.follow(profile.did) + store.me.follows.addFollow(profile.did, { + followRecordUri: res.uri, + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + avatar: profile.avatar, + }) return { state: FollowState.Following, following: true, @@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) { state: FollowState.Unknown, following: false, } - }, [store, did, state]), + }, [store, profile, state]), } } 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/lib/themes.ts b/src/lib/themes.ts index 95aee0842..b778d5b30 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -264,8 +264,8 @@ export const defaultTheme: Theme = { fontWeight: '400', }, 'post-text-lg': { - fontSize: 22, - letterSpacing: 0.4, + fontSize: 20, + letterSpacing: 0.2, fontWeight: '400', }, 'button-lg': { diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 10f88c4a9..e1e8af509 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,6 +1,14 @@ import {makeAutoObservable} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyGraphGetFollows as GetFollows, + 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 type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView @@ -10,6 +18,14 @@ export enum FollowState { Unknown, } +export interface FollowInfo { + did: string + followRecordUri: string | undefined + handle: string + displayName: string | undefined + avatar: string | undefined +} + /** * This model is used to maintain a synced local cache of the user's * follows. It should be periodically refreshed and updated any time @@ -17,9 +33,8 @@ export enum FollowState { */ export class MyFollowsCache { // data - followDidToRecordMap: Record<string, string | boolean> = {} + byDid: Record<string, FollowInfo> = {} lastSync = 0 - myDid?: string constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -35,16 +50,45 @@ export class MyFollowsCache { // = clear() { - this.followDidToRecordMap = {} - this.lastSync = 0 - this.myDid = undefined + this.byDid = {} } + /** + * Syncs a subset of the user's follows + * for performance reasons, caps out at 1000 follows + */ + syncIfNeeded = bundleAsync(async () => { + if (this.lastSync > Date.now() - SYNC_TTL) { + return + } + + let cursor + for (let i = 0; i < MAX_SYNC_PAGES; i++) { + const res: GetFollows.Response = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did, + cursor, + limit: 100, + }) + res.data.follows = res.data.follows.filter( + profile => + !moderateProfile(profile, this.rootStore.preferences.moderationOpts) + .account.filter, + ) + this.hydrateMany(res.data.follows) + if (!res.data.cursor) { + break + } + cursor = res.data.cursor + } + + this.lastSync = Date.now() + }) + getFollowState(did: string): FollowState { - if (typeof this.followDidToRecordMap[did] === 'undefined') { + if (typeof this.byDid[did] === 'undefined') { return FollowState.Unknown } - if (typeof this.followDidToRecordMap[did] === 'string') { + if (typeof this.byDid[did].followRecordUri === 'string') { return FollowState.Following } return FollowState.NotFollowing @@ -53,49 +97,41 @@ export class MyFollowsCache { async fetchFollowState(did: string): Promise<FollowState> { // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf const res = await this.rootStore.agent.getProfile({actor: did}) - if (res.data.viewer?.following) { - this.addFollow(did, res.data.viewer.following) - } else { - this.removeFollow(did) - } + this.hydrate(did, res.data) return this.getFollowState(did) } getFollowUri(did: string): string { - const v = this.followDidToRecordMap[did] - if (typeof v === 'string') { - return v + const v = this.byDid[did] + if (v && typeof v.followRecordUri === 'string') { + return v.followRecordUri } throw new Error('Not a followed user') } - addFollow(did: string, recordUri: string) { - this.followDidToRecordMap[did] = recordUri + addFollow(did: string, info: FollowInfo) { + this.byDid[did] = info } removeFollow(did: string) { - this.followDidToRecordMap[did] = false + if (this.byDid[did]) { + this.byDid[did].followRecordUri = undefined + } } - /** - * Use this to incrementally update the cache as views provide information - */ - hydrate(did: string, recordUri: string | undefined) { - if (recordUri) { - this.followDidToRecordMap[did] = recordUri - } else { - this.followDidToRecordMap[did] = false + hydrate(did: string, profile: Profile) { + this.byDid[did] = { + did, + followRecordUri: profile.viewer?.following, + handle: profile.handle, + displayName: profile.displayName, + avatar: profile.avatar, } } - /** - * Use this to incrementally update the cache as views provide information - */ - hydrateProfiles(profiles: Profile[]) { + hydrateMany(profiles: Profile[]) { for (const profile of profiles) { - if (profile.viewer) { - this.hydrate(profile.did, profile.viewer.following) - } + this.hydrate(profile.did, profile) } } } diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 26fa6008c..906f84c28 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -137,7 +137,7 @@ export class ProfileModel { runInAction(() => { this.followersCount++ this.viewer.following = res.uri - this.rootStore.me.follows.addFollow(this.did, res.uri) + this.rootStore.me.follows.hydrate(this.did, this) }) track('Profile:Follow', { username: this.handle, @@ -290,8 +290,8 @@ export class ProfileModel { this.labels = res.data.labels if (res.data.viewer) { Object.assign(this.viewer, res.data.viewer) - this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } + this.rootStore.me.follows.hydrate(this.did, res.data) } async _createRichText() { diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 580145f65..4a647dcfe 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,8 +1,4 @@ -import { - AppBskyActorDefs, - AppBskyGraphGetFollows as GetFollows, - moderateProfile, -} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' import {bundleAsync} from 'lib/async/bundle' @@ -43,35 +39,13 @@ export class FoafsModel { try { this.isLoading = true - // fetch & hydrate up to 1000 follows - { - let cursor - for (let i = 0; i < 10; i++) { - const res: GetFollows.Response = - await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did, - cursor, - limit: 100, - }) - res.data.follows = res.data.follows.filter( - profile => - !moderateProfile( - profile, - this.rootStore.preferences.moderationOpts, - ).account.filter, - ) - this.rootStore.me.follows.hydrateProfiles(res.data.follows) - if (!res.data.cursor) { - break - } - cursor = res.data.cursor - } - } + // fetch some of the user's follows + await this.rootStore.me.follows.syncIfNeeded() // grab 10 of the users followed by the user runInAction(() => { this.sources = sampleSize( - Object.keys(this.rootStore.me.follows.followDidToRecordMap), + Object.keys(this.rootStore.me.follows.byDid), 10, ) }) @@ -100,7 +74,7 @@ export class FoafsModel { for (let i = 0; i < results.length; i++) { const res = results[i] if (res.status === 'fulfilled') { - this.rootStore.me.follows.hydrateProfiles(res.value.data.follows) + this.rootStore.me.follows.hydrateMany(res.value.data.follows) } const profile = profiles.data.profiles[i] const source = this.sources[i] diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 8ad321ed9..3638e7f0d 100644 --- a/src/state/models/discovery/onboarding.ts +++ b/src/state/models/discovery/onboarding.ts @@ -81,6 +81,7 @@ export class OnboardingModel { } finish() { + this.rootStore.me.mainFeed.refresh() // load the selected content this.step = 'Home' track('Onboarding:Complete') } diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index afa5e74e3..d270267ee 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -76,7 +76,7 @@ export class SuggestedActorsModel { !moderateProfile(actor, this.rootStore.preferences.moderationOpts) .account.filter, ) - this.rootStore.me.follows.hydrateProfiles(actors) + this.rootStore.me.follows.hydrateMany(actors) runInAction(() => { if (replace) { @@ -118,7 +118,7 @@ export class SuggestedActorsModel { actor: actor, }) const {suggestions: moreSuggestions} = res.data - this.rootStore.me.follows.hydrateProfiles(moreSuggestions) + this.rootStore.me.follows.hydrateMany(moreSuggestions) // dedupe const toInsert = moreSuggestions.filter( s => !this.suggestions.find(s2 => s2.did === s.did), diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts index 461073e45..25ce859d2 100644 --- a/src/state/models/discovery/user-autocomplete.ts +++ b/src/state/models/discovery/user-autocomplete.ts @@ -4,6 +4,8 @@ import AwaitLock from 'await-lock' import {RootStoreModel} from '../root-store' import {isInvalidHandle} from 'lib/strings/handles' +type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic + export class UserAutocompleteModel { // state isLoading = false @@ -12,9 +14,8 @@ export class UserAutocompleteModel { lock = new AwaitLock() // data - follows: AppBskyActorDefs.ProfileViewBasic[] = [] - searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] knownHandles: Set<string> = new Set() + _suggestions: ProfileViewBasic[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -27,29 +28,35 @@ export class UserAutocompleteModel { ) } - get suggestions() { + get follows(): ProfileViewBasic[] { + return Object.values(this.rootStore.me.follows.byDid).map(item => ({ + did: item.did, + handle: item.handle, + displayName: item.displayName, + avatar: item.avatar, + })) + } + + get suggestions(): ProfileViewBasic[] { if (!this.isActive) { return [] } - if (this.prefix) { - return this.searchRes.map(user => ({ - handle: user.handle, - displayName: user.displayName, - avatar: user.avatar, - })) - } - return this.follows.map(follow => ({ - handle: follow.handle, - displayName: follow.displayName, - avatar: follow.avatar, - })) + return this._suggestions } // public api // = async setup() { - await this._getFollows() + await this.rootStore.me.follows.syncIfNeeded() + runInAction(() => { + for (const did in this.rootStore.me.follows.byDid) { + const info = this.rootStore.me.follows.byDid[did] + if (!isInvalidHandle(info.handle)) { + this.knownHandles.add(info.handle) + } + } + }) } setActive(v: boolean) { @@ -57,7 +64,7 @@ export class UserAutocompleteModel { } async setPrefix(prefix: string) { - const origPrefix = prefix.trim() + const origPrefix = prefix.trim().toLocaleLowerCase() this.prefix = origPrefix await this.lock.acquireAsync() try { @@ -65,9 +72,27 @@ export class UserAutocompleteModel { if (this.prefix !== origPrefix) { return // another prefix was set before we got our chance } - await this._search() + + // reset to follow results + this._computeSuggestions([]) + + // ask backend + const res = await this.rootStore.agent.searchActorsTypeahead({ + term: this.prefix, + limit: 8, + }) + this._computeSuggestions(res.data.actors) + + // update known handles + runInAction(() => { + for (const u of res.data.actors) { + this.knownHandles.add(u.handle) + } + }) } else { - this.searchRes = [] + runInAction(() => { + this._computeSuggestions([]) + }) } } finally { this.lock.release() @@ -77,28 +102,40 @@ export class UserAutocompleteModel { // internal // = - async _getFollows() { - const res = await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did || '', - }) - runInAction(() => { - this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle)) - for (const f of this.follows) { - this.knownHandles.add(f.handle) + _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) { + if (this.prefix) { + const items: ProfileViewBasic[] = [] + for (const item of this.follows) { + if (prefixMatch(this.prefix, item)) { + items.push(item) + } + if (items.length >= 8) { + break + } } - }) + for (const item of searchRes) { + if (!items.find(item2 => item2.handle === item.handle)) { + items.push({ + did: item.did, + handle: item.handle, + displayName: item.displayName, + avatar: item.avatar, + }) + } + } + this._suggestions = items + } else { + this._suggestions = this.follows + } } +} - async _search() { - const res = await this.rootStore.agent.searchActorsTypeahead({ - term: this.prefix, - limit: 8, - }) - runInAction(() => { - this.searchRes = res.data.actors - for (const u of this.searchRes) { - this.knownHandles.add(u.handle) - } - }) +function prefixMatch(prefix: string, info: ProfileViewBasic): boolean { + if (info.handle.includes(prefix)) { + return true + } + if (info.displayName?.toLocaleLowerCase().includes(prefix)) { + return true } + return false } 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/feeds/posts.ts b/src/state/models/feeds/posts.ts index bb619147f..2462689b1 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -116,6 +116,10 @@ export class PostsFeedModel { return this.hasLoaded && !this.hasContent } + get isLoadingMore() { + return this.isLoading && !this.isRefreshing + } + setHasNewLatest(v: boolean) { this.hasNewLatest = v } @@ -307,12 +311,12 @@ export class PostsFeedModel { } async _appendAll(res: FeedAPIResponse, replace = false) { - this.hasMore = !!res.cursor + this.hasMore = !!res.cursor && res.feed.length > 0 if (replace) { this.emptyFetches = 0 } - this.rootStore.me.follows.hydrateProfiles( + this.rootStore.me.follows.hydrateMany( res.feed.map(item => item.post.author), ) for (const item of res.feed) { diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts index a28e0309a..cd3667062 100644 --- a/src/state/models/invited-users.ts +++ b/src/state/models/invited-users.ts @@ -61,7 +61,7 @@ export class InvitedUsers { profile => !profile.viewer?.following, ) }) - this.rootStore.me.follows.hydrateProfiles(this.profiles) + this.rootStore.me.follows.hydrateMany(this.profiles) } catch (e) { this.rootStore.log.error( 'Failed to fetch profiles for invited users', diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts index 39882d73a..dd3cf18a3 100644 --- a/src/state/models/lists/likes.ts +++ b/src/state/models/lists/likes.ts @@ -126,7 +126,7 @@ export class LikesModel { _appendAll(res: GetLikes.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.rootStore.me.follows.hydrateProfiles( + this.rootStore.me.follows.hydrateMany( res.data.likes.map(like => like.actor), ) this.likes = this.likes.concat(res.data.likes) diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts index a70375bdc..5d4fc107d 100644 --- a/src/state/models/lists/reposted-by.ts +++ b/src/state/models/lists/reposted-by.ts @@ -130,6 +130,6 @@ export class RepostedByModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) - this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) + this.rootStore.me.follows.hydrateMany(res.data.repostedBy) } } diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts index 2962d6242..1f817c33c 100644 --- a/src/state/models/lists/user-followers.ts +++ b/src/state/models/lists/user-followers.ts @@ -115,6 +115,6 @@ export class UserFollowersModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) - this.rootStore.me.follows.hydrateProfiles(res.data.followers) + this.rootStore.me.follows.hydrateMany(res.data.followers) } } diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts index 56432a796..c9630eba8 100644 --- a/src/state/models/lists/user-follows.ts +++ b/src/state/models/lists/user-follows.ts @@ -115,6 +115,6 @@ export class UserFollowsModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) - this.rootStore.me.follows.hydrateProfiles(res.data.follows) + this.rootStore.me.follows.hydrateMany(res.data.follows) } } 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/preferences.ts b/src/state/models/ui/preferences.ts index b3365bd7c..6ca19b4b7 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -418,6 +418,7 @@ export class PreferencesModel { const oldPinned = this.pinnedFeeds this.savedFeeds = saved this.pinnedFeeds = pinned + await this.lock.acquireAsync() try { const res = await cb() runInAction(() => { @@ -430,6 +431,8 @@ export class PreferencesModel { this.pinnedFeeds = oldPinned }) throw e + } finally { + this.lock.release() } } @@ -441,7 +444,7 @@ export class PreferencesModel { async addSavedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], this.pinnedFeeds, () => this.rootStore.agent.addSavedFeed(v), ) @@ -457,8 +460,8 @@ export class PreferencesModel { async addPinnedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - [...this.pinnedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], + [...this.pinnedFeeds.filter(uri => uri !== v), v], () => this.rootStore.agent.addPinnedFeed(v), ) } @@ -473,71 +476,121 @@ export class PreferencesModel { async setBirthDate(birthDate: Date) { this.birthDate = birthDate - await this.rootStore.agent.setPersonalDetails({birthDate}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setPersonalDetails({birthDate}) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReplies() { this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReplies: this.homeFeed.hideReplies, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideRepliesByUnfollowed() { this.homeFeed.hideRepliesByUnfollowed = !this.homeFeed.hideRepliesByUnfollowed - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, + }) + } finally { + this.lock.release() + } } async setHomeFeedHideRepliesByLikeCount(threshold: number) { this.homeFeed.hideRepliesByLikeCount = threshold - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReposts() { this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReposts: this.homeFeed.hideReposts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideQuotePosts() { this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideQuotePosts: this.homeFeed.hideQuotePosts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedMergeFeedEnabled() { this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, + }) + } finally { + this.lock.release() + } } async setThreadSort(v: string) { if (THREAD_SORT_VALUES.includes(v)) { this.thread.sort = v - await this.rootStore.agent.setThreadViewPrefs({sort: v}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({sort: v}) + } finally { + this.lock.release() + } } } async togglePrioritizedFollowedUsers() { this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, + }) + } finally { + this.lock.release() + } } async toggleThreadTreeViewEnabled() { this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + lab_treeViewEnabled: this.thread.lab_treeViewEnabled, + }) + } finally { + this.lock.release() + } } toggleRequireAltTextEnabled() { 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 f8becdec3..c650de004 100644 --- a/src/state/models/ui/reminders.ts +++ b/src/state/models/ui/reminders.ts @@ -3,14 +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 { - // NOTE - // by defaulting to the current date, we ensure that the user won't be nagged - // on first run (aka right after creating an account) - // -prf - lastEmailConfirm: Date = new Date() + lastEmailConfirm: Date | null = null constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -46,6 +40,13 @@ export class Reminders { if (sess.emailConfirmed) { return false } + 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 @@ -54,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/state/models/ui/search.ts b/src/state/models/ui/search.ts index 4ab9db513..2b2036751 100644 --- a/src/state/models/ui/search.ts +++ b/src/state/models/ui/search.ts @@ -59,7 +59,7 @@ export class SearchUIModel { } while (profilesSearch.length) } - this.rootStore.me.follows.hydrateProfiles(profiles) + this.rootStore.me.follows.hydrateMany(profiles) runInAction(() => { this.profiles = profiles diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 24fc9eef1..aaba19c80 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ tdStyles.title2, isTabletOrMobile && tdStyles.title2Small, ]}> - Recomended + Recommended </Text> <Text style={[ diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index d130dc138..6796c64db 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ } } else { try { - await item.save() await item.pin() } catch (e) { Toast.show('There was an issue contacting your server') diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 51e3bc382..2b26918d0 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ </View> <FollowButton - did={profile.did} + profile={profile} labelStyle={styles.followButton} onToggleFollow={async isFollow => { if (isFollow) { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f7b657272..e44a0ce01 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -131,6 +131,9 @@ export const ComposePost = observer(function ComposePost({ }, [store, onClose, graphemeLength, gallery]) // android back button useEffect(() => { + if (!isAndroid) { + return + } const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { 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/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 31e372567..35482bc70 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -119,7 +119,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( onUpdate({editor: editorProp}) { const json = editorProp.getJSON() - const newRt = new RichText({text: editorJsonToText(json).trim()}) + const newRt = new RichText({text: editorJsonToText(json).trimEnd()}) newRt.detectFacetsWithoutResolution() setRichText(newRt) 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 f5e858209..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,157 +1,386 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ +import React, {useState} from 'react' -import React, {useCallback, useRef, useState} from 'react' - -import { - Animated, - ScrollView, - Dimensions, - StyleSheet, - NativeScrollEvent, - NativeSyntheticEvent, - NativeMethodsMixin, -} from 'react-native' +import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' - +import Animated, { + runOnJS, + useAnimatedRef, + useAnimatedStyle, + useAnimatedReaction, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import usePanResponder from '../../hooks/usePanResponder' - -import {getImageStyles, getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' -import {ImageLoading} from './ImageLoading' +import { + createTransform, + readTransform, + applyRounding, + prependPan, + prependPinch, + prependTransform, + TransformMatrix, +} from '../../transforms' +import type {ImageSource, Dimensions as ImageDimensions} from '../../@types' -const SWIPE_CLOSE_OFFSET = 75 -const SWIPE_CLOSE_VELOCITY = 1.75 const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height +const MIN_DOUBLE_TAP_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 + +const AnimatedImage = Animated.createAnimatedComponent(Image) +const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (isZoomed: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } - -const AnimatedImage = Animated.createAnimatedComponent(Image) - const ImageItem = ({ imageSrc, + onTap, onZoom, onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, + isScrollViewBeingDragged, }: Props) => { - const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) + const [isScaled, setIsScaled] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const scrollValueY = new Animated.Value(0) - const [isLoaded, setLoadEnd] = useState(false) - - const onLoaded = useCallback(() => setLoadEnd(true), []) - const onZoomPerformed = useCallback( - (isZoomed: boolean) => { - onZoom(isZoomed) - if (imageContainer?.current) { - imageContainer.current.setNativeProps({ - scrollEnabled: !isZoomed, - }) + const committedTransform = useSharedValue(initialTransform) + const panTranslation = useSharedValue({x: 0, y: 0}) + const pinchOrigin = useSharedValue({x: 0, y: 0}) + const pinchScale = useSharedValue(1) + const pinchTranslation = useSharedValue({x: 0, y: 0}) + const dismissSwipeTranslateY = useSharedValue(0) + const containerRef = useAnimatedRef() + + // Keep track of when we're entering or leaving scaled rendering. + // Note: DO NOT move any logic reading animated values outside this function. + useAnimatedReaction( + () => { + if (pinchScale.value !== 1) { + // We're currently pinching. + return true + } + const [, , committedScale] = readTransform(committedTransform.value) + if (committedScale !== 1) { + // We started from a pinched in state. + return true + } + // We're at rest. + return false + }, + (nextIsScaled, prevIsScaled) => { + if (nextIsScaled !== prevIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onZoom], ) - const onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) - - const [panHandlers, scaleValue, translateValue] = usePanResponder({ - initialScale: scale || 1, - initialTranslate: translate || {x: 0, y: 0}, - onZoom: onZoomPerformed, - doubleTapToZoomEnabled, - onLongPress: onLongPressHandler, - delayLongPress, - }) + function handleZoom(nextIsScaled: boolean) { + setIsScaled(nextIsScaled) + onZoom(nextIsScaled) + } - const imagesStyles = getImageStyles( - imageDimensions, - translateValue, - scaleValue, - ) - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.7, 1, 0.7], + const animatedStyle = useAnimatedStyle(() => { + // Apply the active adjustments on top of the committed transform before the gestures. + // This is matrix multiplication, so operations are applied in the reverse order. + let t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [translateX, translateY, scale] = readTransform(t) + + const dismissDistance = dismissSwipeTranslateY.value + const dismissProgress = Math.min( + Math.abs(dismissDistance) / (SCREEN.height / 2), + 1, + ) + return { + opacity: 1 - dismissProgress, + transform: [ + {translateX}, + {translateY: translateY + dismissDistance}, + {scale}, + ], + } }) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - - const onScrollEndDrag = ({ - nativeEvent, - }: NativeSyntheticEvent<NativeScrollEvent>) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 - - if ( - (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && - offsetY > SWIPE_CLOSE_OFFSET) || - offsetY > SCREEN_HEIGHT / 2 - ) { - onRequestClose() + + // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. + // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. + function getExtraTranslationToStayInBounds( + candidateTransform: TransformMatrix, + ) { + 'worklet' + if (!imageDimensions) { + return [0, 0] } + const [nextTranslateX, nextTranslateY, nextScale] = + readTransform(candidateTransform) + const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) + const clampedTranslateX = clampTranslation( + nextTranslateX, + scaledDimensions.width, + SCREEN.width, + ) + const clampedTranslateY = clampTranslation( + nextTranslateY, + scaledDimensions.height, + SCREEN.height, + ) + const dx = clampedTranslateX - nextTranslateX + const dy = clampedTranslateY - nextTranslateY + return [dx, dy] } - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + const pinch = Gesture.Pinch() + .onStart(e => { + pinchOrigin.value = { + x: e.focalX - SCREEN.width / 2, + y: e.focalY - SCREEN.height / 2, + } + }) + .onChange(e => { + if (!imageDimensions) { + return + } + // Don't let the picture zoom in so close that it gets blurry. + // Also, like in stock Android apps, don't let the user zoom out further than 1:1. + const [, , committedScale] = readTransform(committedTransform.value) + const maxCommittedScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const minPinchScale = 1 / committedScale + const maxPinchScale = maxCommittedScale / committedScale + const nextPinchScale = Math.min( + Math.max(minPinchScale, e.scale), + maxPinchScale, + ) + pinchScale.value = nextPinchScale - scrollValueY.setValue(offsetY) - } + // Zooming out close to the corner could push us out of bounds, which we don't want on Android. + // Calculate where we'll end up so we know how much to translate back to stay in bounds. + const t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [dx, dy] = getExtraTranslationToStayInBounds(t) + if (dx !== 0 || dy !== 0) { + pinchTranslation.value = { + x: pinchTranslation.value.x + dx, + y: pinchTranslation.value.y + dy, + } + } + }) + .onEnd(() => { + // Commit just the pinch. + let t = createTransform() + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pinch. + pinchScale.value = 1 + pinchOrigin.value = {x: 0, y: 0} + pinchTranslation.value = {x: 0, y: 0} + }) + const pan = Gesture.Pan() + .averageTouches(true) + // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: + .minPointers(isScaled ? 1 : 2) + .onChange(e => { + if (!imageDimensions) { + return + } + const nextPanTranslation = {x: e.translationX, y: e.translationY} + let t = createTransform() + prependPan(t, nextPanTranslation) + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + + // Prevent panning from going out of bounds. + const [dx, dy] = getExtraTranslationToStayInBounds(t) + nextPanTranslation.x += dx + nextPanTranslation.y += dy + panTranslation.value = nextPanTranslation + }) + .onEnd(() => { + // Commit just the pan. + let t = createTransform() + prependPan(t, panTranslation.value) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pan. + panTranslation.value = {x: 0, y: 0} + }) + + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + if (!imageDimensions) { + return + } + const [, , committedScale] = readTransform(committedTransform.value) + if (committedScale !== 1) { + // Go back to 1:1 using the identity vector. + let t = createTransform() + committedTransform.value = withClampedSpring(t) + return + } + + // Try to zoom in so that we get rid of the black bars (whatever the orientation was). + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const candidateScale = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_DOUBLE_TAP_SCALE, + ) + // But don't zoom in so close that the picture gets blurry. + const maxScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const scale = Math.min(candidateScale, maxScale) + + // Calculate where we would be if the user pinched into the double tapped point. + // We won't use this transform directly because it may go out of bounds. + const candidateTransform = createTransform() + const origin = { + x: e.absoluteX - SCREEN.width / 2, + y: e.absoluteY - SCREEN.height / 2, + } + prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) + + // Now we know how much we went out of bounds, so we can shoot correctly. + const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform) + const finalTransform = createTransform() + prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) + committedTransform.value = withClampedSpring(finalTransform) + }) + + const dismissSwipePan = Gesture.Pan() + .enabled(!isScaled) + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onUpdate(e => { + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + if (Math.abs(e.velocityY) > 1000) { + dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) + runOnJS(onRequestClose)() + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 700, + damping: 50, + }) + } + }) + + 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 ( - <ScrollView - ref={imageContainer} - style={styles.listItem} - pagingEnabled - nestedScrollEnabled - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={swipeToCloseEnabled} - {...(swipeToCloseEnabled && { - onScroll, - onScrollEndDrag, - })}> - <AnimatedImage - {...panHandlers} - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={onLoaded} - accessibilityLabel={imageSrc.alt} - accessibilityHint="" - /> - {(!isLoaded || !imageDimensions) && <ImageLoading />} - </ScrollView> + <Animated.View ref={containerRef} style={styles.container}> + {isLoading && ( + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> + )} + <GestureDetector gesture={composedGesture}> + <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="" + onLoad={() => setIsLoaded(true)} + /> + </GestureDetector> + </Animated.View> ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + container: { + width: SCREEN.width, + height: SCREEN.height, + overflow: 'hidden', + }, + image: { + flex: 1, }, - imageScrollContainer: { - height: SCREEN_HEIGHT * 2, + loading: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, }, }) +function getScaledDimensions( + imageDimensions: ImageDimensions, + scale: number, +): ImageDimensions { + 'worklet' + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const isLandscape = imageAspect > screenAspect + if (isLandscape) { + return { + width: scale * SCREEN.width, + height: (scale * SCREEN.width) / imageAspect, + } + } else { + return { + width: scale * SCREEN.height * imageAspect, + height: scale * SCREEN.height, + } + } +} + +function clampTranslation( + value: number, + scaledSize: number, + screenSize: number, +): number { + 'worklet' + // Figure out how much the user should be allowed to pan, and constrain the translation. + const panDistance = Math.max(0, (scaledSize - screenSize) / 2) + const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) + return clampedValue +} + +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true}) +} + export default React.memo(ImageItem) 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 03bf45af1..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,159 +6,251 @@ * */ -import React, {useCallback, useRef, useState} from 'react' - -import { - Animated, - Dimensions, - ScrollView, - StyleSheet, - View, - NativeScrollEvent, - NativeSyntheticEvent, - TouchableWithoutFeedback, -} from 'react-native' +import React, {useState} from 'react' + +import {Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' +import Animated, { + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' -import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' import useImageDimensions from '../../hooks/useImageDimensions' -import {getImageStyles, getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' +import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' 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 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 - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -const ImageItem = ({ - imageSrc, - onZoom, - onRequestClose, - onLongPress, - delayLongPress, - swipeToCloseEnabled = true, - doubleTapToZoomEnabled = true, -}: 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 handleDoubleTap = useDoubleTapToZoom( - scrollViewRef, - scaled, - SCREEN, - imageDimensions, - ) - - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const scrollValueY = new Animated.Value(0) - const scaleValue = new Animated.Value(scale || 1) - const translateValue = new Animated.ValueXY(translate) - 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, - translateValue, - scaleValue, - ) - 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 && - swipeToCloseEnabled && - 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, swipeToCloseEnabled], - ) + 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 onLongPressHandler = useCallback(() => { - onLongPress(imageSrc) - }, [imageSrc, onLongPress]) + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + const {absoluteX, absoluteY} = e + runOnJS(handleDoubleTap)(absoluteX, absoluteY) + }) + + 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={swipeToCloseEnabled} - onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1} - {...(swipeToCloseEnabled && { - onScroll, - })}> + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && <ImageLoading />} - <TouchableWithoutFeedback - onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} - onLongPress={onLongPressHandler} - delayLongPress={delayLongPress} - 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, }, }) +const getZoomRectAfterDoubleTap = ( + imageDimensions: ImageDimensions | null, + touchX: number, + touchY: number, +): { + x: number + y: number + width: number + height: number +} => { + if (!imageDimensions) { + return { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } + } + + // First, let's figure out how much we want to zoom in. + // We want to try to zoom in at least close enough to get rid of black bars. + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const zoom = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + 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. + + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. + // We already know the zoom level, so this gives us the rectangle size. + let rectWidth = SCREEN.width / zoom + let rectHeight = SCREEN.height / zoom + + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. + // We don't want to introduce new black bars or make existing black bars unbalanced. + let minX = 0 + let minY = 0 + let maxX = SCREEN.width - rectWidth + let maxY = SCREEN.height - rectHeight + if (imageAspect >= screenAspect) { + // The image has horizontal black bars. Exclude them from the safe area. + const renderedHeight = SCREEN.width / imageAspect + const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2 + minY += horizontalBarHeight + maxY -= horizontalBarHeight + } else { + // The image has vertical black bars. Exclude them from the safe area. + const renderedWidth = SCREEN.height * imageAspect + const verticalBarWidth = (SCREEN.width - renderedWidth) / 2 + minX += verticalBarWidth + maxX -= verticalBarWidth + } + + // Finally, we can position the rect according to its size and the safe area. + let rectX + if (maxX >= minX) { + // Content fills the screen horizontally so we have horizontal wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectX = touchX - touchX / zoom + rectX = Math.min(rectX, maxX) + rectX = Math.max(rectX, minX) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectX = SCREEN.width / 2 - rectWidth / 2 + } + let rectY + if (maxY >= minY) { + // Content fills the screen vertically so we have vertical wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectY = touchY - touchY / zoom + rectY = Math.min(rectY, maxY) + rectY = Math.max(rectY, minY) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectY = SCREEN.height / 2 - rectHeight / 2 + } + + return { + x: rectX, + y: rectY, + height: rectHeight, + width: rectWidth, + } +} + 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 fd377dde2..16688b820 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -7,11 +7,9 @@ import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - onLongPress: (image: ImageSource) => void - delayLongPress: number - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean + isScrollViewBeingDragged: boolean } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts deleted file mode 100644 index c21cd7f2c..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {Animated} from 'react-native' - -const INITIAL_POSITION = {x: 0, y: 0} -const ANIMATION_CONFIG = { - duration: 200, - useNativeDriver: true, -} - -const useAnimatedComponents = () => { - const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) - const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) - - const toggleVisible = (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 headerTransform = headerTranslate.getTranslateTransform() - const footerTransform = footerTranslate.getTranslateTransform() - - return [headerTransform, footerTransform, toggleVisible] as const -} - -export default useAnimatedComponents diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts deleted file mode 100644 index ea81d9f1c..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, {useCallback} from 'react' -import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native' - -import {Dimensions} from '../@types' - -const DOUBLE_TAP_DELAY = 300 -const MIN_ZOOM = 2 - -let lastTapTS: number | null = null - -/** - * This is iOS only. - * Same functionality for Android implemented inside usePanResponder hook. - */ -function useDoubleTapToZoom( - scrollViewRef: React.RefObject<ScrollView>, - scaled: boolean, - screen: Dimensions, - imageDimensions: Dimensions | null, -) { - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent<NativeTouchEvent>) => { - const nowTS = new Date().getTime() - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() - - const getZoomRectAfterDoubleTap = ( - touchX: number, - touchY: number, - ): { - x: number - y: number - width: number - height: number - } => { - if (!imageDimensions) { - return { - x: 0, - y: 0, - width: screen.width, - height: screen.height, - } - } - - // First, let's figure out how much we want to zoom in. - // We want to try to zoom in at least close enough to get rid of black bars. - const imageAspect = imageDimensions.width / imageDimensions.height - const screenAspect = screen.width / screen.height - const zoom = Math.max( - imageAspect / screenAspect, - screenAspect / imageAspect, - MIN_ZOOM, - ) - // 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. - - // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. - // We already know the zoom level, so this gives us the rectangle size. - let rectWidth = screen.width / zoom - let rectHeight = screen.height / zoom - - // Before we settle on the zoomed rect, figure out the safe area it has to be inside. - // We don't want to introduce new black bars or make existing black bars unbalanced. - let minX = 0 - let minY = 0 - let maxX = screen.width - rectWidth - let maxY = screen.height - rectHeight - if (imageAspect >= screenAspect) { - // The image has horizontal black bars. Exclude them from the safe area. - const renderedHeight = screen.width / imageAspect - const horizontalBarHeight = (screen.height - renderedHeight) / 2 - minY += horizontalBarHeight - maxY -= horizontalBarHeight - } else { - // The image has vertical black bars. Exclude them from the safe area. - const renderedWidth = screen.height * imageAspect - const verticalBarWidth = (screen.width - renderedWidth) / 2 - minX += verticalBarWidth - maxX -= verticalBarWidth - } - - // Finally, we can position the rect according to its size and the safe area. - let rectX - if (maxX >= minX) { - // Content fills the screen horizontally so we have horizontal wiggle room. - // Try to keep the tapped point under the finger after zoom. - rectX = touchX - touchX / zoom - rectX = Math.min(rectX, maxX) - rectX = Math.max(rectX, minX) - } else { - // Keep the rect centered on the screen so that black bars are balanced. - rectX = screen.width / 2 - rectWidth / 2 - } - let rectY - if (maxY >= minY) { - // Content fills the screen vertically so we have vertical wiggle room. - // Try to keep the tapped point under the finger after zoom. - rectY = touchY - touchY / zoom - rectY = Math.min(rectY, maxY) - rectY = Math.max(rectY, minY) - } else { - // Keep the rect centered on the screen so that black bars are balanced. - rectY = screen.height / 2 - rectHeight / 2 - } - - return { - x: rectX, - y: rectY, - height: rectHeight, - width: rectWidth, - } - } - - if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { - let nextZoomRect = { - x: 0, - y: 0, - width: screen.width, - height: screen.height, - } - - const willZoom = !scaled - if (willZoom) { - const {pageX, pageY} = event.nativeEvent - nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY) - } - - // @ts-ignore - scrollResponderRef?.scrollResponderZoomTo({ - ...nextZoomRect, // This rect is in screen coordinates - animated: true, - }) - } else { - lastTapTS = nowTS - } - }, - [imageDimensions, scaled, screen.height, screen.width, scrollViewRef], - ) - - return handleDoubleTap -} - -export default useDoubleTapToZoom diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts index a5b0b6bd4..cb46fd0d9 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -8,11 +8,29 @@ import {useEffect, useState} from 'react' import {Image, ImageURISource} from 'react-native' - -import {createCache} from '../utils' import {Dimensions, ImageSource} from '../@types' const CACHE_SIZE = 50 + +type CacheStorageItem = {key: string; value: any} + +const createCache = (cacheSize: number) => ({ + _storage: [] as CacheStorageItem[], + get(key: string): any { + const {value} = + this._storage.find(({key: storageKey}) => storageKey === key) || {} + + return value + }, + set(key: string, value: any) { + if (this._storage.length >= cacheSize) { + this._storage.shift() + } + + this._storage.push({key, value}) + }, +}) + const imageDimensionsCache = createCache(CACHE_SIZE) const useImageDimensions = (image: ImageSource): Dimensions | null => { @@ -21,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/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts deleted file mode 100644 index 16430f3aa..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useState} from 'react' -import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' - -import {Dimensions} from '../@types' - -const useImageIndexChange = (imageIndex: number, screen: Dimensions) => { - const [currentImageIndex, setImageIndex] = useState(imageIndex) - const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const { - nativeEvent: { - contentOffset: {x: scrollX}, - }, - } = event - - if (screen.width) { - const nextIndex = Math.round(scrollX / screen.width) - setImageIndex(nextIndex < 0 ? 0 : nextIndex) - } - } - - return [currentImageIndex, onScroll] as const -} - -export default useImageIndexChange diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts deleted file mode 100644 index 3969945bb..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useEffect} from 'react' -import {Image} from 'react-native' -import {ImageSource} from '../@types' - -const useImagePrefetch = (images: ImageSource[]) => { - useEffect(() => { - images.forEach(image => { - //@ts-ignore - if (image.uri) { - //@ts-ignore - return Image.prefetch(image.uri) - } - }) - }, [images]) -} - -export default useImagePrefetch diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts deleted file mode 100644 index 7908504ea..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useEffect} from 'react' -import { - Animated, - Dimensions, - GestureResponderEvent, - GestureResponderHandlers, - NativeTouchEvent, - PanResponder, - PanResponderGestureState, -} from 'react-native' - -import {Position} from '../@types' -import { - getDistanceBetweenTouches, - getImageTranslate, - getImageDimensionsByTranslate, -} from '../utils' - -const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) -const ANDROID_BAR_HEIGHT = 24 - -const MIN_ZOOM = 2 -const MAX_SCALE = 2 -const DOUBLE_TAP_DELAY = 300 -const OUT_BOUND_MULTIPLIER = 0.75 - -type Props = { - initialScale: number - initialTranslate: Position - onZoom: (isZoomed: boolean) => void - doubleTapToZoomEnabled: boolean - onLongPress: () => void - delayLongPress: number -} - -const usePanResponder = ({ - initialScale, - initialTranslate, - onZoom, - doubleTapToZoomEnabled, - onLongPress, - delayLongPress, -}: Props): Readonly< - [GestureResponderHandlers, Animated.Value, Animated.ValueXY] -> => { - let numberInitialTouches = 1 - let initialTouches: NativeTouchEvent[] = [] - let currentScale = initialScale - let currentTranslate = initialTranslate - let tmpScale = 0 - let tmpTranslate: Position | null = null - let isDoubleTapPerformed = false - let lastTapTS: number | null = null - let longPressHandlerRef: NodeJS.Timeout | null = null - - const meaningfulShift = MIN_DIMENSION * 0.01 - const scaleValue = new Animated.Value(initialScale) - const translateValue = new Animated.ValueXY(initialTranslate) - - const imageDimensions = getImageDimensionsByTranslate( - initialTranslate, - SCREEN, - ) - - const getBounds = (scale: number) => { - const scaledImageDimensions = { - width: imageDimensions.width * scale, - height: imageDimensions.height * scale, - } - const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN) - - const left = initialTranslate.x - translateDelta.x - const right = left - (scaledImageDimensions.width - SCREEN.width) - const top = initialTranslate.y - translateDelta.y - const bottom = top - (scaledImageDimensions.height - SCREEN.height) - - return [top, left, bottom, right] - } - - const getTransformAfterDoubleTap = ( - touchX: number, - touchY: number, - ): [number, Position] => { - let nextScale = initialScale - let nextTranslateX = initialTranslate.x - let nextTranslateY = initialTranslate.y - - // First, let's figure out how much we want to zoom in. - // We want to try to zoom in at least close enough to get rid of black bars. - const imageAspect = imageDimensions.width / imageDimensions.height - const screenAspect = SCREEN.width / SCREEN.height - let zoom = Math.max( - imageAspect / screenAspect, - screenAspect / imageAspect, - MIN_ZOOM, - ) - // Don't zoom so hard that the original image's pixels become blurry. - zoom = Math.min(zoom, MAX_SCALE / initialScale) - nextScale = initialScale * zoom - - // Next, let's see if we need to adjust the scaled image translation. - // Ideally, we want the tapped point to stay under the finger after the scaling. - const dx = SCREEN.width / 2 - touchX - const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT) - // Before we try to adjust the translation, check how much wiggle room we have. - // We don't want to introduce new black bars or make existing black bars unbalanced. - const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale) - if (leftBound > rightBound) { - // Content fills the screen horizontally so we have horizontal wiggle room. - // Try to keep the tapped point under the finger after zoom. - nextTranslateX += dx * zoom - dx - nextTranslateX = Math.min(nextTranslateX, leftBound) - nextTranslateX = Math.max(nextTranslateX, rightBound) - } - if (topBound > bottomBound) { - // Content fills the screen vertically so we have vertical wiggle room. - // Try to keep the tapped point under the finger after zoom. - nextTranslateY += dy * zoom - dy - nextTranslateY = Math.min(nextTranslateY, topBound) - nextTranslateY = Math.max(nextTranslateY, bottomBound) - } - - return [ - nextScale, - { - x: nextTranslateX, - y: nextTranslateY, - }, - ] - } - - const fitsScreenByWidth = () => - imageDimensions.width * currentScale < SCREEN_WIDTH - const fitsScreenByHeight = () => - imageDimensions.height * currentScale < SCREEN_HEIGHT - - useEffect(() => { - scaleValue.addListener(({value}) => { - if (typeof onZoom === 'function') { - onZoom(value !== initialScale) - } - }) - - return () => scaleValue.removeAllListeners() - }) - - const cancelLongPressHandle = () => { - longPressHandlerRef && clearTimeout(longPressHandlerRef) - } - - const panResponder = PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: () => true, - onMoveShouldSetPanResponderCapture: () => true, - onPanResponderGrant: ( - _: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - numberInitialTouches = gestureState.numberActiveTouches - - if (gestureState.numberActiveTouches > 1) { - return - } - - longPressHandlerRef = setTimeout(onLongPress, delayLongPress) - }, - onPanResponderStart: ( - event: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - initialTouches = event.nativeEvent.touches - numberInitialTouches = gestureState.numberActiveTouches - - if (gestureState.numberActiveTouches > 1) { - return - } - - const tapTS = Date.now() - // Handle double tap event by calculating diff between first and second taps timestamps - - isDoubleTapPerformed = Boolean( - lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, - ) - - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { - let nextScale = initialScale - let nextTranslate = initialTranslate - - const willZoom = currentScale === initialScale - if (willZoom) { - const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] - ;[nextScale, nextTranslate] = getTransformAfterDoubleTap( - touchX, - touchY, - ) - } - onZoom(willZoom) - - Animated.parallel( - [ - Animated.timing(translateValue.x, { - toValue: nextTranslate.x, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslate.y, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(scaleValue, { - toValue: nextScale, - duration: 300, - useNativeDriver: true, - }), - ], - {stopTogether: false}, - ).start(() => { - currentScale = nextScale - currentTranslate = nextTranslate - }) - - lastTapTS = null - } else { - lastTapTS = Date.now() - } - }, - onPanResponderMove: ( - event: GestureResponderEvent, - gestureState: PanResponderGestureState, - ) => { - const {dx, dy} = gestureState - - if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { - cancelLongPressHandle() - } - - // Don't need to handle move because double tap in progress (was handled in onStart) - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { - cancelLongPressHandle() - return - } - - if ( - numberInitialTouches === 1 && - gestureState.numberActiveTouches === 2 - ) { - numberInitialTouches = 2 - initialTouches = event.nativeEvent.touches - } - - const isTapGesture = - numberInitialTouches === 1 && gestureState.numberActiveTouches === 1 - const isPinchGesture = - numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 - - if (isPinchGesture) { - cancelLongPressHandle() - - const initialDistance = getDistanceBetweenTouches(initialTouches) - const currentDistance = getDistanceBetweenTouches( - event.nativeEvent.touches, - ) - - let nextScale = (currentDistance / initialDistance) * currentScale - - /** - * In case image is scaling smaller than initial size -> - * slow down this transition by applying OUT_BOUND_MULTIPLIER - */ - if (nextScale < initialScale) { - nextScale = - nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER - } - - /** - * In case image is scaling down -> move it in direction of initial position - */ - if (currentScale > initialScale && currentScale > nextScale) { - const k = (currentScale - initialScale) / (currentScale - nextScale) - - const nextTranslateX = - nextScale < initialScale - ? initialTranslate.x - : currentTranslate.x - - (currentTranslate.x - initialTranslate.x) / k - - const nextTranslateY = - nextScale < initialScale - ? initialTranslate.y - : currentTranslate.y - - (currentTranslate.y - initialTranslate.y) / k - - translateValue.x.setValue(nextTranslateX) - translateValue.y.setValue(nextTranslateY) - - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} - } - - scaleValue.setValue(nextScale) - tmpScale = nextScale - } - - if (isTapGesture && currentScale > initialScale) { - const {x, y} = currentTranslate - // eslint-disable-next-line @typescript-eslint/no-shadow - const {dx, dy} = gestureState - const [topBound, leftBound, bottomBound, rightBound] = - getBounds(currentScale) - - let nextTranslateX = x + dx - let nextTranslateY = y + dy - - if (nextTranslateX > leftBound) { - nextTranslateX = - nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateX < rightBound) { - nextTranslateX = - nextTranslateX - - (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateY > topBound) { - nextTranslateY = - nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER - } - - if (nextTranslateY < bottomBound) { - nextTranslateY = - nextTranslateY - - (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER - } - - if (fitsScreenByWidth()) { - nextTranslateX = x - } - - if (fitsScreenByHeight()) { - nextTranslateY = y - } - - translateValue.x.setValue(nextTranslateX) - translateValue.y.setValue(nextTranslateY) - - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} - } - }, - onPanResponderRelease: () => { - cancelLongPressHandle() - - if (isDoubleTapPerformed) { - isDoubleTapPerformed = false - } - - if (tmpScale > 0) { - if (tmpScale < initialScale || tmpScale > MAX_SCALE) { - tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE - Animated.timing(scaleValue, { - toValue: tmpScale, - duration: 100, - useNativeDriver: true, - }).start() - } - - currentScale = tmpScale - tmpScale = 0 - } - - if (tmpTranslate) { - const {x, y} = tmpTranslate - const [topBound, leftBound, bottomBound, rightBound] = - getBounds(currentScale) - - let nextTranslateX = x - let nextTranslateY = y - - if (!fitsScreenByWidth()) { - if (nextTranslateX > leftBound) { - nextTranslateX = leftBound - } else if (nextTranslateX < rightBound) { - nextTranslateX = rightBound - } - } - - if (!fitsScreenByHeight()) { - if (nextTranslateY > topBound) { - nextTranslateY = topBound - } else if (nextTranslateY < bottomBound) { - nextTranslateY = bottomBound - } - } - - Animated.parallel([ - Animated.timing(translateValue.x, { - toValue: nextTranslateX, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslateY, - duration: 100, - useNativeDriver: true, - }), - ]).start() - - currentTranslate = {x: nextTranslateX, y: nextTranslateY} - tmpTranslate = null - } - }, - onPanResponderTerminationRequest: () => false, - onShouldBlockNativeResponder: () => false, - }) - - return [panResponder.panHandlers, scaleValue, translateValue] -} - -export default usePanResponder diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts deleted file mode 100644 index 4cd03fe71..000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useState} from 'react' - -const useRequestClose = (onRequestClose: () => void) => { - const [opacity, setOpacity] = useState(1) - - return [ - opacity, - () => { - setOpacity(0) - onRequestClose() - setTimeout(() => setOpacity(1), 0) - }, - ] as const -} - -export default useRequestClose diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 1a64fb3af..b6835793d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -8,91 +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, - useCallback, - useRef, - useEffect, - useMemo, -} from 'react' -import { - Animated, - Dimensions, - 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 useAnimatedComponents from './hooks/useAnimatedComponents' -import useImageIndexChange from './hooks/useImageIndexChange' -import useRequestClose from './hooks/useRequestClose' import {ImageSource} from './@types' +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 - onLongPress?: (image: ImageSource) => void - onImageIndexChange?: (imageIndex: number) => void - presentationStyle?: ModalProps['presentationStyle'] - animationType?: ModalProps['animationType'] backgroundColor?: string - swipeToCloseEnabled?: boolean - doubleTapToZoomEnabled?: boolean - delayLongPress?: number HeaderComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}> } const DEFAULT_BG_COLOR = '#000' -const DEFAULT_DELAY_LONG_PRESS = 800 -const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width function ImageViewing({ images, - keyExtractor, - imageIndex, + initialImageIndex, visible, onRequestClose, - onLongPress = () => {}, - onImageIndexChange, backgroundColor = DEFAULT_BG_COLOR, - swipeToCloseEnabled, - doubleTapToZoomEnabled, - delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, }: Props) { - const imageList = useRef<VirtualizedList<ImageSource>>(null) - const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) - const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) - const [headerTransform, footerTransform, toggleBarsVisible] = - useAnimatedComponents() - - useEffect(() => { - if (onImageIndexChange) { - onImageIndexChange(currentImageIndex) + const [isScaled, setIsScaled] = useState(false) + const [isDragging, setIsDragging] = useState(false) + 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), + }, + ], + })) + + const onTap = useCallback(() => { + setShowControls(show => !show) + }, []) + + const onZoom = useCallback((nextIsScaled: boolean) => { + setIsScaled(nextIsScaled) + if (nextIsScaled) { + setShowControls(false) } - }, [currentImageIndex, onImageIndexChange]) - - const onZoom = useCallback( - (isScaled: boolean) => { - // @ts-ignore - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) - toggleBarsVisible(!isScaled) - }, - [toggleBarsVisible], - ) + }, []) const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -101,12 +82,6 @@ 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]) - if (!visible) { return null } @@ -114,60 +89,47 @@ function ImageViewing({ 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 - 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} - onLongPress={onLongPress} - delayLongPress={delayLongPress} - swipeToCloseEnabled={swipeToCloseEnabled} - doubleTapToZoomEnabled={doubleTapToZoomEnabled} - /> - )} - onMomentumScrollEnd={onScroll} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } - /> + <PagerView + scrollEnabled={!isScaled} + initialPage={initialImageIndex} + onPageSelected={e => { + setImageIndex(e.nativeEvent.position) + setIsScaled(false) + }} + 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> )} @@ -179,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%', @@ -200,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/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts new file mode 100644 index 000000000..05476678f --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/transforms.ts @@ -0,0 +1,98 @@ +import type {Position} from './@types' + +export type TransformMatrix = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +] + +// These are affine transforms. See explanation of every cell here: +// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg + +export function createTransform(): TransformMatrix { + 'worklet' + return [1, 0, 0, 0, 1, 0, 0, 0, 1] +} + +export function applyRounding(t: TransformMatrix) { + 'worklet' + t[2] = Math.round(t[2]) + t[5] = Math.round(t[5]) + // For example: 0.985, 0.99, 0.995, then 1: + t[0] = Math.round(t[0] * 200) / 200 + t[4] = Math.round(t[0] * 200) / 200 +} + +// We're using a limited subset (always scaling and translating while keeping aspect ratio) so +// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching. + +// All write operations are applied in-place to avoid unnecessary allocations. + +export function readTransform(t: TransformMatrix): [number, number, number] { + 'worklet' + const scale = t[0] + const translateX = t[2] + const translateY = t[5] + return [translateX, translateY, scale] +} + +export function prependTranslate(t: TransformMatrix, x: number, y: number) { + 'worklet' + t[2] += t[0] * x + t[1] * y + t[5] += t[3] * x + t[4] * y +} + +export function prependScale(t: TransformMatrix, value: number) { + 'worklet' + t[0] *= value + t[1] *= value + t[3] *= value + t[4] *= value +} + +export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) { + 'worklet' + // In-place matrix multiplication. + const a00 = ta[0], + a01 = ta[1], + a02 = ta[2] + const a10 = ta[3], + a11 = ta[4], + a12 = ta[5] + const a20 = ta[6], + a21 = ta[7], + a22 = ta[8] + ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6] + ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7] + ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8] + ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6] + ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7] + ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8] + ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6] + ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7] + ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8] +} + +export function prependPan(t: TransformMatrix, translation: Position) { + 'worklet' + prependTranslate(t, translation.x, translation.y) +} + +export function prependPinch( + t: TransformMatrix, + scale: number, + origin: Position, + translation: Position, +) { + 'worklet' + prependTranslate(t, translation.x, translation.y) + prependTranslate(t, origin.x, origin.y) + prependScale(t, scale) + prependTranslate(t, -origin.x, -origin.y) +} diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts deleted file mode 100644 index d56eea4f4..000000000 --- a/src/view/com/lightbox/ImageViewing/utils.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {Animated, NativeTouchEvent} from 'react-native' -import {Dimensions, Position} from './@types' - -type CacheStorageItem = {key: string; value: any} - -export const createCache = (cacheSize: number) => ({ - _storage: [] as CacheStorageItem[], - get(key: string): any { - const {value} = - this._storage.find(({key: storageKey}) => storageKey === key) || {} - - return value - }, - set(key: string, value: any) { - if (this._storage.length >= cacheSize) { - this._storage.shift() - } - - this._storage.push({key, value}) - }, -}) - -export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] => - arr.reduce((result, item) => { - const batch = result.pop() || [] - - if (batch.length < batchSize) { - batch.push(item) - result.push(batch) - } else { - result.push(batch, [item]) - } - - return result - }, []) - -export const getImageTransform = ( - image: Dimensions | null, - screen: Dimensions, -) => { - 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 -} - -export const getImageStyles = ( - image: Dimensions | null, - translate: Animated.ValueXY, - scale?: Animated.Value, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - - const transform = translate.getTranslateTransform() - - 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, - } -} - -export const getImageTranslate = ( - image: Dimensions, - screen: Dimensions, -): Position => { - 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 const getImageDimensionsByTranslate = ( - translate: Position, - screen: Dimensions, -): Dimensions => ({ - width: screen.width - translate.x * 2, - height: screen.height - translate.y * 2, -}) - -export const getImageTranslateForScale = ( - currentTranslate: Position, - targetScale: number, - screen: Dimensions, -): Position => { - const {width, height} = getImageDimensionsByTranslate( - currentTranslate, - screen, - ) - - const targetImageDimensions = { - width: width * targetScale, - height: height * targetScale, - } - - return getImageTranslate(targetImageDimensions, screen) -} - -export const getDistanceBetweenTouches = ( - touches: NativeTouchEvent[], -): number => { - const [a, b] = touches - - if (a == null || b == null) { - return 0 - } - - return Math.sqrt( - Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), - ) -} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 072bfebfa..92c30f491 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -15,13 +15,48 @@ import * as MediaLibrary from 'expo-media-library' export const Lightbox = observer(function Lightbox() { const store = useStores() - const [isAltExpanded, setAltExpanded] = React.useState(false) - const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() - const onClose = React.useCallback(() => { store.shell.closeLightbox() }, [store]) + if (!store.shell.activeLightbox) { + return null + } else if (store.shell.activeLightbox.name === 'profile-image') { + const opts = store.shell.activeLightbox as models.ProfileImageLightbox + return ( + <ImageView + images={[{uri: opts.profileView.avatar || ''}]} + initialImageIndex={0} + visible + onRequestClose={onClose} + FooterComponent={LightboxFooter} + /> + ) + } else if (store.shell.activeLightbox.name === 'images') { + const opts = store.shell.activeLightbox as models.ImagesLightbox + return ( + <ImageView + images={opts.images.map(img => ({...img}))} + initialImageIndex={opts.index} + visible + onRequestClose={onClose} + FooterComponent={LightboxFooter} + /> + ) + } else { + return null + } +}) + +const LightboxFooter = observer(function LightboxFooter({ + imageIndex, +}: { + imageIndex: number +}) { + const store = useStores() + const [isAltExpanded, setAltExpanded] = React.useState(false) + const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() + const saveImageToAlbumWithToasts = React.useCallback( async (uri: string) => { if (!permissionResponse || permissionResponse.granted === false) { @@ -46,90 +81,57 @@ export const Lightbox = observer(function Lightbox() { [permissionResponse, requestPermission], ) - const LightboxFooter = React.useCallback( - ({imageIndex}: {imageIndex: number}) => { - const lightbox = store.shell.activeLightbox - if (!lightbox) { - return null - } + const lightbox = store.shell.activeLightbox + if (!lightbox) { + return null + } - let altText = '' - let uri = '' - if (lightbox.name === 'images') { - const opts = lightbox as models.ImagesLightbox - uri = opts.images[imageIndex].uri - altText = opts.images[imageIndex].alt || '' - } else if (lightbox.name === 'profile-image') { - const opts = lightbox as models.ProfileImageLightbox - uri = opts.profileView.avatar || '' - } + let altText = '' + let uri = '' + if (lightbox.name === 'images') { + const opts = lightbox as models.ImagesLightbox + uri = opts.images[imageIndex].uri + altText = opts.images[imageIndex].alt || '' + } else if (lightbox.name === 'profile-image') { + const opts = lightbox as models.ProfileImageLightbox + uri = opts.profileView.avatar || '' + } - return ( - <View style={[styles.footer]}> - {altText ? ( - <Pressable - onPress={() => setAltExpanded(!isAltExpanded)} - accessibilityRole="button"> - <Text - style={[s.gray3, styles.footerText]} - numberOfLines={isAltExpanded ? undefined : 3}> - {altText} - </Text> - </Pressable> - ) : null} - <View style={styles.footerBtns}> - <Button - type="primary-outline" - style={styles.footerBtn} - onPress={() => saveImageToAlbumWithToasts(uri)}> - <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> - <Text type="xl" style={s.white}> - Save - </Text> - </Button> - <Button - type="primary-outline" - style={styles.footerBtn} - onPress={() => shareImageModal({uri})}> - <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> - <Text type="xl" style={s.white}> - Share - </Text> - </Button> - </View> - </View> - ) - }, - [store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts], + return ( + <View style={[styles.footer]}> + {altText ? ( + <Pressable + onPress={() => setAltExpanded(!isAltExpanded)} + accessibilityRole="button"> + <Text + style={[s.gray3, styles.footerText]} + numberOfLines={isAltExpanded ? undefined : 3}> + {altText} + </Text> + </Pressable> + ) : null} + <View style={styles.footerBtns}> + <Button + type="primary-outline" + style={styles.footerBtn} + onPress={() => saveImageToAlbumWithToasts(uri)}> + <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> + <Text type="xl" style={s.white}> + Save + </Text> + </Button> + <Button + type="primary-outline" + style={styles.footerBtn} + onPress={() => shareImageModal({uri})}> + <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> + <Text type="xl" style={s.white}> + Share + </Text> + </Button> + </View> + </View> ) - - if (!store.shell.activeLightbox) { - return null - } else if (store.shell.activeLightbox.name === 'profile-image') { - const opts = store.shell.activeLightbox as models.ProfileImageLightbox - return ( - <ImageView - images={[{uri: opts.profileView.avatar || ''}]} - imageIndex={0} - visible - onRequestClose={onClose} - FooterComponent={LightboxFooter} - /> - ) - } else if (store.shell.activeLightbox.name === 'images') { - const opts = store.shell.activeLightbox as models.ImagesLightbox - return ( - <ImageView - images={opts.images.map(img => ({...img}))} - imageIndex={opts.index} - visible - onRequestClose={onClose} - FooterComponent={LightboxFooter} - /> - ) - } else { - return null - } }) const styles = StyleSheet.create({ 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/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/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 51d75e3ef..d5fa32692 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -37,74 +37,69 @@ export function Component({}: {}) { }, [track, store]) return ( - <View style={[styles.container, pal.view]}> + <BottomSheetScrollView + style={[styles.container, pal.view]} + contentContainerStyle={[styles.innerContainer, pal.view]}> <Text type="title-xl" style={[styles.title, pal.text]}> Switch Account </Text> - <BottomSheetScrollView - style={styles.container} - contentContainerStyle={[styles.innerContainer, pal.view]}> - {isSwitching ? ( + {isSwitching ? ( + <View style={[pal.view, styles.linkCard]}> + <ActivityIndicator /> + </View> + ) : ( + <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback> <View style={[pal.view, styles.linkCard]}> - <ActivityIndicator /> - </View> - ) : ( - <Link - href={makeProfileLink(store.me)} - title="Your profile" - noFeedback> - <View style={[pal.view, styles.linkCard]}> - <View style={styles.avi}> - <UserAvatar size={40} avatar={store.me.avatar} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text} numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="sm" style={pal.textLight} numberOfLines={1}> - {store.me.handle} - </Text> - </View> - <TouchableOpacity - testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout} - accessibilityRole="button" - accessibilityLabel="Sign out" - accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> - <Text type="lg" style={pal.link}> - Sign out - </Text> - </TouchableOpacity> - </View> - </Link> - )} - {store.session.switchableAccounts.map(account => ( - <TouchableOpacity - testID={`switchToAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> <View style={styles.avi}> - <UserAvatar size={40} avatar={account.aviUrl} /> + <UserAvatar size={40} avatar={store.me.avatar} /> </View> <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text}> - {account.displayName || account.handle} + <Text type="md-bold" style={pal.text} numberOfLines={1}> + {store.me.displayName || store.me.handle} </Text> - <Text type="sm" style={pal.textLight}> - {account.handle} + <Text type="sm" style={pal.textLight} numberOfLines={1}> + {store.me.handle} </Text> </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} - </BottomSheetScrollView> - </View> + <TouchableOpacity + testID="signOutBtn" + onPress={isSwitching ? undefined : onPressSignout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + Sign out + </Text> + </TouchableOpacity> + </View> + </Link> + )} + {store.session.switchableAccounts.map(account => ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} + onPress={ + isSwitching ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + <View style={styles.avi}> + <UserAvatar size={40} avatar={account.aviUrl} /> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text}> + {account.displayName || account.handle} + </Text> + <Text type="sm" style={pal.textLight}> + {account.handle} + </Text> + </View> + <AccountDropdownBtn handle={account.handle} /> + </TouchableOpacity> + ))} + </BottomSheetScrollView> ) } @@ -113,7 +108,7 @@ const styles = StyleSheet.create({ flex: 1, }, innerContainer: { - paddingBottom: 20, + paddingBottom: 40, }, title: { textAlign: 'center', 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/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx index 89a0da47f..aaf358b87 100644 --- a/src/view/com/notifications/InvitedUsers.tsx +++ b/src/view/com/notifications/InvitedUsers.tsx @@ -75,7 +75,7 @@ function InvitedUser({ <FollowButton unfollowedType="primary" followedType="primary-light" - did={profile.did} + profile={profile} /> <Button testID="dismissBtn" 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/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index c53c2686c..378ef5028 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -439,5 +439,7 @@ const styles = StyleSheet.create({ parentSpinner: { paddingVertical: 10, }, - childSpinner: {}, + childSpinner: { + paddingBottom: 200, + }, }) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 55e69a318..74883f82a 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -33,6 +33,7 @@ export const Feed = observer(function Feed({ onScroll, scrollEventThrottle, renderEmptyState, + renderEndOfFeed, testID, headerOffset = 0, ListHeaderComponent, @@ -44,7 +45,8 @@ 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 ListHeaderComponent?: () => JSX.Element @@ -94,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 { @@ -114,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 @@ -142,14 +141,16 @@ export const Feed = observer(function Feed({ const FeedFooter = React.useCallback( () => - feed.isLoading ? ( + feed.isLoadingMore ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> + ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + renderEndOfFeed() ) : ( <View /> ), - [feed], + [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], ) return ( @@ -177,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/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx index a73ffb68b..61a27e48e 100644 --- a/src/view/com/posts/FollowingEmptyState.tsx +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -28,60 +28,73 @@ export function FollowingEmptyState() { }, [navigation]) const onPressDiscoverFeeds = React.useCallback(() => { - navigation.navigate('Feeds') + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } }, [navigation]) return ( - <View style={styles.emptyContainer}> - <View style={styles.emptyIconContainer}> - <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> - </View> - <Text type="xl-medium" style={[s.textCenter, pal.text]}> - Your following feed is empty! Find some accounts to follow to fix this. - </Text> - <Button - type="inverted" - style={styles.emptyBtn} - onPress={onPressFindAccounts}> - <Text type="lg-medium" style={palInverted.text}> - Find accounts to follow + <View style={styles.container}> + <View style={styles.inner}> + <View style={styles.iconContainer}> + <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} /> + </View> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + Your following feed is empty! Follow more users to see what's + happening. </Text> - <FontAwesomeIcon - icon="angle-right" - style={palInverted.text as FontAwesomeIconStyle} - size={14} - /> - </Button> + <Button + type="inverted" + style={styles.emptyBtn} + onPress={onPressFindAccounts}> + <Text type="lg-medium" style={palInverted.text}> + Find accounts to follow + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> - <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> - You can also discover new Custom Feeds to follow. - </Text> - <Button - type="inverted" - style={[styles.emptyBtn, s.mt10]} - onPress={onPressDiscoverFeeds}> - <Text type="lg-medium" style={palInverted.text}> - Discover new custom feeds + <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> + You can also discover new Custom Feeds to follow. </Text> - <FontAwesomeIcon - icon="angle-right" - style={palInverted.text as FontAwesomeIconStyle} - size={14} - /> - </Button> + <Button + type="inverted" + style={[styles.emptyBtn, s.mt10]} + onPress={onPressDiscoverFeeds}> + <Text type="lg-medium" style={palInverted.text}> + Discover new custom feeds + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + </View> </View> ) } const styles = StyleSheet.create({ - emptyContainer: { + container: { height: '100%', + flexDirection: 'row', + justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 30, }, - emptyIconContainer: { + inner: { + maxWidth: 460, + }, + iconContainer: { marginBottom: 16, }, - emptyIcon: { + icon: { marginLeft: 'auto', marginRight: 'auto', }, @@ -94,13 +107,4 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, borderRadius: 30, }, - - feedsTip: { - position: 'absolute', - left: 22, - }, - feedsTipArrow: { - marginLeft: 32, - marginTop: 8, - }, }) diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx new file mode 100644 index 000000000..48724d8b3 --- /dev/null +++ b/src/view/com/posts/FollowingEndOfFeed.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' + +export function FollowingEndOfFeed() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const navigation = useNavigation<NavigationProp>() + + const onPressFindAccounts = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Search', {}) + } else { + navigation.navigate('SearchTab') + navigation.popToTop() + } + }, [navigation]) + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + return ( + <View style={[styles.container, pal.border]}> + <View style={styles.inner}> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + You've reached the end of your feed! Find some more accounts to + follow. + </Text> + <Button + type="inverted" + style={styles.emptyBtn} + onPress={onPressFindAccounts}> + <Text type="lg-medium" style={palInverted.text}> + Find accounts to follow + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + + <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> + You can also discover new Custom Feeds to follow. + </Text> + <Button + type="inverted" + style={[styles.emptyBtn, s.mt10]} + onPress={onPressDiscoverFeeds}> + <Text type="lg-medium" style={palInverted.text}> + Discover new custom feeds + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + </View> + </View> + ) +} +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'center', + paddingTop: 40, + paddingBottom: 80, + paddingHorizontal: 30, + borderTopWidth: 1, + }, + inner: { + maxWidth: 460, + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 217d326e8..adb496f6d 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,25 +1,26 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {AppBskyActorDefs} from '@atproto/api' import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' import {FollowState} from 'state/models/cache/my-follows' -import {useFollowDid} from 'lib/hooks/useFollowDid' +import {useFollowProfile} from 'lib/hooks/useFollowProfile' export const FollowButton = observer(function FollowButtonImpl({ unfollowedType = 'inverted', followedType = 'default', - did, + profile, onToggleFollow, labelStyle, }: { unfollowedType?: ButtonType followedType?: ButtonType - did: string + profile: AppBskyActorDefs.ProfileViewBasic onToggleFollow?: (v: boolean) => void labelStyle?: StyleProp<TextStyle> }) { - const {state, following, toggle} = useFollowDid({did}) + const {state, following, toggle} = useFollowProfile(profile) const onPress = React.useCallback(async () => { try { diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index e0c8ad21a..d1aed8934 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -200,7 +200,7 @@ export const ProfileCardWithFollowBtn = observer( noBorder={noBorder} followers={followers} renderButton={ - isMe ? undefined : () => <FollowButton did={profile.did} /> + isMe ? undefined : () => <FollowButton profile={profile} /> } /> ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 57fa22f1e..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), ) @@ -392,8 +391,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ { paddingHorizontal: 10, backgroundColor: showSuggestedFollows - ? colors.blue3 - : pal.viewLight.backgroundColor, + ? pal.colors.text + : pal.colors.backgroundLight, }, ]} accessibilityRole="button" diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index b9d66a6fe..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, @@ -19,13 +19,14 @@ import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' -import {useFollowDid} from 'lib/hooks/useFollowDid' +import {useFollowProfile} from 'lib/hooks/useFollowProfile' import {Button} from 'view/com/util/forms/Button' import {sanitizeDisplayName} from 'lib/strings/display-names' 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 @@ -83,7 +84,7 @@ export function ProfileHeaderSuggestedFollows({ return [] } - store.me.follows.hydrateProfiles(suggestions) + store.me.follows.hydrateMany(suggestions) return suggestions } catch (e) { @@ -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 ? ( <> @@ -218,14 +222,14 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({ const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const {following, toggle} = useFollowDid({did: profile.did}) + const {following, toggle} = useFollowProfile(profile) const moderation = moderateProfile(profile, store.preferences.moderationOpts) 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/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx index f04175afd..6bd1b2f00 100644 --- a/src/view/com/search/HeaderWithInput.tsx +++ b/src/view/com/search/HeaderWithInput.tsx @@ -93,7 +93,7 @@ export function HeaderWithInput({ onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} - autoFocus={isMobile} + autoFocus={false} accessibilityRole="search" accessibilityLabel="Search" accessibilityHint="" diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index a495fcd3f..7486b212f 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -22,7 +22,7 @@ export function EmptyState({ }) { const pal = usePalette('default') return ( - <View testID={testID} style={[styles.container, style]}> + <View testID={testID} style={[styles.container, pal.border, style]}> <View style={styles.iconContainer}> {icon === 'user-group' ? ( <UserGroupIcon size="64" style={styles.icon} /> @@ -50,6 +50,7 @@ const styles = StyleSheet.create({ container: { paddingVertical: 20, paddingHorizontal: 36, + borderTopWidth: 1, }, iconContainer: { flexDirection: 'row', diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index c7374e195..529435cf1 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> { public render() { if (this.state.hasError) { return ( - <CenteredView> + <CenteredView style={{height: '100%', flex: 1}}> <ErrorScreen title="Oh no!" message="There was an unexpected issue in the application. Please let us know if this happened to you!" diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 472d943e1..94fe75536 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, @@ -49,7 +48,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { anchorNoUnderline?: boolean } -export const Link = observer(function Link({ +export const Link = memo(function Link({ testID, style, href, @@ -135,7 +134,7 @@ export const Link = observer(function Link({ ) }) -export const TextLink = observer(function TextLink({ +export const TextLink = memo(function TextLink({ testID, type = 'md', style, @@ -235,7 +234,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/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 035e29c25..6cbcddc32 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -52,20 +52,20 @@ export function AutoSizedImage({ if (onPress || onLongPress || onPressIn) { return ( + // disable a11y rule because in this case we want the tags on the image (#1640) + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors <Pressable onPress={onPress} onLongPress={onLongPress} onPressIn={onPressIn} - style={[styles.container, style]} - accessible={true} - accessibilityRole="button" - accessibilityLabel={alt || 'Image'} - accessibilityHint="Tap to view fully"> + style={[styles.container, style]}> <Image style={[styles.image, {aspectRatio}]} source={uri} - accessible={false} // Must set for `accessibilityLabel` to work + accessible={true} // Must set for `accessibilityLabel` to work accessibilityIgnoresInvertColors + accessibilityLabel={alt} + accessibilityHint="Tap to view fully" /> {children} </Pressable> 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 e53d4a08e..ad47e9f9b 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,32 +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( @@ -97,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} /> @@ -127,6 +119,7 @@ export const HomeScreen = withAuthRequired( isPageFocused={selectedPage === 0} feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={FollowingEndOfFeed} /> {customFeeds.map((f, index) => { return ( @@ -144,193 +137,7 @@ export const HomeScreen = withAuthRequired( }), ) -const FeedPage = observer(function FeedPageImpl({ - testID, - isPageFocused, - feed, - renderEmptyState, -}: { - testID?: string - feed: PostsFeedModel - isPageFocused: boolean - renderEmptyState?: () => 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} - 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/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index b545a643d..b80c1667f 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -148,18 +148,18 @@ export const SearchScreen = withAuthRequired( style={pal.view} onScroll={onMainScroll} scrollEventThrottle={100}> - {query && autocompleteView.searchRes.length ? ( + {query && autocompleteView.suggestions.length ? ( <> - {autocompleteView.searchRes.map((profile, index) => ( + {autocompleteView.suggestions.map((suggestion, index) => ( <ProfileCard - key={profile.did} - testID={`searchAutoCompleteResult-${profile.handle}`} - profile={profile} + key={suggestion.did} + testID={`searchAutoCompleteResult-${suggestion.handle}`} + profile={suggestion} noBorder={index === 0} /> ))} </> - ) : query && !autocompleteView.searchRes.length ? ( + ) : query && !autocompleteView.suggestions.length ? ( <View> <Text style={[pal.textLight, styles.searchPrompt]}> No results found for {autocompleteView.prefix} 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/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/Search.tsx b/src/view/shell/desktop/Search.tsx index dfd4f50bf..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) @@ -90,9 +97,9 @@ export const DesktopSearch = observer(function DesktopSearch() { {query !== '' && ( <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> - {autocompleteView.searchRes.length ? ( + {autocompleteView.suggestions.length ? ( <> - {autocompleteView.searchRes.map((item, i) => ( + {autocompleteView.suggestions.map((item, i) => ( <ProfileCard key={item.did} profile={item} noBorder={i === 0} /> ))} </> 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 a4c1cbb61..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": @@ -2255,10 +2256,10 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== -"@gorhom/bottom-sheet@^4.4.7": - version "4.4.7" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-4.4.7.tgz#fc80b3f0b7ebab056ce226f3aa3a89b2db8660dd" - integrity sha512-ukTuTqDQi2heo68hAJsBpUQeEkdqP9REBcn47OpuvPKhdPuO1RBOOADjqXJNCnZZRcY+HqbnGPMSLFVc31zylQ== +"@gorhom/bottom-sheet@^4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-4.5.1.tgz#1ac4b234a80e7dff263f0b7ac207f92e41562849" + integrity sha512-4Qy6hzvN32fXu2hDxDXOIS0IBGBT6huST7J7+K1V5bXemZ08KIx5ZffyLgwhCUl+CnyeG2KG6tqk6iYLkIwi7Q== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" @@ -3223,44 +3224,44 @@ resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.3.2.tgz#185a2c7dd03ba168cc95069bc4742e9505fd6c6c" integrity sha512-0ID+pyZKdC4RdgC7HePxUQ6JmsbNrgz03u+6SgqYpmBoK/rE+7JffqIw7IEsfoKitLEcRNLGekIBsfwCqiEkew== -"@react-native-community/cli-clean@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-11.3.6.tgz#43a06cbee1a5480da804debc4f94662a197720f2" - integrity sha512-jOOaeG5ebSXTHweq1NznVJVAFKtTFWL4lWgUXl845bCGX7t1lL8xQNWHKwT8Oh1pGR2CI3cKmRjY4hBg+pEI9g== +"@react-native-community/cli-clean@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-11.3.7.tgz#cb4c2f225f78593412c2d191b55b8570f409a48f" + integrity sha512-twtsv54ohcRyWVzPXL3F9VHGb4Qhn3slqqRs3wEuRzjR7cTmV2TIO2b1VhaqF4HlCgNd+cGuirvLtK2JJyaxMg== dependencies: - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" execa "^5.0.0" prompts "^2.4.0" -"@react-native-community/cli-config@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-config/-/cli-config-11.3.6.tgz#6d3636a8a3c4542ebb123eaf61bbbc0c2a1d2a6b" - integrity sha512-edy7fwllSFLan/6BG6/rznOBCLPrjmJAE10FzkEqNLHowi0bckiAPg1+1jlgQ2qqAxV5kuk+c9eajVfQvPLYDA== +"@react-native-community/cli-config@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-config/-/cli-config-11.3.7.tgz#4ce95548252ecb094b576369abebf9867c95d277" + integrity sha512-FDBLku9xskS+bx0YFJFLCmUJhEZ4/MMSC9qPYOGBollWYdgE7k/TWI0IeYFmMALAnbCdKQAYP5N29N55Tad8lg== dependencies: - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" cosmiconfig "^5.1.0" deepmerge "^4.3.0" glob "^7.1.3" joi "^17.2.1" -"@react-native-community/cli-debugger-ui@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-11.3.6.tgz#1eb2276450f270a938686b49881fe232a08c01c4" - integrity sha512-jhMOSN/iOlid9jn/A2/uf7HbC3u7+lGktpeGSLnHNw21iahFBzcpuO71ekEdlmTZ4zC/WyxBXw9j2ka33T358w== +"@react-native-community/cli-debugger-ui@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-11.3.7.tgz#2147b73313af8de3c9b396406d5d344b904cf2bb" + integrity sha512-aVmKuPKHZENR8SrflkMurZqeyLwbKieHdOvaZCh1Nn/0UC5CxWcyST2DB2XQboZwsvr3/WXKJkSUO+SZ1J9qTQ== dependencies: serve-static "^1.13.1" -"@react-native-community/cli-doctor@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-doctor/-/cli-doctor-11.3.6.tgz#fa33ee00fe5120af516aa0f17fe3ad50270976e7" - integrity sha512-UT/Tt6omVPi1j6JEX+CObc85eVFghSZwy4GR9JFMsO7gNg2Tvcu1RGWlUkrbmWMAMHw127LUu6TGK66Ugu1NLA== +"@react-native-community/cli-doctor@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-doctor/-/cli-doctor-11.3.7.tgz#7d5f5b1aea78134bba713fa97795986345ff1344" + integrity sha512-YEHUqWISOHnsl5+NM14KHelKh68Sr5/HeEZvvNdIcvcKtZic3FU7Xd1WcbNdo3gCq5JvzGFfufx02Tabh5zmrg== dependencies: - "@react-native-community/cli-config" "11.3.6" - "@react-native-community/cli-platform-android" "11.3.6" - "@react-native-community/cli-platform-ios" "11.3.6" - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-config" "11.3.7" + "@react-native-community/cli-platform-android" "11.3.7" + "@react-native-community/cli-platform-ios" "11.3.7" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" command-exists "^1.2.8" envinfo "^7.7.2" @@ -3276,64 +3277,64 @@ wcwidth "^1.0.1" yaml "^2.2.1" -"@react-native-community/cli-hermes@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-hermes/-/cli-hermes-11.3.6.tgz#b1acc7feff66ab0859488e5812b3b3e8b8e9434c" - integrity sha512-O55YAYGZ3XynpUdePPVvNuUPGPY0IJdctLAOHme73OvS80gNwfntHDXfmY70TGHWIfkK2zBhA0B+2v8s5aTyTA== +"@react-native-community/cli-hermes@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-hermes/-/cli-hermes-11.3.7.tgz#091e730a1f8bace6c3729e8744bad6141002e0e8" + integrity sha512-chkKd8n/xeZkinRvtH6QcYA8rjNOKU3S3Lw/3Psxgx+hAYV0Gyk95qJHTalx7iu+PwjOOqqvCkJo5jCkYLkoqw== dependencies: - "@react-native-community/cli-platform-android" "11.3.6" - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-platform-android" "11.3.7" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" hermes-profile-transformer "^0.0.6" ip "^1.1.5" -"@react-native-community/cli-platform-android@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-android/-/cli-platform-android-11.3.6.tgz#6f3581ca4eed3deec7edba83c1bc467098c8167b" - integrity sha512-ZARrpLv5tn3rmhZc//IuDM1LSAdYnjUmjrp58RynlvjLDI4ZEjBAGCQmgysRgXAsK7ekMrfkZgemUczfn9td2A== +"@react-native-community/cli-platform-android@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-android/-/cli-platform-android-11.3.7.tgz#7845bc48258b6bb55df208a23b3690647f113995" + integrity sha512-WGtXI/Rm178UQb8bu1TAeFC/RJvYGnbHpULXvE20GkmeJ1HIrMjkagyk6kkY3Ej25JAP2R878gv+TJ/XiRhaEg== dependencies: - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" execa "^5.0.0" glob "^7.1.3" logkitty "^0.7.1" -"@react-native-community/cli-platform-ios@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-11.3.6.tgz#0fa58d01f55d85618c4218925509a4be77867dab" - integrity sha512-tZ9VbXWiRW+F+fbZzpLMZlj93g3Q96HpuMsS6DRhrTiG+vMQ3o6oPWSEEmMGOvJSYU7+y68Dc9ms2liC7VD6cw== +"@react-native-community/cli-platform-ios@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-11.3.7.tgz#87478f907634713b7236c77870446a5ca1f35ff1" + integrity sha512-Z/8rseBput49EldX7MogvN6zJlWzZ/4M97s2P+zjS09ZoBU7I0eOKLi0N9wx+95FNBvGQQ/0P62bB9UaFQH2jw== dependencies: - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" execa "^5.0.0" fast-xml-parser "^4.0.12" glob "^7.1.3" ora "^5.4.1" -"@react-native-community/cli-plugin-metro@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-plugin-metro/-/cli-plugin-metro-11.3.6.tgz#2d632c304313435c9ea104086901fbbeba0f1882" - integrity sha512-D97racrPX3069ibyabJNKw9aJpVcaZrkYiEzsEnx50uauQtPDoQ1ELb/5c6CtMhAEGKoZ0B5MS23BbsSZcLs2g== +"@react-native-community/cli-plugin-metro@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-plugin-metro/-/cli-plugin-metro-11.3.7.tgz#2e8a9deb30b40495c5c1347a1837a824400fa00f" + integrity sha512-0WhgoBVGF1f9jXcuagQmtxpwpfP+2LbLZH4qMyo6OtYLWLG13n2uRep+8tdGzfNzl1bIuUTeE9yZSAdnf9LfYQ== dependencies: - "@react-native-community/cli-server-api" "11.3.6" - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-server-api" "11.3.7" + "@react-native-community/cli-tools" "11.3.7" chalk "^4.1.2" execa "^5.0.0" - metro "0.76.7" - metro-config "0.76.7" - metro-core "0.76.7" - metro-react-native-babel-transformer "0.76.7" - metro-resolver "0.76.7" - metro-runtime "0.76.7" + metro "0.76.8" + metro-config "0.76.8" + metro-core "0.76.8" + metro-react-native-babel-transformer "0.76.8" + metro-resolver "0.76.8" + metro-runtime "0.76.8" readline "^1.3.0" -"@react-native-community/cli-server-api@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-server-api/-/cli-server-api-11.3.6.tgz#3a16039518f7f3865f85f8f54b19174448bbcdbb" - integrity sha512-8GUKodPnURGtJ9JKg8yOHIRtWepPciI3ssXVw5jik7+dZ43yN8P5BqCoDaq8e1H1yRer27iiOfT7XVnwk8Dueg== +"@react-native-community/cli-server-api@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-server-api/-/cli-server-api-11.3.7.tgz#2cce54b3331c9c51b9067129c297ab2e9a142216" + integrity sha512-yoFyGdvR3HxCnU6i9vFqKmmSqFzCbnFSnJ29a+5dppgPRetN+d//O8ard/YHqHzToFnXutAFf2neONn23qcJAg== dependencies: - "@react-native-community/cli-debugger-ui" "11.3.6" - "@react-native-community/cli-tools" "11.3.6" + "@react-native-community/cli-debugger-ui" "11.3.7" + "@react-native-community/cli-tools" "11.3.7" compression "^1.7.1" connect "^3.6.5" errorhandler "^1.5.1" @@ -3342,10 +3343,10 @@ serve-static "^1.13.1" ws "^7.5.1" -"@react-native-community/cli-tools@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-tools/-/cli-tools-11.3.6.tgz#ec213b8409917a56e023595f148c84b9cb3ad871" - integrity sha512-JpmUTcDwAGiTzLsfMlIAYpCMSJ9w2Qlf7PU7mZIRyEu61UzEawyw83DkqfbzDPBuRwRnaeN44JX2CP/yTO3ThQ== +"@react-native-community/cli-tools@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-tools/-/cli-tools-11.3.7.tgz#37aa7efc7b4a1b7077d541f1d7bb11a2ab7b6ff2" + integrity sha512-peyhP4TV6Ps1hk+MBHTFaIR1eI3u+OfGBvr5r0wPwo3FAJvldRinMgcB/TcCcOBXVORu7ba1XYjkubPeYcqAyA== dependencies: appdirsjs "^1.2.4" chalk "^4.1.2" @@ -3357,27 +3358,27 @@ semver "^7.5.2" shell-quote "^1.7.3" -"@react-native-community/cli-types@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli-types/-/cli-types-11.3.6.tgz#34012f1d0cb1c4039268828abc07c9c69f2e15be" - integrity sha512-6DxjrMKx5x68N/tCJYVYRKAtlRHbtUVBZrnAvkxbRWFD9v4vhNgsPM0RQm8i2vRugeksnao5mbnRGpS6c0awCw== +"@react-native-community/cli-types@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli-types/-/cli-types-11.3.7.tgz#12fe7cff3da08bd27e11116531b2e001939854b9" + integrity sha512-OhSr/TiDQkXjL5YOs8+hvGSB+HltLn5ZI0+A3DCiMsjUgTTsYh+Z63OtyMpNjrdCEFcg0MpfdU2uxstCS6Dc5g== dependencies: joi "^17.2.1" -"@react-native-community/cli@11.3.6": - version "11.3.6" - resolved "https://registry.yarnpkg.com/@react-native-community/cli/-/cli-11.3.6.tgz#d92618d75229eaf6c0391a6b075684eba5d9819f" - integrity sha512-bdwOIYTBVQ9VK34dsf6t3u6vOUU5lfdhKaAxiAVArjsr7Je88Bgs4sAbsOYsNK3tkE8G77U6wLpekknXcanlww== - dependencies: - "@react-native-community/cli-clean" "11.3.6" - "@react-native-community/cli-config" "11.3.6" - "@react-native-community/cli-debugger-ui" "11.3.6" - "@react-native-community/cli-doctor" "11.3.6" - "@react-native-community/cli-hermes" "11.3.6" - "@react-native-community/cli-plugin-metro" "11.3.6" - "@react-native-community/cli-server-api" "11.3.6" - "@react-native-community/cli-tools" "11.3.6" - "@react-native-community/cli-types" "11.3.6" +"@react-native-community/cli@11.3.7": + version "11.3.7" + resolved "https://registry.yarnpkg.com/@react-native-community/cli/-/cli-11.3.7.tgz#564c0054269d8385fa9d301750b2e56dbb5c0cc9" + integrity sha512-Ou8eDlF+yh2rzXeCTpMPYJ2fuqsusNOhmpYPYNQJQ2h6PvaF30kPomflgRILems+EBBuggRtcT+I+1YH4o/q6w== + dependencies: + "@react-native-community/cli-clean" "11.3.7" + "@react-native-community/cli-config" "11.3.7" + "@react-native-community/cli-debugger-ui" "11.3.7" + "@react-native-community/cli-doctor" "11.3.7" + "@react-native-community/cli-hermes" "11.3.7" + "@react-native-community/cli-plugin-metro" "11.3.7" + "@react-native-community/cli-server-api" "11.3.7" + "@react-native-community/cli-tools" "11.3.7" + "@react-native-community/cli-types" "11.3.7" chalk "^4.1.2" commander "^9.4.1" execa "^5.0.0" @@ -3438,10 +3439,10 @@ resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.72.0.tgz#c82a76a1d86ec0c3907be76f7faf97a32bbed05d" integrity sha512-Im93xRJuHHxb1wniGhBMsxLwcfzdYreSZVQGDoMJgkd6+Iky61LInGEHnQCTN0fKNYF1Dvcofb4uMmE1RQHXHQ== -"@react-native/codegen@^0.72.6": - version "0.72.6" - resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.72.6.tgz#029cf61f82f5c6872f0b2ce58f27c4239a5586c8" - integrity sha512-idTVI1es/oopN0jJT/0jB6nKdvTUKE3757zA5+NPXZTeB46CIRbmmos4XBiAec8ufu9/DigLPbHTYAaMNZJ6Ig== +"@react-native/codegen@^0.72.7": + version "0.72.7" + resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.72.7.tgz#b6832ce631ac63143024ea094a6b5480a780e589" + integrity sha512-O7xNcGeXGbY+VoqBGNlZ3O05gxfATlwE1Q1qQf5E38dK+tXn5BY4u0jaQ9DPjfE8pBba8g/BYI1N44lynidMtg== dependencies: "@babel/parser" "^7.20.0" flow-parser "^0.206.0" @@ -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" @@ -13003,53 +13118,53 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== -metro-babel-transformer@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.76.7.tgz#ba620d64cbaf97d1aa14146d654a3e5d7477fc62" - integrity sha512-bgr2OFn0J4r0qoZcHrwEvccF7g9k3wdgTOgk6gmGHrtlZ1Jn3oCpklW/DfZ9PzHfjY2mQammKTc19g/EFGyOJw== +metro-babel-transformer@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.76.8.tgz#5efd1027353b36b73706164ef09c290dceac096a" + integrity sha512-Hh6PW34Ug/nShlBGxkwQJSgPGAzSJ9FwQXhUImkzdsDgVu6zj5bx258J8cJVSandjNoQ8nbaHK6CaHlnbZKbyA== dependencies: "@babel/core" "^7.20.0" hermes-parser "0.12.0" nullthrows "^1.1.1" -metro-cache-key@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.76.7.tgz#70913f43b92b313096673c37532edd07438cb325" - integrity sha512-0pecoIzwsD/Whn/Qfa+SDMX2YyasV0ndbcgUFx7w1Ct2sLHClujdhQ4ik6mvQmsaOcnGkIyN0zcceMDjC2+BFQ== +metro-cache-key@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.76.8.tgz#8a0a5e991c06f56fcc584acadacb313c312bdc16" + integrity sha512-buKQ5xentPig9G6T37Ww/R/bC+/V1MA5xU/D8zjnhlelsrPG6w6LtHUS61ID3zZcMZqYaELWk5UIadIdDsaaLw== -metro-cache@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.76.7.tgz#e49e51423fa960df4eeff9760d131f03e003a9eb" - integrity sha512-nWBMztrs5RuSxZRI7hgFgob5PhYDmxICh9FF8anm9/ito0u0vpPvRxt7sRu8fyeD2AHdXqE7kX32rWY0LiXgeg== +metro-cache@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.76.8.tgz#296c1c189db2053b89735a8f33dbe82575f53661" + integrity sha512-QBJSJIVNH7Hc/Yo6br/U/qQDUpiUdRgZ2ZBJmvAbmAKp2XDzsapnMwK/3BGj8JNWJF7OLrqrYHsRsukSbUBpvQ== dependencies: - metro-core "0.76.7" + metro-core "0.76.8" rimraf "^3.0.2" -metro-config@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.76.7.tgz#f0fc171707523aa7d3a9311550872136880558c0" - integrity sha512-CFDyNb9bqxZemiChC/gNdXZ7OQkIwmXzkrEXivcXGbgzlt/b2juCv555GWJHyZSlorwnwJfY3uzAFu4A9iRVfg== +metro-config@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.76.8.tgz#20bd5397fcc6096f98d2a813a7cecb38b8af062d" + integrity sha512-SL1lfKB0qGHALcAk2zBqVgQZpazDYvYFGwCK1ikz0S6Y/CM2i2/HwuZN31kpX6z3mqjv/6KvlzaKoTb1otuSAA== dependencies: connect "^3.6.5" cosmiconfig "^5.0.5" jest-validate "^29.2.1" - metro "0.76.7" - metro-cache "0.76.7" - metro-core "0.76.7" - metro-runtime "0.76.7" + metro "0.76.8" + metro-cache "0.76.8" + metro-core "0.76.8" + metro-runtime "0.76.8" -metro-core@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.76.7.tgz#5d2b8bac2cde801dc22666ad7be1336d1f021b61" - integrity sha512-0b8KfrwPmwCMW+1V7ZQPkTy2tsEKZjYG9Pu1PTsu463Z9fxX7WaR0fcHFshv+J1CnQSUTwIGGjbNvj1teKe+pw== +metro-core@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.76.8.tgz#917c8157c63406cb223522835abb8e7c6291dcad" + integrity sha512-sl2QLFI3d1b1XUUGxwzw/KbaXXU/bvFYrSKz6Sg19AdYGWFyzsgZ1VISRIDf+HWm4R/TJXluhWMEkEtZuqi3qA== dependencies: lodash.throttle "^4.1.1" - metro-resolver "0.76.7" + metro-resolver "0.76.8" -metro-file-map@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.76.7.tgz#0f041a4f186ac672f0188180310609c8483ffe89" - integrity sha512-s+zEkTcJ4mOJTgEE2ht4jIo1DZfeWreQR3tpT3gDV/Y/0UQ8aJBTv62dE775z0GLsWZApiblAYZsj7ZE8P06nw== +metro-file-map@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.76.8.tgz#a1db1185b6c316904ba6b53d628e5d1323991d79" + integrity sha512-A/xP1YNEVwO1SUV9/YYo6/Y1MmzhL4ZnVgcJC3VmHp/BYVOXVStzgVbWv2wILe56IIMkfXU+jpXrGKKYhFyHVw== dependencies: anymatch "^3.0.3" debug "^2.2.0" @@ -13066,10 +13181,10 @@ metro-file-map@0.76.7: optionalDependencies: fsevents "^2.3.2" -metro-inspector-proxy@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-inspector-proxy/-/metro-inspector-proxy-0.76.7.tgz#c067df25056e932002a72a4b45cf7b4b749f808e" - integrity sha512-rNZ/6edTl/1qUekAhAbaFjczMphM50/UjtxiKulo6vqvgn/Mjd9hVqDvVYfAMZXqPvlusD88n38UjVYPkruLSg== +metro-inspector-proxy@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-inspector-proxy/-/metro-inspector-proxy-0.76.8.tgz#6b8678a7461b0b42f913a7881cc9319b4d3cddff" + integrity sha512-Us5o5UEd4Smgn1+TfHX4LvVPoWVo9VsVMn4Ldbk0g5CQx3Gu0ygc/ei2AKPGTwsOZmKxJeACj7yMH2kgxQP/iw== dependencies: connect "^3.6.5" debug "^2.2.0" @@ -13077,65 +13192,20 @@ metro-inspector-proxy@0.76.7: ws "^7.5.1" yargs "^17.6.2" -metro-minify-terser@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.76.7.tgz#aefac8bb8b6b3a0fcb5ea0238623cf3e100893ff" - integrity sha512-FQiZGhIxCzhDwK4LxyPMLlq0Tsmla10X7BfNGlYFK0A5IsaVKNJbETyTzhpIwc+YFRT4GkFFwgo0V2N5vxO5HA== +metro-minify-terser@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.76.8.tgz#915ab4d1419257fc6a0b9fa15827b83fe69814bf" + integrity sha512-Orbvg18qXHCrSj1KbaeSDVYRy/gkro2PC7Fy2tDSH1c9RB4aH8tuMOIXnKJE+1SXxBtjWmQ5Yirwkth2DyyEZA== dependencies: terser "^5.15.0" -metro-minify-uglify@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-minify-uglify/-/metro-minify-uglify-0.76.7.tgz#3e0143786718dcaea4e28a724698d4f8ac199a43" - integrity sha512-FuXIU3j2uNcSvQtPrAJjYWHruPiQ+EpE++J9Z+VznQKEHcIxMMoQZAfIF2IpZSrZYfLOjVFyGMvj41jQMxV1Vw== +metro-minify-uglify@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-minify-uglify/-/metro-minify-uglify-0.76.8.tgz#74745045ea2dd29f8783db483b2fce58385ba695" + integrity sha512-6l8/bEvtVaTSuhG1FqS0+Mc8lZ3Bl4RI8SeRIifVLC21eeSDp4CEBUWSGjpFyUDfi6R5dXzYaFnSgMNyfxADiQ== dependencies: uglify-es "^3.1.9" -metro-react-native-babel-preset@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.7.tgz#dfe15c040d0918147a8b0e9f530d558287acbb54" - integrity sha512-R25wq+VOSorAK3hc07NW0SmN8z9S/IR0Us0oGAsBcMZnsgkbOxu77Mduqf+f4is/wnWHc5+9bfiqdLnaMngiVw== - dependencies: - "@babel/core" "^7.20.0" - "@babel/plugin-proposal-async-generator-functions" "^7.0.0" - "@babel/plugin-proposal-class-properties" "^7.18.0" - "@babel/plugin-proposal-export-default-from" "^7.0.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.0" - "@babel/plugin-proposal-numeric-separator" "^7.0.0" - "@babel/plugin-proposal-object-rest-spread" "^7.20.0" - "@babel/plugin-proposal-optional-catch-binding" "^7.0.0" - "@babel/plugin-proposal-optional-chaining" "^7.20.0" - "@babel/plugin-syntax-dynamic-import" "^7.8.0" - "@babel/plugin-syntax-export-default-from" "^7.0.0" - "@babel/plugin-syntax-flow" "^7.18.0" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.0.0" - "@babel/plugin-syntax-optional-chaining" "^7.0.0" - "@babel/plugin-transform-arrow-functions" "^7.0.0" - "@babel/plugin-transform-async-to-generator" "^7.20.0" - "@babel/plugin-transform-block-scoping" "^7.0.0" - "@babel/plugin-transform-classes" "^7.0.0" - "@babel/plugin-transform-computed-properties" "^7.0.0" - "@babel/plugin-transform-destructuring" "^7.20.0" - "@babel/plugin-transform-flow-strip-types" "^7.20.0" - "@babel/plugin-transform-function-name" "^7.0.0" - "@babel/plugin-transform-literals" "^7.0.0" - "@babel/plugin-transform-modules-commonjs" "^7.0.0" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.0.0" - "@babel/plugin-transform-parameters" "^7.0.0" - "@babel/plugin-transform-react-display-name" "^7.0.0" - "@babel/plugin-transform-react-jsx" "^7.0.0" - "@babel/plugin-transform-react-jsx-self" "^7.0.0" - "@babel/plugin-transform-react-jsx-source" "^7.0.0" - "@babel/plugin-transform-runtime" "^7.0.0" - "@babel/plugin-transform-shorthand-properties" "^7.0.0" - "@babel/plugin-transform-spread" "^7.0.0" - "@babel/plugin-transform-sticky-regex" "^7.0.0" - "@babel/plugin-transform-typescript" "^7.5.0" - "@babel/plugin-transform-unicode-regex" "^7.0.0" - "@babel/template" "^7.0.0" - babel-plugin-transform-flow-enums "^0.0.2" - react-refresh "^0.4.0" - metro-react-native-babel-preset@0.76.8: version "0.76.8" resolved "https://registry.yarnpkg.com/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.8.tgz#7476efae14363cbdfeeec403b4f01d7348e6c048" @@ -13225,29 +13295,21 @@ metro-react-native-babel-preset@^0.73.7: "@babel/template" "^7.0.0" react-refresh "^0.4.0" -metro-react-native-babel-transformer@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.76.7.tgz#ccc7c25b49ee8a1860aafdbf48bfa5441d206f8f" - integrity sha512-W6lW3J7y/05ph3c2p3KKJNhH0IdyxdOCbQ5it7aM2MAl0SM4wgKjaV6EYv9b3rHklpV6K3qMH37UKVcjMooWiA== +metro-react-native-babel-transformer@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.76.8.tgz#c3a98e1f4cd5faf1e21eba8e004b94a90c4db69b" + integrity sha512-3h+LfS1WG1PAzhq8QF0kfXjxuXetbY/lgz8vYMQhgrMMp17WM1DNJD0gjx8tOGYbpbBC1qesJ45KMS4o5TA73A== dependencies: "@babel/core" "^7.20.0" babel-preset-fbjs "^3.4.0" hermes-parser "0.12.0" - metro-react-native-babel-preset "0.76.7" + metro-react-native-babel-preset "0.76.8" nullthrows "^1.1.1" -metro-resolver@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.76.7.tgz#f00ebead64e451c060f30926ecbf4f797588df52" - integrity sha512-pC0Wgq29HHIHrwz23xxiNgylhI8Rq1V01kQaJ9Kz11zWrIdlrH0ZdnJ7GC6qA0ErROG+cXmJ0rJb8/SW1Zp2IA== - -metro-runtime@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.76.7.tgz#4d75f2dbbcd19a4f01e0d89494e140b0ba8247e4" - integrity sha512-MuWHubQHymUWBpZLwuKZQgA/qbb35WnDAKPo83rk7JRLIFPvzXSvFaC18voPuzJBt1V98lKQIonh6MiC9gd8Ug== - dependencies: - "@babel/runtime" "^7.0.0" - react-refresh "^0.4.0" +metro-resolver@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.76.8.tgz#0862755b9b84e26853978322464fb37c6fdad76d" + integrity sha512-KccOqc10vrzS7ZhG2NSnL2dh3uVydarB7nOhjreQ7C4zyWuiW9XpLC4h47KtGQv3Rnv/NDLJYeDqaJ4/+140HQ== metro-runtime@0.76.8: version "0.76.8" @@ -13257,20 +13319,6 @@ metro-runtime@0.76.8: "@babel/runtime" "^7.0.0" react-refresh "^0.4.0" -metro-source-map@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.76.7.tgz#9a4aa3a35e1e8ffde9a74cd7ab5f49d9d4a4da14" - integrity sha512-Prhx7PeRV1LuogT0Kn5VjCuFu9fVD68eefntdWabrksmNY6mXK8pRqzvNJOhTojh6nek+RxBzZeD6MIOOyXS6w== - dependencies: - "@babel/traverse" "^7.20.0" - "@babel/types" "^7.20.0" - invariant "^2.2.4" - metro-symbolicate "0.76.7" - nullthrows "^1.1.1" - ob1 "0.76.7" - source-map "^0.5.6" - vlq "^1.0.0" - metro-source-map@0.76.8: version "0.76.8" resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.76.8.tgz#f085800152a6ba0b41ca26833874d31ec36c5a53" @@ -13285,18 +13333,6 @@ metro-source-map@0.76.8: source-map "^0.5.6" vlq "^1.0.0" -metro-symbolicate@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.76.7.tgz#1720e6b4ce5676935d7a8a440f25d3f16638e87a" - integrity sha512-p0zWEME5qLSL1bJb93iq+zt5fz3sfVn9xFYzca1TJIpY5MommEaS64Va87lp56O0sfEIvh4307Oaf/ZzRjuLiQ== - dependencies: - invariant "^2.2.4" - metro-source-map "0.76.7" - nullthrows "^1.1.1" - source-map "^0.5.6" - through2 "^2.0.1" - vlq "^1.0.0" - metro-symbolicate@0.76.8: version "0.76.8" resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.76.8.tgz#f102ac1a306d51597ecc8fdf961c0a88bddbca03" @@ -13309,10 +13345,10 @@ metro-symbolicate@0.76.8: through2 "^2.0.1" vlq "^1.0.0" -metro-transform-plugins@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.76.7.tgz#5d5f75371706fbf5166288e43ffd36b5e5bd05bc" - integrity sha512-iSmnjVApbdivjuzb88Orb0JHvcEt5veVyFAzxiS5h0QB+zV79w6JCSqZlHCrbNOkOKBED//LqtKbFVakxllnNg== +metro-transform-plugins@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.76.8.tgz#d77c28a6547a8e3b72250f740fcfbd7f5408f8ba" + integrity sha512-PlkGTQNqS51Bx4vuufSQCdSn2R2rt7korzngo+b5GCkeX5pjinPjnO2kNhQ8l+5bO0iUD/WZ9nsM2PGGKIkWFA== dependencies: "@babel/core" "^7.20.0" "@babel/generator" "^7.20.0" @@ -13320,28 +13356,28 @@ metro-transform-plugins@0.76.7: "@babel/traverse" "^7.20.0" nullthrows "^1.1.1" -metro-transform-worker@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.76.7.tgz#b842d5a542f1806cca401633fc002559b3e3d668" - integrity sha512-cGvELqFMVk9XTC15CMVzrCzcO6sO1lURfcbgjuuPdzaWuD11eEyocvkTX0DPiRjsvgAmicz4XYxVzgYl3MykDw== +metro-transform-worker@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.76.8.tgz#b9012a196cee205170d0c899b8b175b9305acdea" + integrity sha512-mE1fxVAnJKmwwJyDtThildxxos9+DGs9+vTrx2ktSFMEVTtXS/bIv2W6hux1pqivqAfyJpTeACXHk5u2DgGvIQ== dependencies: "@babel/core" "^7.20.0" "@babel/generator" "^7.20.0" "@babel/parser" "^7.20.0" "@babel/types" "^7.20.0" babel-preset-fbjs "^3.4.0" - metro "0.76.7" - metro-babel-transformer "0.76.7" - metro-cache "0.76.7" - metro-cache-key "0.76.7" - metro-source-map "0.76.7" - metro-transform-plugins "0.76.7" + metro "0.76.8" + metro-babel-transformer "0.76.8" + metro-cache "0.76.8" + metro-cache-key "0.76.8" + metro-source-map "0.76.8" + metro-transform-plugins "0.76.8" nullthrows "^1.1.1" -metro@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/metro/-/metro-0.76.7.tgz#4885917ad28738c7d1e556630e0155f687336230" - integrity sha512-67ZGwDeumEPnrHI+pEDSKH2cx+C81Gx8Mn5qOtmGUPm/Up9Y4I1H2dJZ5n17MWzejNo0XAvPh0QL0CrlJEODVQ== +metro@0.76.8: + version "0.76.8" + resolved "https://registry.yarnpkg.com/metro/-/metro-0.76.8.tgz#ba526808b99977ca3f9ac5a7432fd02a340d13a6" + integrity sha512-oQA3gLzrrYv3qKtuWArMgHPbHu8odZOD9AoavrqSFllkPgOtmkBvNNDLCELqv5SjBfqjISNffypg+5UGG3y0pg== dependencies: "@babel/code-frame" "^7.0.0" "@babel/core" "^7.20.0" @@ -13365,22 +13401,22 @@ metro@0.76.7: jest-worker "^27.2.0" jsc-safe-url "^0.2.2" lodash.throttle "^4.1.1" - metro-babel-transformer "0.76.7" - metro-cache "0.76.7" - metro-cache-key "0.76.7" - metro-config "0.76.7" - metro-core "0.76.7" - metro-file-map "0.76.7" - metro-inspector-proxy "0.76.7" - metro-minify-terser "0.76.7" - metro-minify-uglify "0.76.7" - metro-react-native-babel-preset "0.76.7" - metro-resolver "0.76.7" - metro-runtime "0.76.7" - metro-source-map "0.76.7" - metro-symbolicate "0.76.7" - metro-transform-plugins "0.76.7" - metro-transform-worker "0.76.7" + metro-babel-transformer "0.76.8" + metro-cache "0.76.8" + metro-cache-key "0.76.8" + metro-config "0.76.8" + metro-core "0.76.8" + metro-file-map "0.76.8" + metro-inspector-proxy "0.76.8" + metro-minify-terser "0.76.8" + metro-minify-uglify "0.76.8" + metro-react-native-babel-preset "0.76.8" + metro-resolver "0.76.8" + metro-runtime "0.76.8" + metro-source-map "0.76.8" + metro-symbolicate "0.76.8" + metro-transform-plugins "0.76.8" + metro-transform-worker "0.76.8" mime-types "^2.1.27" node-fetch "^2.2.0" nullthrows "^1.1.1" @@ -13857,11 +13893,6 @@ nwsapi@^2.2.0, nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== -ob1@0.76.7: - version "0.76.7" - resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.76.7.tgz#95b68fadafd47e7a6a0ad64cf80f3140dd6d1124" - integrity sha512-BQdRtxxoUNfSoZxqeBGOyuT9nEYSn18xZHwGMb0mMVpn2NBcYbnyKY4BK2LIHRgw33CBGlUmE+KMaNvyTpLLtQ== - ob1@0.76.8: version "0.76.8" resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.76.8.tgz#ac4c459465b1c0e2c29aaa527e09fc463d3ffec8" @@ -15931,17 +15962,17 @@ react-native-web@~0.19.6: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native@0.72.4: - version "0.72.4" - resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.4.tgz#97b57e22e4d7657eaf4d1f62a678511fcf9bdda7" - integrity sha512-+vrObi0wZR+NeqL09KihAAdVlQ9IdplwznJWtYrjnQ4UbCW6rkzZJebRsugwUneSOKNFaHFEo1uKU89HsgtYBg== +react-native@0.72.5: + version "0.72.5" + resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357" + integrity sha512-oIewslu5DBwOmo7x5rdzZlZXCqDIna0R4dUwVpfmVteORYLr4yaZo5wQnMeR+H7x54GaMhmgeqp0ZpULtulJFg== dependencies: "@jest/create-cache-key-function" "^29.2.1" - "@react-native-community/cli" "11.3.6" - "@react-native-community/cli-platform-android" "11.3.6" - "@react-native-community/cli-platform-ios" "11.3.6" + "@react-native-community/cli" "11.3.7" + "@react-native-community/cli-platform-android" "11.3.7" + "@react-native-community/cli-platform-ios" "11.3.7" "@react-native/assets-registry" "^0.72.0" - "@react-native/codegen" "^0.72.6" + "@react-native/codegen" "^0.72.7" "@react-native/gradle-plugin" "^0.72.11" "@react-native/js-polyfills" "^0.72.1" "@react-native/normalize-colors" "^0.72.0" @@ -16788,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== @@ -17974,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== @@ -19275,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" |