diff --git a/.detoxrc.js b/.detoxrc.js index 1e41165dac..9066204308 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/README.md b/README.md index 08e7aba28f..4f7d00ebba 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Get the app itself: This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto). -There is a small about of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. +There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. -The [Build Instructions](./docs/builds.md) are a good place to get started with the app itself. +The [Build Instructions](./docs/build.md) are a good place to get started with the app itself. The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at: diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 6613f54d07..482df6ef63 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 similarity index 100% rename from __e2e__/tests/shell.test.ts rename to __e2e__/tests/shell.test.skip.ts diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 3055a9ef6d..8bb52ed407 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 a1477a8aea..82d0d5a0ca 100644 --- a/app.config.js +++ b/app.config.js @@ -19,7 +19,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, ios: { - buildNumber: '1', + buildNumber: '2', supportsTablet: false, bundleIdentifier: 'xyz.blueskyweb.app', config: { @@ -43,7 +43,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, android: { - versionCode: 40, + versionCode: 41, adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', backgroundColor: '#ffffff', diff --git a/jest/dev-infra/_common.sh b/jest/dev-infra/_common.sh new file mode 100755 index 0000000000..0d66653c87 --- /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 " + 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 0000000000..3d582c18b3 --- /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 0000000000..cc083491a5 --- /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 0000000000..c2b0c75ff1 --- /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/test-pds.ts b/jest/test-pds.ts index 37ad824a09..bc3692600a 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 } +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 { 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 = {} 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 { diff --git a/package.json b/package.json index 90192f74e8..6974482844 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.51.0", + "version": "1.52.0", "private": true, "scripts": { "prepare": "is-ci || husky install", @@ -18,7 +18,7 @@ "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", @@ -188,7 +188,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", @@ -215,7 +215,8 @@ "webpack-dev-server": "^4.11.1" }, "resolutions": { - "@types/react": "^18" + "@types/react": "^18", + "**/zeed-dom": "estrattonbailey/zeed-dom#publish" }, "jest": { "preset": "jest-expo/ios", diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 3c27d86397..106d2ca319 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined { export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) - if (!labelDomain) { - return true - } + let urip try { - const urip = new URL(uri) - return labelDomain !== urip.hostname + urip = new URL(uri) } catch { return true } + + if (urip.hostname === 'bsky.app') { + // if this is a link to internal content, + // warn if it represents itself as a URL to another app + if ( + labelDomain && + labelDomain !== 'bsky.app' && + isPossiblyAUrl(labelDomain) + ) { + return true + } + return false + } else { + // if this is a link to external content, + // warn if the label doesnt match the target + if (!labelDomain) { + return true + } + return labelDomain !== urip.hostname + } } function labelToDomain(label: string): string | undefined { diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 07079b5afa..e1e8af5099 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -5,6 +5,7 @@ import { moderateProfile, } from '@atproto/api' import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' const MAX_SYNC_PAGES = 10 const SYNC_TTL = 60e3 * 10 // 10 minutes @@ -56,7 +57,7 @@ export class MyFollowsCache { * Syncs a subset of the user's follows * for performance reasons, caps out at 1000 follows */ - async syncIfNeeded() { + syncIfNeeded = bundleAsync(async () => { if (this.lastSync > Date.now() - SYNC_TTL) { return } @@ -81,7 +82,7 @@ export class MyFollowsCache { } this.lastSync = Date.now() - } + }) getFollowState(did: string): FollowState { if (typeof this.byDid[did] === 'undefined') { diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 8a7a4c851e..186e61cf6b 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -25,13 +25,13 @@ export class MeModel { savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache - invites: ComAtprotoServerDefs.InviteCode[] | null = [] + invites: ComAtprotoServerDefs.InviteCode[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() lastNotifsUpdate = Date.now() get invitesAvailable() { - return this.invites?.filter(isInviteAvailable).length || null + return this.invites.filter(isInviteAvailable).length } constructor(public rootStore: RootStoreModel) { @@ -180,9 +180,7 @@ export class MeModel { } catch (e) { this.rootStore.log.error('Failed to fetch user invite codes', e) } - if (this.invites) { - await this.rootStore.invitedUsers.fetch(this.invites) - } + await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index 10aef0ff48..c26f9b87c6 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -166,7 +166,7 @@ export class ImageModel implements Omit { async crop() { try { // NOTE - // on ios, react-native-image-cropper gives really bad quality + // on ios, react-native-image-crop-picker gives really bad quality // without specifying width and height. on android, however, the // crop stretches incorrectly if you do specify it. these are // both separate bugs in the library. we deal with that by diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts new file mode 100644 index 0000000000..ec0eca40d2 --- /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 60dbf5d880..c650de0040 100644 --- a/src/state/models/ui/reminders.ts +++ b/src/state/models/ui/reminders.ts @@ -3,10 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {toHashCode} from 'lib/strings/helpers' -const DAY = 60e3 * 24 * 1 // 1 day (ms) - export class Reminders { - lastEmailConfirm: Date = new Date() + lastEmailConfirm: Date | null = null constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -45,6 +43,10 @@ export class Reminders { if (this.rootStore.onboarding.isActive) { return false } + // only prompt once + if (this.lastEmailConfirm) { + return false + } const today = new Date() // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when @@ -53,9 +55,7 @@ export class Reminders { if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { return false } - // only ask once a day at most, but because of the bucketing - // this will be more like weekly - return Number(today) - Number(this.lastEmailConfirm) > DAY + return true } setEmailConfirmationRequested() { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 5218df2ac5..286e2762da 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -219,16 +219,19 @@ export const TextInput = forwardRef(function TextInputImpl( const textDecorated = useMemo(() => { let i = 0 - return Array.from(richtext.segments()).map(segment => ( - - {segment.text} - - )) + return Array.from(richtext.segments()).map(segment => { + const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) + return ( + + {segment.text} + + ) + }) }, [richtext, pal.link, pal.text]) return ( 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 553a4a2e72..513524864e 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,9 +1,8 @@ -import React, {MutableRefObject, useState} from 'react' +import React, {useState} from 'react' import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' import Animated, { - measure, runOnJS, useAnimatedRef, useAnimatedStyle, @@ -12,11 +11,7 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated' -import { - GestureDetector, - Gesture, - GestureType, -} from 'react-native-gesture-handler' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import { createTransform, @@ -40,7 +35,6 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (isZoomed: boolean) => void - pinchGestureRef: MutableRefObject isScrollViewBeingDragged: boolean } const ImageItem = ({ @@ -48,7 +42,6 @@ const ImageItem = ({ onZoom, onRequestClose, isScrollViewBeingDragged, - pinchGestureRef, }: Props) => { const [isScaled, setIsScaled] = useState(false) const [isLoaded, setIsLoaded] = useState(false) @@ -140,28 +133,7 @@ const ImageItem = ({ return [dx, dy] } - // This is a hack. - // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. - // However, there is no great reliable way to coordinate this yet in RGNH. - // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. - const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { - if (isScrollViewBeingDragged) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - const measurement = measure(containerRef) - if (!measurement || measurement.pageX !== 0) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - // Fail this "fake" gesture so that the gestures after it can proceed. - manager.fail() - }) - const pinch = Gesture.Pinch() - .withRef(pinchGestureRef) .onStart(e => { pinchOrigin.value = { x: e.focalX - SCREEN.width / 2, @@ -318,22 +290,26 @@ const ImageItem = ({ } }) + const composedGesture = isScrollViewBeingDragged + ? // If the parent is not at rest, provide a no-op gesture. + Gesture.Manual() + : Gesture.Exclusive( + dismissSwipePan, + Gesture.Simultaneous(pinch, pan), + doubleTap, + ) + const isLoading = !isLoaded || !imageDimensions return ( {isLoading && ( )} - + void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject isScrollViewBeingDragged: boolean } @@ -49,44 +50,42 @@ const AnimatedImage = Animated.createAnimatedComponent(Image) let lastTapTS: number | null = null const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const scrollViewRef = useRef(null) + const scrollViewRef = useAnimatedRef() + const translationY = useSharedValue(0) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const [scrollValueY] = useState(() => new Animated.Value(0)) - const maxScrollViewZoom = MAX_SCALE / (scale || 1) + const maxZoomScale = imageDimensions + ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + : 1 - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.5, 1, 0.5], + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + translationY.value, + [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + [0.5, 1, 0.5], + ), + } }) - const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - const onScrollEndDrag = useCallback( - ({nativeEvent}: NativeSyntheticEvent) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const currentScaled = nativeEvent?.zoomScale > 1 - - onZoom(currentScaled) - setScaled(currentScaled) - - if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - onRequestClose() + const scrollHandler = useAnimatedScrollHandler({ + onScroll(e) { + translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y + }, + onEndDrag(e) { + const velocityY = e.velocity?.y ?? 0 + const nextIsScaled = e.zoomScale > 1 + runOnJS(handleZoom)(nextIsScaled) + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { + runOnJS(onRequestClose)() } }, - [onRequestClose, onZoom], - ) + }) - const onScroll = ({nativeEvent}: NativeSyntheticEvent) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 - - if (nativeEvent?.zoomScale > 1) { - return - } - - scrollValueY.setValue(offsetY) + function handleZoom(nextIsScaled: boolean) { + onZoom(nextIsScaled) + setScaled(nextIsScaled) } const handleDoubleTap = useCallback( @@ -121,23 +120,21 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { lastTapTS = nowTS } }, - [imageDimensions, scaled], + [imageDimensions, scaled, scrollViewRef], ) return ( - + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && } { accessibilityLabel={imageSrc.alt} accessibilityHint=""> setLoaded(true)} /> - + ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, - }, imageScrollContainer: { - height: SCREEN_HEIGHT, + height: SCREEN.height, + }, + listItem: { + width: SCREEN.width, + height: SCREEN.height, + }, + image: { + width: SCREEN.width, + height: SCREEN.height, }, }) @@ -191,7 +194,7 @@ const getZoomRectAfterDoubleTap = ( const zoom = Math.max( imageAspect / screenAspect, screenAspect / imageAspect, - MIN_ZOOM, + MIN_DOUBLE_TAP_SCALE, ) // Unlike in the Android version, we don't constrain the *max* zoom level here. // Instead, this is done in the ScrollView props so that it constraints pinch too. @@ -253,61 +256,4 @@ const getZoomRectAfterDoubleTap = ( } } -const getImageStyles = ( - image: ImageDimensions | null, - translate: {readonly x: number; readonly y: number} | undefined, - scale?: number, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - const transform = [] - if (translate) { - transform.push({translateX: translate.x}) - transform.push({translateY: translate.y}) - } - if (scale) { - // @ts-ignore TODO - is scale incorrect? might need to remove -prf - transform.push({scale}, {perspective: new Animated.Value(1000)}) - } - return { - width: image.width, - height: image.height, - transform, - } -} - -const getImageTransform = ( - image: ImageDimensions | null, - screen: ImageDimensions, -) => { - if (!image?.width || !image?.height) { - return [] as const - } - - const wScale = screen.width / image.width - const hScale = screen.height / image.height - const scale = Math.min(wScale, hScale) - const {x, y} = getImageTranslate(image, screen) - - return [{x, y}, scale] as const -} - -const getImageTranslate = ( - image: ImageDimensions, - screen: ImageDimensions, -): {x: number; y: number} => { - const getTranslateForAxis = (axis: 'x' | 'y'): number => { - const imageSize = axis === 'x' ? image.width : image.height - const screenSize = axis === 'x' ? screen.width : screen.height - - return (screenSize - imageSize) / 2 - } - - return { - x: getTranslateForAxis('x'), - y: getTranslateForAxis('y'), - } -} - export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 898b00c788..35be96e46c 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,15 +1,13 @@ // default implementation fallback for web -import React, {MutableRefObject} from 'react' +import React from 'react' import {View} from 'react-native' -import {GestureType} from 'react-native-gesture-handler' import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject isScrollViewBeingDragged: boolean } diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts index 7f0851af3b..cb46fd0d9c 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => { // eslint-disable-next-line @typescript-eslint/no-shadow const getImageDimensions = (image: ImageSource): Promise => { return new Promise(resolve => { - if (typeof image === 'number') { - const cacheKey = `${image}` - let imageDimensions = imageDimensionsCache.get(cacheKey) - - if (!imageDimensions) { - const {width, height} = Image.resolveAssetSource(image) - imageDimensions = {width, height} - imageDimensionsCache.set(cacheKey, imageDimensions) - } - - resolve(imageDimensions) - - return - } - - // @ts-ignore if (image.uri) { const source = image as ImageURISource - const cacheKey = source.uri as string - const imageDimensions = imageDimensionsCache.get(cacheKey) - if (imageDimensions) { resolve(imageDimensions) } else { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index bc2a8a4480..78d16f8a62 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -8,121 +8,60 @@ // Original code copied and simplified from the link below as the codebase is currently not maintained: // https://github.com/jobtoday/react-native-image-viewing -import React, { - ComponentType, - createRef, - useCallback, - useRef, - useMemo, - useState, -} from 'react' -import { - Animated, - Dimensions, - NativeSyntheticEvent, - NativeScrollEvent, - StyleSheet, - View, - VirtualizedList, - ModalProps, - Platform, -} from 'react-native' -import {ModalsContainer} from '../../modals/Modal' +import React, {ComponentType, useCallback, useMemo, useState} from 'react' +import {StyleSheet, View, Platform} from 'react-native' import ImageItem from './components/ImageItem/ImageItem' import ImageDefaultHeader from './components/ImageDefaultHeader' import {ImageSource} from './@types' -import {ScrollView, GestureType} from 'react-native-gesture-handler' +import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import PagerView from 'react-native-pager-view' type Props = { images: ImageSource[] - keyExtractor?: (imageSrc: ImageSource, index: number) => string - imageIndex: number + initialImageIndex: number visible: boolean onRequestClose: () => void - presentationStyle?: ModalProps['presentationStyle'] - animationType?: ModalProps['animationType'] backgroundColor?: string HeaderComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}> } const DEFAULT_BG_COLOR = '#000' -const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const INITIAL_POSITION = {x: 0, y: 0} -const ANIMATION_CONFIG = { - duration: 200, - useNativeDriver: true, -} function ImageViewing({ images, - keyExtractor, - imageIndex, + initialImageIndex, visible, onRequestClose, backgroundColor = DEFAULT_BG_COLOR, HeaderComponent, FooterComponent, }: Props) { - const imageList = useRef>(null) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) - const [opacity, setOpacity] = useState(1) - const [currentImageIndex, setImageIndex] = useState(imageIndex) - const [headerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) - const [footerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) + const [imageIndex, setImageIndex] = useState(initialImageIndex) - const toggleBarsVisible = (isVisible: boolean) => { - if (isVisible) { - Animated.parallel([ - Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - ]).start() - } else { - Animated.parallel([ - Animated.timing(headerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: -300, - }), - Animated.timing(footerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: 300, - }), - ]).start() - } - } - - const onRequestCloseEnhanced = () => { - setOpacity(0) - onRequestClose() - setTimeout(() => setOpacity(1), 0) - } - - const onScroll = (event: NativeSyntheticEvent) => { - const { - nativeEvent: { - contentOffset: {x: scrollX}, + const animatedHeaderStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: withClampedSpring(isScaled ? -300 : 0), }, - } = event + ], + })) + const animatedFooterStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: withClampedSpring(isScaled ? 300 : 0), + }, + ], + })) - if (SCREEN.width) { - const nextIndex = Math.round(scrollX / SCREEN.width) - setImageIndex(nextIndex < 0 ? 0 : nextIndex) - } - } - - const onZoom = (nextIsScaled: boolean) => { - toggleBarsVisible(!nextIsScaled) - setIsScaled(false) - } + const onZoom = useCallback((nextIsScaled: boolean) => { + setIsScaled(nextIsScaled) + }, []) const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -131,100 +70,53 @@ function ImageViewing({ return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area }, []) - const onLayout = useCallback(() => { - if (imageIndex) { - imageList.current?.scrollToIndex({index: imageIndex, animated: false}) - } - }, [imageList, imageIndex]) - - // This is a hack. - // RNGH doesn't have an easy way to express that pinch of individual items - // should "steal" all pinches from the scroll view. So we're keeping a ref - // to all pinch gestures so that we may give them to . - const [pinchGestureRefs] = useState(new Map()) - for (let imageSrc of images) { - if (!pinchGestureRefs.get(imageSrc)) { - pinchGestureRefs.set(imageSrc, createRef()) - } - } - if (!visible) { return null } - const headerTransform = headerTranslate.getTranslateTransform() - const footerTransform = footerTranslate.getTranslateTransform() return ( - - - + + {typeof HeaderComponent !== 'undefined' ? ( React.createElement(HeaderComponent, { - imageIndex: currentImageIndex, + imageIndex, }) ) : ( - + )} - images[index]} - getItemCount={() => images.length} - getItemLayout={(_, index) => ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, - index, - })} - renderItem={({item: imageSrc}) => ( - - )} - renderScrollComponent={props => ( - - )} - onScrollBeginDrag={() => { - setIsDragging(true) - }} - onScrollEndDrag={() => { - setIsDragging(false) - }} - onMomentumScrollEnd={e => { + { + setImageIndex(e.nativeEvent.position) setIsScaled(false) - onScroll(e) }} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } - /> + onPageScrollStateChanged={e => { + setIsDragging(e.nativeEvent.pageScrollState !== 'idle') + }} + overdrag={true} + style={styles.pager}> + {images.map(imageSrc => ( + + + + ))} + {typeof FooterComponent !== 'undefined' && ( - + {React.createElement(FooterComponent, { - imageIndex: currentImageIndex, + imageIndex, })} )} @@ -236,11 +128,18 @@ function ImageViewing({ const styles = StyleSheet.create({ screen: { position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, }, container: { flex: 1, backgroundColor: '#000', }, + pager: { + flex: 1, + }, header: { position: 'absolute', width: '100%', @@ -257,7 +156,12 @@ const styles = StyleSheet.create({ }) const EnhancedImageViewing = (props: Props) => ( - + ) +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true}) +} + export default EnhancedImageViewing diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index ad66dce327..92c30f4917 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() { return ( ({...img}))} - imageIndex={opts.index} + initialImageIndex={opts.index} visible onRequestClose={onClose} FooterComponent={LightboxFooter} diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index c92dabdcac..0125705569 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 ( - - - - - - {stage === Stages.InputEmail ? 'Change Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} - {stage === Stages.Done ? 'Email Updated' : ''} - - - - - {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. - - )} + + + + + {stage === Stages.InputEmail ? 'Change Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} + {stage === Stages.Done ? 'Email Updated' : ''} + - {stage === Stages.InputEmail && ( - - )} - {stage === Stages.ConfirmCode && ( - + + {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. + )} + - {error ? ( - - ) : undefined} + {stage === Stages.InputEmail && ( + + )} + {stage === Stages.ConfirmCode && ( + + )} - - {isProcessing ? ( - - - - ) : ( - - {stage === Stages.InputEmail && ( -