Merge remote-tracking branch 'origin' into eric/app-864-integrate-post-tags-into-app
* origin: (21 commits) resolve fork of zeed-dom (#1663) Port remaining lightbox code to Reanimated (#1669) Update testrunner to use new dev-env [WIP] (#1575) Fix MobX crash for Android lightbox (#1668) Change lightbox to use Pager (#1666) make empty feed required (#1667) Only warn on links to bsky.app if it represents itself as another url (#1662) Fix keyboard double pad issue in email change & verify modals (#1664) Don't highlight tags in composer yet (#1665) Fix: fetch follows on desktop search for typeahead (#1660) Remove duplicate modal container (#1661) Drive-by lightbox refactors (#1659) Only prompt users once to verify email (according to local storage) close #1657 (#1658) Revert "Fix invite codes flash on desktop, use loading placeholder (#1591)" (#1656) Refactor iOS lightbox to Reanimated (#1645) Bump package.json to 1.52 Remove unnecessary opacity logic (#1646) Fix typo in image.ts (#1638) fix typo README.md (#1631) Typo fix in README.md: "small about" -> "small amount" (#1639) ...
This commit is contained in:
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
|||||||
simulator: {
|
simulator: {
|
||||||
type: 'ios.simulator',
|
type: 'ios.simulator',
|
||||||
device: {
|
device: {
|
||||||
type: 'iPhone 15',
|
type: 'iPhone 15 Pro',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
attached: {
|
attached: {
|
||||||
|
|||||||
@@ -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).
|
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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -502,6 +502,9 @@ async function main() {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// flush caches
|
||||||
|
await server.mocker.testNet.processAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Ready')
|
console.log('Ready')
|
||||||
|
|||||||
@@ -27,6 +27,42 @@ describe('linkRequiresWarning', () => {
|
|||||||
['http://site.pages', 'http://site.pages.dev', true],
|
['http://site.pages', 'http://site.pages.dev', true],
|
||||||
['http://site.pages.dev', 'site.pages', true],
|
['http://site.pages.dev', 'site.pages', true],
|
||||||
['http://site.pages', 'site.pages.dev', 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
|
// bad uri inputs, default to true
|
||||||
['', '', true],
|
['', '', true],
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ module.exports = function () {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
buildNumber: '1',
|
buildNumber: '2',
|
||||||
supportsTablet: false,
|
supportsTablet: false,
|
||||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||||
config: {
|
config: {
|
||||||
@@ -43,7 +43,7 @@ module.exports = function () {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
versionCode: 40,
|
versionCode: 41,
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
foregroundImage: './assets/adaptive-icon.png',
|
foregroundImage: './assets/adaptive-icon.png',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
|
|||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
get_container_id() {
|
||||||
|
local compose_file=$1
|
||||||
|
local service=$2
|
||||||
|
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||||
|
echo "usage: get_container_id <compose_file> <service>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f $compose_file ps --format json --status running \
|
||||||
|
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exports all environment variables
|
||||||
|
export_env() {
|
||||||
|
export_pg_env
|
||||||
|
export_redis_env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exports postgres environment variables
|
||||||
|
export_pg_env() {
|
||||||
|
# Based on creds in compose.yaml
|
||||||
|
export PGPORT=5433
|
||||||
|
export PGHOST=localhost
|
||||||
|
export PGUSER=pg
|
||||||
|
export PGPASSWORD=password
|
||||||
|
export PGDATABASE=postgres
|
||||||
|
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exports redis environment variables
|
||||||
|
export_redis_env() {
|
||||||
|
export REDIS_HOST="127.0.0.1:6380"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main entry point
|
||||||
|
main() {
|
||||||
|
# Expect a SERVICES env var to be set with the docker service names
|
||||||
|
local services=${SERVICES}
|
||||||
|
|
||||||
|
dir=$(dirname $0)
|
||||||
|
compose_file="${dir}/docker-compose.yaml"
|
||||||
|
|
||||||
|
# whether this particular script started the container(s)
|
||||||
|
started_container=false
|
||||||
|
|
||||||
|
# trap SIGINT and performs cleanup as necessary, i.e.
|
||||||
|
# taking down containers if this script started them
|
||||||
|
trap "on_sigint ${services}" INT
|
||||||
|
on_sigint() {
|
||||||
|
local services=$@
|
||||||
|
echo # newline
|
||||||
|
if $started_container; then
|
||||||
|
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||||
|
fi
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
# check if all services are running already
|
||||||
|
not_running=false
|
||||||
|
for service in $services; do
|
||||||
|
container_id=$(get_container_id $compose_file $service)
|
||||||
|
if [ -z $container_id ]; then
|
||||||
|
not_running=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# if any are missing, recreate all services
|
||||||
|
if $not_running; then
|
||||||
|
docker compose -f $compose_file up --wait --force-recreate ${services}
|
||||||
|
started_container=true
|
||||||
|
else
|
||||||
|
echo "all services ${services} are already running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# setup environment variables and run args
|
||||||
|
export_env
|
||||||
|
"$@"
|
||||||
|
# save return code for later
|
||||||
|
code=$?
|
||||||
|
|
||||||
|
# performs cleanup as necessary, i.e. taking down containers
|
||||||
|
# if this script started them
|
||||||
|
echo # newline
|
||||||
|
if $started_container; then
|
||||||
|
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit ${code}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
# An ephermerally-stored postgres database for single-use test runs
|
||||||
|
db_test: &db_test
|
||||||
|
image: postgres:14.4-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=pg
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
ports:
|
||||||
|
- '5433:5432'
|
||||||
|
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||||
|
healthcheck:
|
||||||
|
test: 'pg_isready -U pg'
|
||||||
|
interval: 500ms
|
||||||
|
timeout: 10s
|
||||||
|
retries: 20
|
||||||
|
# A persistently-stored postgres database
|
||||||
|
db:
|
||||||
|
<<: *db_test
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
volumes:
|
||||||
|
- atp_db:/var/lib/postgresql/data
|
||||||
|
# An ephermerally-stored redis cache for single-use test runs
|
||||||
|
redis_test: &redis_test
|
||||||
|
image: redis:7.0-alpine
|
||||||
|
ports:
|
||||||
|
- '6380:6379'
|
||||||
|
# Healthcheck ensures redis is queryable when `docker-compose up --wait` completes
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', '[ "$$(redis-cli ping)" = "PONG" ]']
|
||||||
|
interval: 500ms
|
||||||
|
timeout: 10s
|
||||||
|
retries: 20
|
||||||
|
# A persistently-stored redis cache
|
||||||
|
redis:
|
||||||
|
<<: *redis_test
|
||||||
|
command: redis-server --save 60 1 --loglevel warning
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
volumes:
|
||||||
|
- atp_redis:/data
|
||||||
|
volumes:
|
||||||
|
atp_db:
|
||||||
|
atp_redis:
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||||
|
|
||||||
|
dir=$(dirname $0)
|
||||||
|
. ${dir}/_common.sh
|
||||||
|
|
||||||
|
SERVICES="db_test" main "$@"
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
# ./with-test-redis-and-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||||
|
# ./with-test-redis-and-db.sh redis-cli -h localhost -p 6380 ping
|
||||||
|
|
||||||
|
dir=$(dirname $0)
|
||||||
|
. ${dir}/_common.sh
|
||||||
|
|
||||||
|
SERVICES="db_test redis_test" main "$@"
|
||||||
+94
-60
@@ -1,7 +1,7 @@
|
|||||||
import net from 'net'
|
import net from 'net'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import {TestNetworkNoAppView} from '@atproto/dev-env'
|
import {TestNetwork} from '@atproto/dev-env'
|
||||||
import {AtUri, BskyAgent} from '@atproto/api'
|
import {AtUri, BskyAgent} from '@atproto/api'
|
||||||
|
|
||||||
export interface TestUser {
|
export interface TestUser {
|
||||||
@@ -18,14 +18,59 @@ export interface TestPDS {
|
|||||||
close: () => Promise<void>
|
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(
|
export async function createServer(
|
||||||
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||||
): Promise<TestPDS> {
|
): Promise<TestPDS> {
|
||||||
const port = await getPort()
|
const port = await getPort()
|
||||||
const port2 = await getPort(port + 1)
|
const port2 = await getPort(port + 1)
|
||||||
const pdsUrl = `http://localhost:${port}`
|
const pdsUrl = `http://localhost:${port}`
|
||||||
const testNet = await TestNetworkNoAppView.create({
|
const id = ids.next()
|
||||||
pds: {port, publicUrl: pdsUrl, inviteRequired},
|
const testNet = await TestNetwork.create({
|
||||||
|
pds: {
|
||||||
|
port,
|
||||||
|
publicUrl: pdsUrl,
|
||||||
|
inviteRequired,
|
||||||
|
dbPostgresSchema: `pds_${id}`,
|
||||||
|
},
|
||||||
|
bsky: {
|
||||||
|
dbPostgresSchema: `bsky_${id}`,
|
||||||
|
},
|
||||||
plc: {port: port2},
|
plc: {port: port2},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,7 +93,7 @@ class Mocker {
|
|||||||
users: Record<string, TestUser> = {}
|
users: Record<string, TestUser> = {}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public testNet: TestNetworkNoAppView,
|
public testNet: TestNetwork,
|
||||||
public service: string,
|
public service: string,
|
||||||
public pic: Uint8Array,
|
public pic: Uint8Array,
|
||||||
) {
|
) {
|
||||||
@@ -59,6 +104,10 @@ class Mocker {
|
|||||||
return this.testNet.pds
|
return this.testNet.pds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get bsky() {
|
||||||
|
return this.testNet.bsky
|
||||||
|
}
|
||||||
|
|
||||||
get plc() {
|
get plc() {
|
||||||
return this.testNet.plc
|
return this.testNet.plc
|
||||||
}
|
}
|
||||||
@@ -81,11 +130,7 @@ class Mocker {
|
|||||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1},
|
{useCount: 1},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: this.pds.adminAuthHeaders('admin'),
|
||||||
authorization: `Basic ${btoa(
|
|
||||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
encoding: 'application/json',
|
encoding: 'application/json',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -260,11 +305,7 @@ class Mocker {
|
|||||||
await agent.api.com.atproto.server.createInviteCode(
|
await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1, forAccount},
|
{useCount: 1, forAccount},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: this.pds.adminAuthHeaders('admin'),
|
||||||
authorization: `Basic ${btoa(
|
|
||||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
encoding: 'application/json',
|
encoding: 'application/json',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -275,24 +316,21 @@ class Mocker {
|
|||||||
if (!did) {
|
if (!did) {
|
||||||
throw new Error(`Invalid user: ${user}`)
|
throw new Error(`Invalid user: ${user}`)
|
||||||
}
|
}
|
||||||
const ctx = this.pds.ctx
|
const ctx = this.bsky.ctx
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Invalid PDS')
|
throw new Error('Invalid appview')
|
||||||
}
|
}
|
||||||
|
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||||
await ctx.db.db
|
await labelSrvc.createLabels([
|
||||||
.insertInto('label')
|
{
|
||||||
.values([
|
src: ctx.cfg.labelerDid,
|
||||||
{
|
uri: did,
|
||||||
src: ctx.cfg.labelerDid,
|
cid: '',
|
||||||
uri: did,
|
val: label,
|
||||||
cid: '',
|
neg: false,
|
||||||
val: label,
|
cts: new Date().toISOString(),
|
||||||
neg: 0,
|
},
|
||||||
cts: new Date().toISOString(),
|
])
|
||||||
},
|
|
||||||
])
|
|
||||||
.execute()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async labelProfile(label: string, user: string) {
|
async labelProfile(label: string, user: string) {
|
||||||
@@ -307,43 +345,39 @@ class Mocker {
|
|||||||
rkey: 'self',
|
rkey: 'self',
|
||||||
})
|
})
|
||||||
|
|
||||||
const ctx = this.pds.ctx
|
const ctx = this.bsky.ctx
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Invalid PDS')
|
throw new Error('Invalid appview')
|
||||||
}
|
}
|
||||||
await ctx.db.db
|
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||||
.insertInto('label')
|
await labelSrvc.createLabels([
|
||||||
.values([
|
{
|
||||||
{
|
src: ctx.cfg.labelerDid,
|
||||||
src: ctx.cfg.labelerDid,
|
uri: profile.uri,
|
||||||
uri: profile.uri,
|
cid: profile.cid,
|
||||||
cid: profile.cid,
|
val: label,
|
||||||
val: label,
|
neg: false,
|
||||||
neg: 0,
|
cts: new Date().toISOString(),
|
||||||
cts: new Date().toISOString(),
|
},
|
||||||
},
|
])
|
||||||
])
|
|
||||||
.execute()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||||
const ctx = this.pds.ctx
|
const ctx = this.bsky.ctx
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Invalid PDS')
|
throw new Error('Invalid appview')
|
||||||
}
|
}
|
||||||
await ctx.db.db
|
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||||
.insertInto('label')
|
await labelSrvc.createLabels([
|
||||||
.values([
|
{
|
||||||
{
|
src: ctx.cfg.labelerDid,
|
||||||
src: ctx.cfg.labelerDid,
|
uri,
|
||||||
uri,
|
cid,
|
||||||
cid,
|
val: label,
|
||||||
val: label,
|
neg: false,
|
||||||
neg: 0,
|
cts: new Date().toISOString(),
|
||||||
cts: new Date().toISOString(),
|
},
|
||||||
},
|
])
|
||||||
])
|
|
||||||
.execute()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createMuteList(user: string, name: string): Promise<string> {
|
async createMuteList(user: string, name: string): Promise<string> {
|
||||||
|
|||||||
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.51.0",
|
"version": "1.52.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "is-ci || husky install",
|
"prepare": "is-ci || husky install",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"test-coverage": "jest --coverage",
|
"test-coverage": "jest --coverage",
|
||||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
"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:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||||
"e2e:build": "detox build -c ios.sim.debug",
|
"e2e:build": "detox build -c ios.sim.debug",
|
||||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"babel-loader": "^9.1.2",
|
"babel-loader": "^9.1.2",
|
||||||
"babel-plugin-module-resolver": "^5.0.0",
|
"babel-plugin-module-resolver": "^5.0.0",
|
||||||
"babel-plugin-react-native-web": "^0.18.12",
|
"babel-plugin-react-native-web": "^0.18.12",
|
||||||
"detox": "^20.11.3",
|
"detox": "^20.13.0",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-plugin-detox": "^1.0.0",
|
"eslint-plugin-detox": "^1.0.0",
|
||||||
"eslint-plugin-ft-flow": "^2.0.3",
|
"eslint-plugin-ft-flow": "^2.0.3",
|
||||||
@@ -215,7 +215,8 @@
|
|||||||
"webpack-dev-server": "^4.11.1"
|
"webpack-dev-server": "^4.11.1"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "^18"
|
"@types/react": "^18",
|
||||||
|
"**/zeed-dom": "estrattonbailey/zeed-dom#publish"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "jest-expo/ios",
|
"preset": "jest-expo/ios",
|
||||||
|
|||||||
@@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
|||||||
|
|
||||||
export function linkRequiresWarning(uri: string, label: string) {
|
export function linkRequiresWarning(uri: string, label: string) {
|
||||||
const labelDomain = labelToDomain(label)
|
const labelDomain = labelToDomain(label)
|
||||||
if (!labelDomain) {
|
let urip
|
||||||
return true
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const urip = new URL(uri)
|
urip = new URL(uri)
|
||||||
return labelDomain !== urip.hostname
|
|
||||||
} catch {
|
} catch {
|
||||||
return true
|
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 {
|
function labelToDomain(label: string): string | undefined {
|
||||||
|
|||||||
Vendored
+3
-2
@@ -5,6 +5,7 @@ import {
|
|||||||
moderateProfile,
|
moderateProfile,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
|
||||||
const MAX_SYNC_PAGES = 10
|
const MAX_SYNC_PAGES = 10
|
||||||
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||||
@@ -56,7 +57,7 @@ export class MyFollowsCache {
|
|||||||
* Syncs a subset of the user's follows
|
* Syncs a subset of the user's follows
|
||||||
* for performance reasons, caps out at 1000 follows
|
* for performance reasons, caps out at 1000 follows
|
||||||
*/
|
*/
|
||||||
async syncIfNeeded() {
|
syncIfNeeded = bundleAsync(async () => {
|
||||||
if (this.lastSync > Date.now() - SYNC_TTL) {
|
if (this.lastSync > Date.now() - SYNC_TTL) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ export class MyFollowsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.lastSync = Date.now()
|
this.lastSync = Date.now()
|
||||||
}
|
})
|
||||||
|
|
||||||
getFollowState(did: string): FollowState {
|
getFollowState(did: string): FollowState {
|
||||||
if (typeof this.byDid[did] === 'undefined') {
|
if (typeof this.byDid[did] === 'undefined') {
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ export class MeModel {
|
|||||||
savedFeeds: SavedFeedsModel
|
savedFeeds: SavedFeedsModel
|
||||||
notifications: NotificationsFeedModel
|
notifications: NotificationsFeedModel
|
||||||
follows: MyFollowsCache
|
follows: MyFollowsCache
|
||||||
invites: ComAtprotoServerDefs.InviteCode[] | null = []
|
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||||
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
||||||
lastProfileStateUpdate = Date.now()
|
lastProfileStateUpdate = Date.now()
|
||||||
lastNotifsUpdate = Date.now()
|
lastNotifsUpdate = Date.now()
|
||||||
|
|
||||||
get invitesAvailable() {
|
get invitesAvailable() {
|
||||||
return this.invites?.filter(isInviteAvailable).length || null
|
return this.invites.filter(isInviteAvailable).length
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
@@ -180,9 +180,7 @@ export class MeModel {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.rootStore.log.error('Failed to fetch user invite codes', 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
|||||||
async crop() {
|
async crop() {
|
||||||
try {
|
try {
|
||||||
// NOTE
|
// 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
|
// without specifying width and height. on android, however, the
|
||||||
// crop stretches incorrectly if you do specify it. these are
|
// crop stretches incorrectly if you do specify it. these are
|
||||||
// both separate bugs in the library. we deal with that by
|
// both separate bugs in the library. we deal with that by
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -3,10 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards'
|
|||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {toHashCode} from 'lib/strings/helpers'
|
import {toHashCode} from 'lib/strings/helpers'
|
||||||
|
|
||||||
const DAY = 60e3 * 24 * 1 // 1 day (ms)
|
|
||||||
|
|
||||||
export class Reminders {
|
export class Reminders {
|
||||||
lastEmailConfirm: Date = new Date()
|
lastEmailConfirm: Date | null = null
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
@@ -45,6 +43,10 @@ export class Reminders {
|
|||||||
if (this.rootStore.onboarding.isActive) {
|
if (this.rootStore.onboarding.isActive) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// only prompt once
|
||||||
|
if (this.lastEmailConfirm) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
// shard the users into 2 day of the week buckets
|
// shard the users into 2 day of the week buckets
|
||||||
// (this is to avoid a sudden influx of email updates when
|
// (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) {
|
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// only ask once a day at most, but because of the bucketing
|
return true
|
||||||
// this will be more like weekly
|
|
||||||
return Number(today) - Number(this.lastEmailConfirm) > DAY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEmailConfirmationRequested() {
|
setEmailConfirmationRequested() {
|
||||||
|
|||||||
@@ -219,16 +219,19 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
const textDecorated = useMemo(() => {
|
const textDecorated = useMemo(() => {
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
return Array.from(richtext.segments()).map(segment => (
|
return Array.from(richtext.segments()).map(segment => {
|
||||||
<Text
|
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||||
key={i++}
|
return (
|
||||||
style={[
|
<Text
|
||||||
!segment.facet ? pal.text : pal.link,
|
key={i++}
|
||||||
styles.textInputFormatting,
|
style={[
|
||||||
]}>
|
segment.facet && !isTag ? pal.link : pal.text,
|
||||||
{segment.text}
|
styles.textInputFormatting,
|
||||||
</Text>
|
]}>
|
||||||
))
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})
|
||||||
}, [richtext, pal.link, pal.text])
|
}, [richtext, pal.link, pal.text])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, {MutableRefObject, useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
|
|
||||||
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
measure,
|
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -12,11 +11,7 @@ import Animated, {
|
|||||||
withDecay,
|
withDecay,
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {
|
import {GestureDetector, Gesture} from 'react-native-gesture-handler'
|
||||||
GestureDetector,
|
|
||||||
Gesture,
|
|
||||||
GestureType,
|
|
||||||
} from 'react-native-gesture-handler'
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
import {
|
import {
|
||||||
createTransform,
|
createTransform,
|
||||||
@@ -40,7 +35,6 @@ type Props = {
|
|||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (isZoomed: boolean) => void
|
onZoom: (isZoomed: boolean) => void
|
||||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
|
||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
@@ -48,7 +42,6 @@ const ImageItem = ({
|
|||||||
onZoom,
|
onZoom,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
isScrollViewBeingDragged,
|
isScrollViewBeingDragged,
|
||||||
pinchGestureRef,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
@@ -140,28 +133,7 @@ const ImageItem = ({
|
|||||||
return [dx, dy]
|
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()
|
const pinch = Gesture.Pinch()
|
||||||
.withRef(pinchGestureRef)
|
|
||||||
.onStart(e => {
|
.onStart(e => {
|
||||||
pinchOrigin.value = {
|
pinchOrigin.value = {
|
||||||
x: e.focalX - SCREEN.width / 2,
|
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
|
const isLoading = !isLoaded || !imageDimensions
|
||||||
return (
|
return (
|
||||||
<Animated.View ref={containerRef} style={styles.container}>
|
<Animated.View ref={containerRef} style={styles.container}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
)}
|
)}
|
||||||
<GestureDetector
|
<GestureDetector gesture={composedGesture}>
|
||||||
gesture={Gesture.Exclusive(
|
|
||||||
consumeHScroll,
|
|
||||||
dismissSwipePan,
|
|
||||||
Gesture.Simultaneous(pinch, pan),
|
|
||||||
doubleTap,
|
|
||||||
)}>
|
|
||||||
<AnimatedImage
|
<AnimatedImage
|
||||||
source={imageSrc}
|
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
|
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||||
|
source={{uri: imageSrc.uri}}
|
||||||
style={[styles.image, animatedStyle]}
|
style={[styles.image, animatedStyle]}
|
||||||
accessibilityLabel={imageSrc.alt}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
|||||||
@@ -6,21 +6,25 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
import React, {useCallback, useState} from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
NativeTouchEvent,
|
NativeTouchEvent,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {GestureType} from 'react-native-gesture-handler'
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
|
|
||||||
@@ -31,16 +35,13 @@ const DOUBLE_TAP_DELAY = 300
|
|||||||
const SWIPE_CLOSE_OFFSET = 75
|
const SWIPE_CLOSE_OFFSET = 75
|
||||||
const SWIPE_CLOSE_VELOCITY = 1
|
const SWIPE_CLOSE_VELOCITY = 1
|
||||||
const SCREEN = Dimensions.get('screen')
|
const SCREEN = Dimensions.get('screen')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const MIN_DOUBLE_TAP_SCALE = 2
|
||||||
const MIN_ZOOM = 2
|
|
||||||
const MAX_SCALE = 2
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (scaled: boolean) => void
|
onZoom: (scaled: boolean) => void
|
||||||
pinchGestureRef: MutableRefObject<GestureType>
|
|
||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,44 +50,42 @@ const AnimatedImage = Animated.createAnimatedComponent(Image)
|
|||||||
let lastTapTS: number | null = null
|
let lastTapTS: number | null = null
|
||||||
|
|
||||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||||
const scrollViewRef = useRef<ScrollView>(null)
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
|
const translationY = useSharedValue(0)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [scaled, setScaled] = useState(false)
|
const [scaled, setScaled] = useState(false)
|
||||||
const imageDimensions = useImageDimensions(imageSrc)
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const maxZoomScale = imageDimensions
|
||||||
const [scrollValueY] = useState(() => new Animated.Value(0))
|
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
: 1
|
||||||
|
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
return {
|
||||||
outputRange: [0.5, 1, 0.5],
|
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(
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
onScroll(e) {
|
||||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y
|
||||||
const currentScaled = nativeEvent?.zoomScale > 1
|
},
|
||||||
|
onEndDrag(e) {
|
||||||
onZoom(currentScaled)
|
const velocityY = e.velocity?.y ?? 0
|
||||||
setScaled(currentScaled)
|
const nextIsScaled = e.zoomScale > 1
|
||||||
|
runOnJS(handleZoom)(nextIsScaled)
|
||||||
if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||||
onRequestClose()
|
runOnJS(onRequestClose)()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onRequestClose, onZoom],
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
onZoom(nextIsScaled)
|
||||||
|
setScaled(nextIsScaled)
|
||||||
if (nativeEvent?.zoomScale > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollValueY.setValue(offsetY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDoubleTap = useCallback(
|
const handleDoubleTap = useCallback(
|
||||||
@@ -121,23 +120,21 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
|||||||
lastTapTS = nowTS
|
lastTapTS = nowTS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imageDimensions, scaled],
|
[imageDimensions, scaled, scrollViewRef],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ScrollView
|
<Animated.ScrollView
|
||||||
|
// @ts-ignore Something's up with the types here
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
style={styles.listItem}
|
style={styles.listItem}
|
||||||
pinchGestureEnabled
|
pinchGestureEnabled
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxScrollViewZoom}
|
maximumZoomScale={maxZoomScale}
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
scrollEnabled={true}
|
onScroll={scrollHandler}>
|
||||||
onScroll={onScroll}
|
|
||||||
onScrollEndDrag={onScrollEndDrag}
|
|
||||||
scrollEventThrottle={1}>
|
|
||||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
onPress={handleDoubleTap}
|
onPress={handleDoubleTap}
|
||||||
@@ -145,23 +142,29 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
|||||||
accessibilityLabel={imageSrc.alt}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<AnimatedImage
|
<AnimatedImage
|
||||||
source={imageSrc}
|
contentFit="contain"
|
||||||
style={imageStylesWithOpacity}
|
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||||
|
source={{uri: imageSrc.uri}}
|
||||||
|
style={[styles.image, animatedStyle]}
|
||||||
onLoad={() => setLoaded(true)}
|
onLoad={() => setLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
</ScrollView>
|
</Animated.ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
listItem: {
|
|
||||||
width: SCREEN_WIDTH,
|
|
||||||
height: SCREEN_HEIGHT,
|
|
||||||
},
|
|
||||||
imageScrollContainer: {
|
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(
|
const zoom = Math.max(
|
||||||
imageAspect / screenAspect,
|
imageAspect / screenAspect,
|
||||||
screenAspect / imageAspect,
|
screenAspect / imageAspect,
|
||||||
MIN_ZOOM,
|
MIN_DOUBLE_TAP_SCALE,
|
||||||
)
|
)
|
||||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
// 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.
|
// 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)
|
export default React.memo(ImageItem)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
// default implementation fallback for web
|
// default implementation fallback for web
|
||||||
|
|
||||||
import React, {MutableRefObject} from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {GestureType} from 'react-native-gesture-handler'
|
|
||||||
import {ImageSource} from '../../@types'
|
import {ImageSource} from '../../@types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (scaled: boolean) => void
|
onZoom: (scaled: boolean) => void
|
||||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
|
||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
|
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
|
||||||
return new Promise(resolve => {
|
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) {
|
if (image.uri) {
|
||||||
const source = image as ImageURISource
|
const source = image as ImageURISource
|
||||||
|
|
||||||
const cacheKey = source.uri as string
|
const cacheKey = source.uri as string
|
||||||
|
|
||||||
const imageDimensions = imageDimensionsCache.get(cacheKey)
|
const imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||||
|
|
||||||
if (imageDimensions) {
|
if (imageDimensions) {
|
||||||
resolve(imageDimensions)
|
resolve(imageDimensions)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,121 +8,60 @@
|
|||||||
// Original code copied and simplified from the link below as the codebase is currently not maintained:
|
// Original code copied and simplified from the link below as the codebase is currently not maintained:
|
||||||
// https://github.com/jobtoday/react-native-image-viewing
|
// https://github.com/jobtoday/react-native-image-viewing
|
||||||
|
|
||||||
import React, {
|
import React, {ComponentType, useCallback, useMemo, useState} from 'react'
|
||||||
ComponentType,
|
import {StyleSheet, View, Platform} from 'react-native'
|
||||||
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 ImageItem from './components/ImageItem/ImageItem'
|
import ImageItem from './components/ImageItem/ImageItem'
|
||||||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||||
|
|
||||||
import {ImageSource} from './@types'
|
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 {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||||
|
import PagerView from 'react-native-pager-view'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: ImageSource[]
|
images: ImageSource[]
|
||||||
keyExtractor?: (imageSrc: ImageSource, index: number) => string
|
initialImageIndex: number
|
||||||
imageIndex: number
|
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
presentationStyle?: ModalProps['presentationStyle']
|
|
||||||
animationType?: ModalProps['animationType']
|
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
HeaderComponent?: ComponentType<{imageIndex: number}>
|
HeaderComponent?: ComponentType<{imageIndex: number}>
|
||||||
FooterComponent?: ComponentType<{imageIndex: number}>
|
FooterComponent?: ComponentType<{imageIndex: number}>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_BG_COLOR = '#000'
|
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({
|
function ImageViewing({
|
||||||
images,
|
images,
|
||||||
keyExtractor,
|
initialImageIndex,
|
||||||
imageIndex,
|
|
||||||
visible,
|
visible,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
backgroundColor = DEFAULT_BG_COLOR,
|
backgroundColor = DEFAULT_BG_COLOR,
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
FooterComponent,
|
FooterComponent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [opacity, setOpacity] = useState(1)
|
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
|
||||||
const [headerTranslate] = useState(
|
|
||||||
() => new Animated.ValueXY(INITIAL_POSITION),
|
|
||||||
)
|
|
||||||
const [footerTranslate] = useState(
|
|
||||||
() => new Animated.ValueXY(INITIAL_POSITION),
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleBarsVisible = (isVisible: boolean) => {
|
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
||||||
if (isVisible) {
|
transform: [
|
||||||
Animated.parallel([
|
{
|
||||||
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
translateY: withClampedSpring(isScaled ? -300 : 0),
|
||||||
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
|
||||||
]).start()
|
|
||||||
} else {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(headerTranslate.y, {
|
|
||||||
...ANIMATION_CONFIG,
|
|
||||||
toValue: -300,
|
|
||||||
}),
|
|
||||||
Animated.timing(footerTranslate.y, {
|
|
||||||
...ANIMATION_CONFIG,
|
|
||||||
toValue: 300,
|
|
||||||
}),
|
|
||||||
]).start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRequestCloseEnhanced = () => {
|
|
||||||
setOpacity(0)
|
|
||||||
onRequestClose()
|
|
||||||
setTimeout(() => setOpacity(1), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
const {
|
|
||||||
nativeEvent: {
|
|
||||||
contentOffset: {x: scrollX},
|
|
||||||
},
|
},
|
||||||
} = event
|
],
|
||||||
|
}))
|
||||||
|
const animatedFooterStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: withClampedSpring(isScaled ? 300 : 0),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
if (SCREEN.width) {
|
const onZoom = useCallback((nextIsScaled: boolean) => {
|
||||||
const nextIndex = Math.round(scrollX / SCREEN.width)
|
setIsScaled(nextIsScaled)
|
||||||
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
|
}, [])
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onZoom = (nextIsScaled: boolean) => {
|
|
||||||
toggleBarsVisible(!nextIsScaled)
|
|
||||||
setIsScaled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
@@ -131,100 +70,53 @@ function ImageViewing({
|
|||||||
return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
|
return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onLayout = useCallback(() => {
|
|
||||||
if (imageIndex) {
|
|
||||||
imageList.current?.scrollToIndex({index: imageIndex, animated: false})
|
|
||||||
}
|
|
||||||
}, [imageList, imageIndex])
|
|
||||||
|
|
||||||
// This is a hack.
|
|
||||||
// RNGH doesn't have an easy way to express that pinch of individual items
|
|
||||||
// should "steal" all pinches from the scroll view. So we're keeping a ref
|
|
||||||
// to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
|
|
||||||
const [pinchGestureRefs] = useState(new Map())
|
|
||||||
for (let imageSrc of images) {
|
|
||||||
if (!pinchGestureRefs.get(imageSrc)) {
|
|
||||||
pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerTransform = headerTranslate.getTranslateTransform()
|
|
||||||
const footerTransform = footerTranslate.getTranslateTransform()
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
style={styles.screen}
|
style={styles.screen}
|
||||||
onLayout={onLayout}
|
|
||||||
edges={edges}
|
edges={edges}
|
||||||
aria-modal
|
aria-modal
|
||||||
accessibilityViewIsModal>
|
accessibilityViewIsModal>
|
||||||
<ModalsContainer />
|
<View style={[styles.container, {backgroundColor}]}>
|
||||||
<View style={[styles.container, {opacity, backgroundColor}]}>
|
<Animated.View style={[styles.header, animatedHeaderStyle]}>
|
||||||
<Animated.View style={[styles.header, {transform: headerTransform}]}>
|
|
||||||
{typeof HeaderComponent !== 'undefined' ? (
|
{typeof HeaderComponent !== 'undefined' ? (
|
||||||
React.createElement(HeaderComponent, {
|
React.createElement(HeaderComponent, {
|
||||||
imageIndex: currentImageIndex,
|
imageIndex,
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
|
<ImageDefaultHeader onRequestClose={onRequestClose} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<VirtualizedList
|
<PagerView
|
||||||
ref={imageList}
|
scrollEnabled={!isScaled}
|
||||||
data={images}
|
initialPage={initialImageIndex}
|
||||||
horizontal
|
onPageSelected={e => {
|
||||||
pagingEnabled
|
setImageIndex(e.nativeEvent.position)
|
||||||
scrollEnabled={!isScaled || isDragging}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
getItem={(_, index) => images[index]}
|
|
||||||
getItemCount={() => images.length}
|
|
||||||
getItemLayout={(_, index) => ({
|
|
||||||
length: SCREEN_WIDTH,
|
|
||||||
offset: SCREEN_WIDTH * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
renderItem={({item: imageSrc}) => (
|
|
||||||
<ImageItem
|
|
||||||
onZoom={onZoom}
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
onRequestClose={onRequestCloseEnhanced}
|
|
||||||
pinchGestureRef={pinchGestureRefs.get(imageSrc)}
|
|
||||||
isScrollViewBeingDragged={isDragging}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderScrollComponent={props => (
|
|
||||||
<ScrollView
|
|
||||||
{...props}
|
|
||||||
waitFor={Array.from(pinchGestureRefs.values())}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
onScrollBeginDrag={() => {
|
|
||||||
setIsDragging(true)
|
|
||||||
}}
|
|
||||||
onScrollEndDrag={() => {
|
|
||||||
setIsDragging(false)
|
|
||||||
}}
|
|
||||||
onMomentumScrollEnd={e => {
|
|
||||||
setIsScaled(false)
|
setIsScaled(false)
|
||||||
onScroll(e)
|
|
||||||
}}
|
}}
|
||||||
//@ts-ignore
|
onPageScrollStateChanged={e => {
|
||||||
keyExtractor={(imageSrc, index) =>
|
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
|
||||||
keyExtractor
|
}}
|
||||||
? keyExtractor(imageSrc, index)
|
overdrag={true}
|
||||||
: typeof imageSrc === 'number'
|
style={styles.pager}>
|
||||||
? `${imageSrc}`
|
{images.map(imageSrc => (
|
||||||
: imageSrc.uri
|
<View key={imageSrc.uri}>
|
||||||
}
|
<ImageItem
|
||||||
/>
|
onZoom={onZoom}
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
onRequestClose={onRequestClose}
|
||||||
|
isScrollViewBeingDragged={isDragging}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</PagerView>
|
||||||
{typeof FooterComponent !== 'undefined' && (
|
{typeof FooterComponent !== 'undefined' && (
|
||||||
<Animated.View style={[styles.footer, {transform: footerTransform}]}>
|
<Animated.View style={[styles.footer, animatedFooterStyle]}>
|
||||||
{React.createElement(FooterComponent, {
|
{React.createElement(FooterComponent, {
|
||||||
imageIndex: currentImageIndex,
|
imageIndex,
|
||||||
})}
|
})}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -236,11 +128,18 @@ function ImageViewing({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screen: {
|
screen: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
},
|
},
|
||||||
|
pager: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -257,7 +156,12 @@ const styles = StyleSheet.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const EnhancedImageViewing = (props: Props) => (
|
const EnhancedImageViewing = (props: Props) => (
|
||||||
<ImageViewing key={props.imageIndex} {...props} />
|
<ImageViewing key={props.initialImageIndex} {...props} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function withClampedSpring(value: any) {
|
||||||
|
'worklet'
|
||||||
|
return withSpring(value, {overshootClamping: true})
|
||||||
|
}
|
||||||
|
|
||||||
export default EnhancedImageViewing
|
export default EnhancedImageViewing
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() {
|
|||||||
return (
|
return (
|
||||||
<ImageView
|
<ImageView
|
||||||
images={[{uri: opts.profileView.avatar || ''}]}
|
images={[{uri: opts.profileView.avatar || ''}]}
|
||||||
imageIndex={0}
|
initialImageIndex={0}
|
||||||
visible
|
visible
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
FooterComponent={LightboxFooter}
|
FooterComponent={LightboxFooter}
|
||||||
@@ -37,7 +37,7 @@ export const Lightbox = observer(function Lightbox() {
|
|||||||
return (
|
return (
|
||||||
<ImageView
|
<ImageView
|
||||||
images={opts.images.map(img => ({...img}))}
|
images={opts.images.map(img => ({...img}))}
|
||||||
imageIndex={opts.index}
|
initialImageIndex={opts.index}
|
||||||
visible
|
visible
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
FooterComponent={LightboxFooter}
|
FooterComponent={LightboxFooter}
|
||||||
|
|||||||
+115
-129
@@ -1,11 +1,5 @@
|
|||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {
|
import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
SafeAreaView,
|
|
||||||
StyleSheet,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {ScrollView, TextInput} from './util'
|
import {ScrollView, TextInput} from './util'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
@@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={[pal.view, s.flex1]}>
|
||||||
behavior="padding"
|
<ScrollView
|
||||||
style={[pal.view, styles.container]}>
|
testID="changeEmailModal"
|
||||||
<SafeAreaView style={s.flex1}>
|
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||||
<ScrollView
|
<View style={styles.titleSection}>
|
||||||
testID="changeEmailModal"
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
{stage === Stages.InputEmail ? 'Change Your Email' : ''}
|
||||||
<View style={styles.titleSection}>
|
{stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
|
||||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
{stage === Stages.Done ? 'Email Updated' : ''}
|
||||||
{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.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{stage === Stages.InputEmail && (
|
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||||
<TextInput
|
{stage === Stages.InputEmail ? (
|
||||||
testID="emailInput"
|
<>Enter your new email address below.</>
|
||||||
style={[styles.textInput, pal.border, pal.text]}
|
) : stage === Stages.ConfirmCode ? (
|
||||||
placeholder="alice@mail.com"
|
<>
|
||||||
placeholderTextColor={pal.colors.textLight}
|
An email has been sent to your previous address,{' '}
|
||||||
value={email}
|
{store.session.currentSession?.email || ''}. It includes a
|
||||||
onChangeText={setEmail}
|
confirmation code which you can enter below.
|
||||||
accessible={true}
|
</>
|
||||||
accessibilityLabel="Email"
|
) : (
|
||||||
accessibilityHint=""
|
<>
|
||||||
autoCapitalize="none"
|
Your email has been updated but not verified. As a next step,
|
||||||
autoComplete="email"
|
please verify your new 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>
|
||||||
|
|
||||||
{error ? (
|
{stage === Stages.InputEmail && (
|
||||||
<ErrorMessage message={error} style={styles.error} />
|
<TextInput
|
||||||
) : undefined}
|
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]}>
|
{error ? (
|
||||||
{isProcessing ? (
|
<ErrorMessage message={error} style={styles.error} />
|
||||||
<View style={styles.btn}>
|
) : undefined}
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
</View>
|
<View style={[styles.btnContainer]}>
|
||||||
) : (
|
{isProcessing ? (
|
||||||
<View style={{gap: 6}}>
|
<View style={styles.btn}>
|
||||||
{stage === Stages.InputEmail && (
|
<ActivityIndicator color="#fff" />
|
||||||
<Button
|
</View>
|
||||||
testID="requestChangeBtn"
|
) : (
|
||||||
type="primary"
|
<View style={{gap: 6}}>
|
||||||
onPress={onRequestChange}
|
{stage === Stages.InputEmail && (
|
||||||
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]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
testID="cancelBtn"
|
testID="requestChangeBtn"
|
||||||
type="default"
|
type="primary"
|
||||||
onPress={() => store.shell.closeModal()}
|
onPress={onRequestChange}
|
||||||
accessibilityLabel="Cancel"
|
accessibilityLabel="Request Change"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
label="Cancel"
|
label="Request Change"
|
||||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||||
labelStyle={[s.f18]}
|
labelStyle={[s.f18]}
|
||||||
/>
|
/>
|
||||||
</View>
|
)}
|
||||||
)}
|
{stage === Stages.ConfirmCode && (
|
||||||
</View>
|
<Button
|
||||||
</ScrollView>
|
testID="confirmBtn"
|
||||||
</SafeAreaView>
|
type="primary"
|
||||||
</KeyboardAvoidingView>
|
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]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingBottom: isWeb ? 0 : 40,
|
|
||||||
},
|
|
||||||
titleSection: {
|
titleSection: {
|
||||||
paddingTop: isWeb ? 0 : 4,
|
paddingTop: isWeb ? 0 : 4,
|
||||||
paddingBottom: isWeb ? 14 : 10,
|
paddingBottom: isWeb ? 14 : 10,
|
||||||
|
|||||||
@@ -26,33 +26,6 @@ export function Component({}: {}) {
|
|||||||
store.shell.closeModal()
|
store.shell.closeModal()
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
if (store.me.invites === null) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
|
|
||||||
<Text type="title-xl" style={[styles.title, pal.text]}>
|
|
||||||
Error
|
|
||||||
</Text>
|
|
||||||
<Text type="lg" style={[styles.description, pal.text]}>
|
|
||||||
An error occurred while loading invite codes.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.flex1} />
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.btnContainer,
|
|
||||||
isTabletOrDesktop && styles.btnContainerDesktop,
|
|
||||||
]}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
label="Done"
|
|
||||||
style={styles.btn}
|
|
||||||
labelStyle={styles.btnLabel}
|
|
||||||
onPress={onClose}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.me.invites.length === 0) {
|
if (store.me.invites.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
|
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
|
||||||
|
|||||||
+144
-155
@@ -1,7 +1,6 @@
|
|||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -82,169 +81,163 @@ export const Component = observer(function Component({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={[pal.view, s.flex1]}>
|
||||||
behavior="padding"
|
<ScrollView
|
||||||
style={[pal.view, styles.container]}>
|
testID="verifyEmailModal"
|
||||||
<SafeAreaView style={s.flex1}>
|
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||||
<ScrollView
|
{stage === Stages.Reminder && <ReminderIllustration />}
|
||||||
testID="verifyEmailModal"
|
<View style={styles.titleSection}>
|
||||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
{stage === Stages.Reminder && <ReminderIllustration />}
|
{stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
|
||||||
<View style={styles.titleSection}>
|
{stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
|
||||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
{stage === Stages.Email ? 'Verify Your Email' : ''}
|
||||||
{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.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{stage === Stages.Email ? (
|
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||||
|
{stage === Stages.Reminder ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.emailContainer}>
|
Your email has not yet been verified. This is an important
|
||||||
<FontAwesomeIcon
|
security step which we recommend.
|
||||||
icon="envelope"
|
</>
|
||||||
color={pal.colors.text}
|
) : stage === Stages.Email ? (
|
||||||
size={16}
|
<>
|
||||||
/>
|
This is important in case you ever need to change your email or
|
||||||
<Text
|
reset your password.
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
) : stage === Stages.ConfirmCode ? (
|
) : stage === Stages.ConfirmCode ? (
|
||||||
<TextInput
|
<>
|
||||||
testID="confirmCodeInput"
|
An email has been sent to{' '}
|
||||||
style={[styles.textInput, pal.border, pal.text]}
|
{store.session.currentSession?.email || ''}. It includes a
|
||||||
placeholder="XXXXX-XXXXX"
|
confirmation code which you can enter below.
|
||||||
placeholderTextColor={pal.colors.textLight}
|
</>
|
||||||
value={confirmationCode}
|
) : (
|
||||||
onChangeText={setConfirmationCode}
|
''
|
||||||
accessible={true}
|
)}
|
||||||
accessibilityLabel="Confirmation code"
|
</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=""
|
accessibilityHint=""
|
||||||
autoCapitalize="none"
|
onPress={onEmailIncorrect}
|
||||||
autoComplete="off"
|
style={styles.changeEmailLink}>
|
||||||
autoCorrect={false}
|
<Text type="lg" style={pal.link}>
|
||||||
/>
|
Change
|
||||||
) : undefined}
|
</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 ? (
|
{error ? (
|
||||||
<ErrorMessage message={error} style={styles.error} />
|
<ErrorMessage message={error} style={styles.error} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
<View style={[styles.btnContainer]}>
|
<View style={[styles.btnContainer]}>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<View style={styles.btn}>
|
<View style={styles.btn}>
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{gap: 6}}>
|
<View style={{gap: 6}}>
|
||||||
{stage === Stages.Reminder && (
|
{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="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"
|
|
||||||
accessibilityHint=""
|
|
||||||
label="Confirm"
|
|
||||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
|
||||||
labelStyle={[s.f18]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
testID="cancelBtn"
|
testID="getStartedBtn"
|
||||||
type="default"
|
type="primary"
|
||||||
onPress={() => store.shell.closeModal()}
|
onPress={() => setStage(Stages.Email)}
|
||||||
accessibilityLabel={
|
accessibilityLabel="Get Started"
|
||||||
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
|
|
||||||
}
|
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
|
label="Get Started"
|
||||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||||
labelStyle={[s.f18]}
|
labelStyle={[s.f18]}
|
||||||
/>
|
/>
|
||||||
</View>
|
)}
|
||||||
)}
|
{stage === Stages.Email && (
|
||||||
</View>
|
<>
|
||||||
</ScrollView>
|
<Button
|
||||||
</SafeAreaView>
|
testID="sendEmailBtn"
|
||||||
</KeyboardAvoidingView>
|
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"
|
||||||
|
accessibilityHint=""
|
||||||
|
label="Confirm"
|
||||||
|
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||||
|
labelStyle={[s.f18]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingBottom: isWeb ? 0 : 40,
|
|
||||||
},
|
|
||||||
titleSection: {
|
titleSection: {
|
||||||
paddingTop: isWeb ? 0 : 4,
|
paddingTop: isWeb ? 0 : 4,
|
||||||
paddingBottom: isWeb ? 14 : 10,
|
paddingBottom: isWeb ? 14 : 10,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const Feed = observer(function Feed({
|
|||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScroll?: OnScrollCb
|
onScroll?: OnScrollCb
|
||||||
scrollEventThrottle?: number
|
scrollEventThrottle?: number
|
||||||
renderEmptyState?: () => JSX.Element
|
renderEmptyState: () => JSX.Element
|
||||||
renderEndOfFeed?: () => JSX.Element
|
renderEndOfFeed?: () => JSX.Element
|
||||||
testID?: string
|
testID?: string
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
@@ -116,10 +116,7 @@ export const Feed = observer(function Feed({
|
|||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
({item}: {item: any}) => {
|
({item}: {item: any}) => {
|
||||||
if (item === EMPTY_FEED_ITEM) {
|
if (item === EMPTY_FEED_ITEM) {
|
||||||
if (renderEmptyState) {
|
return renderEmptyState()
|
||||||
return renderEmptyState()
|
|
||||||
}
|
|
||||||
return <View />
|
|
||||||
} else if (item === ERROR_ITEM) {
|
} else if (item === ERROR_ITEM) {
|
||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const FeedPage = observer(function FeedPageImpl({
|
|||||||
testID?: string
|
testID?: string
|
||||||
feed: PostsFeedModel
|
feed: PostsFeedModel
|
||||||
isPageFocused: boolean
|
isPageFocused: boolean
|
||||||
renderEmptyState?: () => JSX.Element
|
renderEmptyState: () => JSX.Element
|
||||||
renderEndOfFeed?: () => JSX.Element
|
renderEndOfFeed?: () => JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|||||||
@@ -322,45 +322,37 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
{store.me.invitesAvailable !== null && (
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
<>
|
Invite a Friend
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
</Text>
|
||||||
Invite a Friend
|
<TouchableOpacity
|
||||||
</Text>
|
testID="inviteFriendBtn"
|
||||||
<TouchableOpacity
|
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||||
testID="inviteFriendBtn"
|
onPress={isSwitching ? undefined : onPressInviteCodes}
|
||||||
style={[
|
accessibilityRole="button"
|
||||||
styles.linkCard,
|
accessibilityLabel="Invite"
|
||||||
pal.view,
|
accessibilityHint="Opens invite code list">
|
||||||
isSwitching && styles.dimmed,
|
<View
|
||||||
]}
|
style={[
|
||||||
onPress={isSwitching ? undefined : onPressInviteCodes}
|
styles.iconContainer,
|
||||||
accessibilityRole="button"
|
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||||
accessibilityLabel="Invite"
|
]}>
|
||||||
accessibilityHint="Opens invite code list">
|
<FontAwesomeIcon
|
||||||
<View
|
icon="ticket"
|
||||||
style={[
|
style={
|
||||||
styles.iconContainer,
|
(store.me.invitesAvailable > 0
|
||||||
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
? primaryText
|
||||||
]}>
|
: pal.text) as FontAwesomeIconStyle
|
||||||
<FontAwesomeIcon
|
}
|
||||||
icon="ticket"
|
/>
|
||||||
style={
|
</View>
|
||||||
(store.me.invitesAvailable > 0
|
<Text
|
||||||
? primaryText
|
type="lg"
|
||||||
: pal.text) as FontAwesomeIconStyle
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
||||||
}
|
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||||
/>
|
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||||
</View>
|
</Text>
|
||||||
<Text
|
</TouchableOpacity>
|
||||||
type="lg"
|
|
||||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
|
||||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
|
||||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
|
|||||||
+26
-28
@@ -426,34 +426,32 @@ const InviteCodes = observer(function InviteCodesImpl({
|
|||||||
store.shell.openModal({name: 'invite-codes'})
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
}, [store, track])
|
}, [store, track])
|
||||||
return (
|
return (
|
||||||
store.me.invitesAvailable !== null && (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
testID="menuItemInviteCodes"
|
||||||
testID="menuItemInviteCodes"
|
style={[styles.inviteCodes, style]}
|
||||||
style={[styles.inviteCodes, style]}
|
onPress={onPress}
|
||||||
onPress={onPress}
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
accessibilityLabel={
|
||||||
accessibilityLabel={
|
invitesAvailable === 1
|
||||||
invitesAvailable === 1
|
? 'Invite codes: 1 available'
|
||||||
? 'Invite codes: 1 available'
|
: `Invite codes: ${invitesAvailable} available`
|
||||||
: `Invite codes: ${invitesAvailable} available`
|
}
|
||||||
}
|
accessibilityHint="Opens list of invite codes">
|
||||||
accessibilityHint="Opens list of invite codes">
|
<FontAwesomeIcon
|
||||||
<FontAwesomeIcon
|
icon="ticket"
|
||||||
icon="ticket"
|
style={[
|
||||||
style={[
|
styles.inviteCodesIcon,
|
||||||
styles.inviteCodesIcon,
|
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
]}
|
||||||
]}
|
size={18}
|
||||||
size={18}
|
/>
|
||||||
/>
|
<Text
|
||||||
<Text
|
type="lg-medium"
|
||||||
type="lg-medium"
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
{pluralize(store.me.invitesAvailable, 'code')}
|
||||||
{pluralize(store.me.invitesAvailable, 'code')}
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {DesktopSearch} from './Search'
|
|||||||
import {DesktopFeeds} from './Feeds'
|
import {DesktopFeeds} from './Feeds'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {TextLink} from 'view/com/util/Link'
|
import {TextLink} from 'view/com/util/Link'
|
||||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
|
||||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
|
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
@@ -90,41 +89,32 @@ const InviteCodes = observer(function InviteCodesImpl() {
|
|||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
store.shell.openModal({name: 'invite-codes'})
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.separator, pal.border]}>
|
<TouchableOpacity
|
||||||
{store.me.invitesAvailable === null ? (
|
style={[styles.inviteCodes, pal.border]}
|
||||||
<View style={[s.p10]}>
|
onPress={onPress}
|
||||||
<LoadingPlaceholder width={186} height={32} style={[styles.br40]} />
|
accessibilityRole="button"
|
||||||
</View>
|
accessibilityLabel={
|
||||||
) : (
|
invitesAvailable === 1
|
||||||
<TouchableOpacity
|
? 'Invite codes: 1 available'
|
||||||
style={[styles.inviteCodes]}
|
: `Invite codes: ${invitesAvailable} available`
|
||||||
onPress={onPress}
|
}
|
||||||
accessibilityRole="button"
|
accessibilityHint="Opens list of invite codes">
|
||||||
accessibilityLabel={
|
<FontAwesomeIcon
|
||||||
invitesAvailable === 1
|
icon="ticket"
|
||||||
? 'Invite codes: 1 available'
|
style={[
|
||||||
: `Invite codes: ${invitesAvailable} available`
|
styles.inviteCodesIcon,
|
||||||
}
|
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||||
accessibilityHint="Opens list of invite codes">
|
]}
|
||||||
<FontAwesomeIcon
|
size={16}
|
||||||
icon="ticket"
|
/>
|
||||||
style={[
|
<Text
|
||||||
styles.inviteCodesIcon,
|
type="md-medium"
|
||||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||||
]}
|
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||||
size={16}
|
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||||
/>
|
</Text>
|
||||||
<Text
|
</TouchableOpacity>
|
||||||
type="md-medium"
|
|
||||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
|
||||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
|
||||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,20 +131,16 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
message: {
|
message: {
|
||||||
paddingVertical: 18,
|
paddingVertical: 18,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
},
|
},
|
||||||
messageLine: {
|
messageLine: {
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
separator: {
|
|
||||||
borderTopWidth: 1,
|
|
||||||
},
|
|
||||||
br40: {borderRadius: 40},
|
|
||||||
|
|
||||||
inviteCodes: {
|
inviteCodes: {
|
||||||
paddingHorizontal: 12,
|
borderTopWidth: 1,
|
||||||
paddingVertical: 16,
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||||||
)
|
)
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
// initial setup
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (store.me.did) {
|
||||||
|
autocompleteView.setup()
|
||||||
|
}
|
||||||
|
}, [autocompleteView, store.me.did])
|
||||||
|
|
||||||
const onChangeQuery = React.useCallback(
|
const onChangeQuery = React.useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
setQuery(text)
|
setQuery(text)
|
||||||
|
|||||||
@@ -8145,10 +8145,10 @@ detect-port-alt@^1.1.6:
|
|||||||
address "^1.0.1"
|
address "^1.0.1"
|
||||||
debug "^2.6.0"
|
debug "^2.6.0"
|
||||||
|
|
||||||
detox@^20.11.3:
|
detox@^20.13.0:
|
||||||
version "20.11.3"
|
version "20.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/detox/-/detox-20.11.3.tgz#56d5ea869977f5a747e1be0901b279ab953f8b7b"
|
resolved "https://registry.yarnpkg.com/detox/-/detox-20.13.0.tgz#923111638dfdb16089eea4f07bf4f0b56468d097"
|
||||||
integrity sha512-kdoRAtDLFxXpjt1QlniI+WryMtf7Y8mrZ33Ql8cTR9qoCS/CThi4pweYAQm8yUPqAv1ZtT3eIm3EzRwjEosgLA==
|
integrity sha512-p9MUcoHWFTqSDaoaN+/hnJYdzNYqdelUr/sxzy3zLoS/qehnVJv2yG9pYqz/+gKpJaMIpw2+TVw9imdAx5JpaA==
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv "^8.6.3"
|
ajv "^8.6.3"
|
||||||
bunyan "^1.8.12"
|
bunyan "^1.8.12"
|
||||||
@@ -19206,10 +19206,9 @@ yocto-queue@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||||
|
|
||||||
zeed-dom@^0.9.19:
|
zeed-dom@^0.9.19, zeed-dom@estrattonbailey/zeed-dom#publish:
|
||||||
version "0.9.26"
|
version "0.10.8"
|
||||||
resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.9.26.tgz#f0127d1024b34a1233a321bd6d0275b3ba998b30"
|
resolved "https://codeload.github.com/estrattonbailey/zeed-dom/tar.gz/aad32339dc2473b75aa0a90d8baee21c40a1e914"
|
||||||
integrity sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ==
|
|
||||||
dependencies:
|
dependencies:
|
||||||
css-what "^6.1.0"
|
css-what "^6.1.0"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user