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: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 15',
|
||||
type: 'iPhone 15 Pro',
|
||||
},
|
||||
},
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -502,6 +502,9 @@ async function main() {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
|
||||
@@ -27,6 +27,42 @@ describe('linkRequiresWarning', () => {
|
||||
['http://site.pages', 'http://site.pages.dev', true],
|
||||
['http://site.pages.dev', 'site.pages', true],
|
||||
['http://site.pages', 'site.pages.dev', true],
|
||||
['http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['http://bsky.app/', 'bluesky', false],
|
||||
['https://bsky.app/', 'bluesky', false],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
['http://bsky.app/', 'https://google.com', true],
|
||||
['https://bsky.app/', 'https://google.com', true],
|
||||
|
||||
// bad uri inputs, default to true
|
||||
['', '', true],
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
buildNumber: '2',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -43,7 +43,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: 40,
|
||||
versionCode: 41,
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#ffffff',
|
||||
|
||||
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 path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestNetworkNoAppView} from '@atproto/dev-env'
|
||||
import {TestNetwork} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
@@ -18,14 +18,59 @@ export interface TestPDS {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
class StringIdGenerator {
|
||||
_nextId = [0]
|
||||
constructor(
|
||||
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
) {}
|
||||
|
||||
next() {
|
||||
const r = []
|
||||
for (const char of this._nextId) {
|
||||
r.unshift(this._chars[char])
|
||||
}
|
||||
this._increment()
|
||||
return r.join('')
|
||||
}
|
||||
|
||||
_increment() {
|
||||
for (let i = 0; i < this._nextId.length; i++) {
|
||||
const val = ++this._nextId[i]
|
||||
if (val >= this._chars.length) {
|
||||
this._nextId[i] = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
this._nextId.push(0)
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
while (true) {
|
||||
yield this.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||
): Promise<TestPDS> {
|
||||
const port = await getPort()
|
||||
const port2 = await getPort(port + 1)
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const testNet = await TestNetworkNoAppView.create({
|
||||
pds: {port, publicUrl: pdsUrl, inviteRequired},
|
||||
const id = ids.next()
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
publicUrl: pdsUrl,
|
||||
inviteRequired,
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
},
|
||||
plc: {port: port2},
|
||||
})
|
||||
|
||||
@@ -48,7 +93,7 @@ class Mocker {
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(
|
||||
public testNet: TestNetworkNoAppView,
|
||||
public testNet: TestNetwork,
|
||||
public service: string,
|
||||
public pic: Uint8Array,
|
||||
) {
|
||||
@@ -59,6 +104,10 @@ class Mocker {
|
||||
return this.testNet.pds
|
||||
}
|
||||
|
||||
get bsky() {
|
||||
return this.testNet.bsky
|
||||
}
|
||||
|
||||
get plc() {
|
||||
return this.testNet.plc
|
||||
}
|
||||
@@ -81,11 +130,7 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -260,11 +305,7 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -275,24 +316,21 @@ class Mocker {
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -307,43 +345,39 @@ class Mocker {
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.51.0",
|
||||
"version": "1.52.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
@@ -18,7 +18,7 @@
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
|
||||
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build": "detox build -c ios.sim.debug",
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
@@ -188,7 +188,7 @@
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"babel-plugin-react-native-web": "^0.18.12",
|
||||
"detox": "^20.11.3",
|
||||
"detox": "^20.13.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-detox": "^1.0.0",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
@@ -215,7 +215,8 @@
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18"
|
||||
"@types/react": "^18",
|
||||
"**/zeed-dom": "estrattonbailey/zeed-dom#publish"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
@@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
||||
|
||||
export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
let urip
|
||||
try {
|
||||
const urip = new URL(uri)
|
||||
return labelDomain !== urip.hostname
|
||||
urip = new URL(uri)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
|
||||
if (urip.hostname === 'bsky.app') {
|
||||
// if this is a link to internal content,
|
||||
// warn if it represents itself as a URL to another app
|
||||
if (
|
||||
labelDomain &&
|
||||
labelDomain !== 'bsky.app' &&
|
||||
isPossiblyAUrl(labelDomain)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// if this is a link to external content,
|
||||
// warn if the label doesnt match the target
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
return labelDomain !== urip.hostname
|
||||
}
|
||||
}
|
||||
|
||||
function labelToDomain(label: string): string | undefined {
|
||||
|
||||
Vendored
+3
-2
@@ -5,6 +5,7 @@ import {
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const MAX_SYNC_PAGES = 10
|
||||
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||
@@ -56,7 +57,7 @@ export class MyFollowsCache {
|
||||
* Syncs a subset of the user's follows
|
||||
* for performance reasons, caps out at 1000 follows
|
||||
*/
|
||||
async syncIfNeeded() {
|
||||
syncIfNeeded = bundleAsync(async () => {
|
||||
if (this.lastSync > Date.now() - SYNC_TTL) {
|
||||
return
|
||||
}
|
||||
@@ -81,7 +82,7 @@ export class MyFollowsCache {
|
||||
}
|
||||
|
||||
this.lastSync = Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
getFollowState(did: string): FollowState {
|
||||
if (typeof this.byDid[did] === 'undefined') {
|
||||
|
||||
@@ -25,13 +25,13 @@ export class MeModel {
|
||||
savedFeeds: SavedFeedsModel
|
||||
notifications: NotificationsFeedModel
|
||||
follows: MyFollowsCache
|
||||
invites: ComAtprotoServerDefs.InviteCode[] | null = []
|
||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
||||
lastProfileStateUpdate = Date.now()
|
||||
lastNotifsUpdate = Date.now()
|
||||
|
||||
get invitesAvailable() {
|
||||
return this.invites?.filter(isInviteAvailable).length || null
|
||||
return this.invites.filter(isInviteAvailable).length
|
||||
}
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
@@ -180,9 +180,7 @@ export class MeModel {
|
||||
} catch (e) {
|
||||
this.rootStore.log.error('Failed to fetch user invite codes', e)
|
||||
}
|
||||
if (this.invites) {
|
||||
await this.rootStore.invitedUsers.fetch(this.invites)
|
||||
}
|
||||
await this.rootStore.invitedUsers.fetch(this.invites)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
async crop() {
|
||||
try {
|
||||
// NOTE
|
||||
// on ios, react-native-image-cropper gives really bad quality
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
|
||||
@@ -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 {toHashCode} from 'lib/strings/helpers'
|
||||
|
||||
const DAY = 60e3 * 24 * 1 // 1 day (ms)
|
||||
|
||||
export class Reminders {
|
||||
lastEmailConfirm: Date = new Date()
|
||||
lastEmailConfirm: Date | null = null
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
@@ -45,6 +43,10 @@ export class Reminders {
|
||||
if (this.rootStore.onboarding.isActive) {
|
||||
return false
|
||||
}
|
||||
// only prompt once
|
||||
if (this.lastEmailConfirm) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
// shard the users into 2 day of the week buckets
|
||||
// (this is to avoid a sudden influx of email updates when
|
||||
@@ -53,9 +55,7 @@ export class Reminders {
|
||||
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
|
||||
return false
|
||||
}
|
||||
// only ask once a day at most, but because of the bucketing
|
||||
// this will be more like weekly
|
||||
return Number(today) - Number(this.lastEmailConfirm) > DAY
|
||||
return true
|
||||
}
|
||||
|
||||
setEmailConfirmationRequested() {
|
||||
|
||||
@@ -219,16 +219,19 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
!segment.facet ? pal.text : pal.link,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||
return (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
segment.facet && !isTag ? pal.link : pal.text,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
}, [richtext, pal.link, pal.text])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, {MutableRefObject, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
|
||||
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
@@ -12,11 +11,7 @@ import Animated, {
|
||||
withDecay,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
GestureDetector,
|
||||
Gesture,
|
||||
GestureType,
|
||||
} from 'react-native-gesture-handler'
|
||||
import {GestureDetector, Gesture} from 'react-native-gesture-handler'
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
import {
|
||||
createTransform,
|
||||
@@ -40,7 +35,6 @@ type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
const ImageItem = ({
|
||||
@@ -48,7 +42,6 @@ const ImageItem = ({
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
isScrollViewBeingDragged,
|
||||
pinchGestureRef,
|
||||
}: Props) => {
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
@@ -140,28 +133,7 @@ const ImageItem = ({
|
||||
return [dx, dy]
|
||||
}
|
||||
|
||||
// This is a hack.
|
||||
// We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
|
||||
// However, there is no great reliable way to coordinate this yet in RGNH.
|
||||
// This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
|
||||
const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
|
||||
if (isScrollViewBeingDragged) {
|
||||
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||
manager.activate()
|
||||
return
|
||||
}
|
||||
const measurement = measure(containerRef)
|
||||
if (!measurement || measurement.pageX !== 0) {
|
||||
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||
manager.activate()
|
||||
return
|
||||
}
|
||||
// Fail this "fake" gesture so that the gestures after it can proceed.
|
||||
manager.fail()
|
||||
})
|
||||
|
||||
const pinch = Gesture.Pinch()
|
||||
.withRef(pinchGestureRef)
|
||||
.onStart(e => {
|
||||
pinchOrigin.value = {
|
||||
x: e.focalX - SCREEN.width / 2,
|
||||
@@ -318,22 +290,26 @@ const ImageItem = ({
|
||||
}
|
||||
})
|
||||
|
||||
const composedGesture = isScrollViewBeingDragged
|
||||
? // If the parent is not at rest, provide a no-op gesture.
|
||||
Gesture.Manual()
|
||||
: Gesture.Exclusive(
|
||||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
)
|
||||
|
||||
const isLoading = !isLoaded || !imageDimensions
|
||||
return (
|
||||
<Animated.View ref={containerRef} style={styles.container}>
|
||||
{isLoading && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<GestureDetector
|
||||
gesture={Gesture.Exclusive(
|
||||
consumeHScroll,
|
||||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
)}>
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<AnimatedImage
|
||||
source={imageSrc}
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
|
||||
@@ -6,21 +6,25 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
||||
import React, {useCallback, useState} from 'react'
|
||||
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
NativeTouchEvent,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {GestureType} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
|
||||
@@ -31,16 +35,13 @@ const DOUBLE_TAP_DELAY = 300
|
||||
const SWIPE_CLOSE_OFFSET = 75
|
||||
const SWIPE_CLOSE_VELOCITY = 1
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
const MIN_ZOOM = 2
|
||||
const MAX_SCALE = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
const MIN_DOUBLE_TAP_SCALE = 2
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
@@ -49,44 +50,42 @@ const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
let lastTapTS: number | null = null
|
||||
|
||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const translationY = useSharedValue(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [scaled, setScaled] = useState(false)
|
||||
const imageDimensions = useImageDimensions(imageSrc)
|
||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||
const [scrollValueY] = useState(() => new Animated.Value(0))
|
||||
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
||||
const maxZoomScale = imageDimensions
|
||||
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||
: 1
|
||||
|
||||
const imageOpacity = scrollValueY.interpolate({
|
||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
outputRange: [0.5, 1, 0.5],
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
translationY.value,
|
||||
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
[0.5, 1, 0.5],
|
||||
),
|
||||
}
|
||||
})
|
||||
const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1)
|
||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
||||
|
||||
const onScrollEndDrag = useCallback(
|
||||
({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
||||
const currentScaled = nativeEvent?.zoomScale > 1
|
||||
|
||||
onZoom(currentScaled)
|
||||
setScaled(currentScaled)
|
||||
|
||||
if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||
onRequestClose()
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y
|
||||
},
|
||||
onEndDrag(e) {
|
||||
const velocityY = e.velocity?.y ?? 0
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||
runOnJS(onRequestClose)()
|
||||
}
|
||||
},
|
||||
[onRequestClose, onZoom],
|
||||
)
|
||||
})
|
||||
|
||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||
|
||||
if (nativeEvent?.zoomScale > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollValueY.setValue(offsetY)
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
onZoom(nextIsScaled)
|
||||
setScaled(nextIsScaled)
|
||||
}
|
||||
|
||||
const handleDoubleTap = useCallback(
|
||||
@@ -121,23 +120,21 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||
lastTapTS = nowTS
|
||||
}
|
||||
},
|
||||
[imageDimensions, scaled],
|
||||
[imageDimensions, scaled, scrollViewRef],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
// @ts-ignore Something's up with the types here
|
||||
ref={scrollViewRef}
|
||||
style={styles.listItem}
|
||||
pinchGestureEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
maximumZoomScale={maxScrollViewZoom}
|
||||
maximumZoomScale={maxZoomScale}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={true}
|
||||
onScroll={onScroll}
|
||||
onScrollEndDrag={onScrollEndDrag}
|
||||
scrollEventThrottle={1}>
|
||||
onScroll={scrollHandler}>
|
||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||
<TouchableWithoutFeedback
|
||||
onPress={handleDoubleTap}
|
||||
@@ -145,23 +142,29 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint="">
|
||||
<AnimatedImage
|
||||
source={imageSrc}
|
||||
style={imageStylesWithOpacity}
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
listItem: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
image: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -191,7 +194,7 @@ const getZoomRectAfterDoubleTap = (
|
||||
const zoom = Math.max(
|
||||
imageAspect / screenAspect,
|
||||
screenAspect / imageAspect,
|
||||
MIN_ZOOM,
|
||||
MIN_DOUBLE_TAP_SCALE,
|
||||
)
|
||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||
@@ -253,61 +256,4 @@ const getZoomRectAfterDoubleTap = (
|
||||
}
|
||||
}
|
||||
|
||||
const getImageStyles = (
|
||||
image: ImageDimensions | null,
|
||||
translate: {readonly x: number; readonly y: number} | undefined,
|
||||
scale?: number,
|
||||
) => {
|
||||
if (!image?.width || !image?.height) {
|
||||
return {width: 0, height: 0}
|
||||
}
|
||||
const transform = []
|
||||
if (translate) {
|
||||
transform.push({translateX: translate.x})
|
||||
transform.push({translateY: translate.y})
|
||||
}
|
||||
if (scale) {
|
||||
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
|
||||
transform.push({scale}, {perspective: new Animated.Value(1000)})
|
||||
}
|
||||
return {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
transform,
|
||||
}
|
||||
}
|
||||
|
||||
const getImageTransform = (
|
||||
image: ImageDimensions | null,
|
||||
screen: ImageDimensions,
|
||||
) => {
|
||||
if (!image?.width || !image?.height) {
|
||||
return [] as const
|
||||
}
|
||||
|
||||
const wScale = screen.width / image.width
|
||||
const hScale = screen.height / image.height
|
||||
const scale = Math.min(wScale, hScale)
|
||||
const {x, y} = getImageTranslate(image, screen)
|
||||
|
||||
return [{x, y}, scale] as const
|
||||
}
|
||||
|
||||
const getImageTranslate = (
|
||||
image: ImageDimensions,
|
||||
screen: ImageDimensions,
|
||||
): {x: number; y: number} => {
|
||||
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
|
||||
const imageSize = axis === 'x' ? image.width : image.height
|
||||
const screenSize = axis === 'x' ? screen.width : screen.height
|
||||
|
||||
return (screenSize - imageSize) / 2
|
||||
}
|
||||
|
||||
return {
|
||||
x: getTranslateForAxis('x'),
|
||||
y: getTranslateForAxis('y'),
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(ImageItem)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// default implementation fallback for web
|
||||
|
||||
import React, {MutableRefObject} from 'react'
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {GestureType} from 'react-native-gesture-handler'
|
||||
import {ImageSource} from '../../@types'
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
|
||||
return new Promise(resolve => {
|
||||
if (typeof image === 'number') {
|
||||
const cacheKey = `${image}`
|
||||
let imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||
|
||||
if (!imageDimensions) {
|
||||
const {width, height} = Image.resolveAssetSource(image)
|
||||
imageDimensions = {width, height}
|
||||
imageDimensionsCache.set(cacheKey, imageDimensions)
|
||||
}
|
||||
|
||||
resolve(imageDimensions)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (image.uri) {
|
||||
const source = image as ImageURISource
|
||||
|
||||
const cacheKey = source.uri as string
|
||||
|
||||
const imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||
|
||||
if (imageDimensions) {
|
||||
resolve(imageDimensions)
|
||||
} else {
|
||||
|
||||
@@ -8,121 +8,60 @@
|
||||
// Original code copied and simplified from the link below as the codebase is currently not maintained:
|
||||
// https://github.com/jobtoday/react-native-image-viewing
|
||||
|
||||
import React, {
|
||||
ComponentType,
|
||||
createRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
StyleSheet,
|
||||
View,
|
||||
VirtualizedList,
|
||||
ModalProps,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import {ModalsContainer} from '../../modals/Modal'
|
||||
import React, {ComponentType, useCallback, useMemo, useState} from 'react'
|
||||
import {StyleSheet, View, Platform} from 'react-native'
|
||||
|
||||
import ImageItem from './components/ImageItem/ImageItem'
|
||||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||
|
||||
import {ImageSource} from './@types'
|
||||
import {ScrollView, GestureType} from 'react-native-gesture-handler'
|
||||
import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
|
||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
|
||||
type Props = {
|
||||
images: ImageSource[]
|
||||
keyExtractor?: (imageSrc: ImageSource, index: number) => string
|
||||
imageIndex: number
|
||||
initialImageIndex: number
|
||||
visible: boolean
|
||||
onRequestClose: () => void
|
||||
presentationStyle?: ModalProps['presentationStyle']
|
||||
animationType?: ModalProps['animationType']
|
||||
backgroundColor?: string
|
||||
HeaderComponent?: ComponentType<{imageIndex: number}>
|
||||
FooterComponent?: ComponentType<{imageIndex: number}>
|
||||
}
|
||||
|
||||
const DEFAULT_BG_COLOR = '#000'
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const INITIAL_POSITION = {x: 0, y: 0}
|
||||
const ANIMATION_CONFIG = {
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}
|
||||
|
||||
function ImageViewing({
|
||||
images,
|
||||
keyExtractor,
|
||||
imageIndex,
|
||||
initialImageIndex,
|
||||
visible,
|
||||
onRequestClose,
|
||||
backgroundColor = DEFAULT_BG_COLOR,
|
||||
HeaderComponent,
|
||||
FooterComponent,
|
||||
}: Props) {
|
||||
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
||||
const [headerTranslate] = useState(
|
||||
() => new Animated.ValueXY(INITIAL_POSITION),
|
||||
)
|
||||
const [footerTranslate] = useState(
|
||||
() => new Animated.ValueXY(INITIAL_POSITION),
|
||||
)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
|
||||
const toggleBarsVisible = (isVisible: boolean) => {
|
||||
if (isVisible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
||||
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
||||
]).start()
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(headerTranslate.y, {
|
||||
...ANIMATION_CONFIG,
|
||||
toValue: -300,
|
||||
}),
|
||||
Animated.timing(footerTranslate.y, {
|
||||
...ANIMATION_CONFIG,
|
||||
toValue: 300,
|
||||
}),
|
||||
]).start()
|
||||
}
|
||||
}
|
||||
|
||||
const onRequestCloseEnhanced = () => {
|
||||
setOpacity(0)
|
||||
onRequestClose()
|
||||
setTimeout(() => setOpacity(1), 0)
|
||||
}
|
||||
|
||||
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const {
|
||||
nativeEvent: {
|
||||
contentOffset: {x: scrollX},
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(isScaled ? -300 : 0),
|
||||
},
|
||||
} = event
|
||||
],
|
||||
}))
|
||||
const animatedFooterStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(isScaled ? 300 : 0),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
if (SCREEN.width) {
|
||||
const nextIndex = Math.round(scrollX / SCREEN.width)
|
||||
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const onZoom = (nextIsScaled: boolean) => {
|
||||
toggleBarsVisible(!nextIsScaled)
|
||||
setIsScaled(false)
|
||||
}
|
||||
const onZoom = useCallback((nextIsScaled: boolean) => {
|
||||
setIsScaled(nextIsScaled)
|
||||
}, [])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
@@ -131,100 +70,53 @@ function ImageViewing({
|
||||
return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
|
||||
}, [])
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
if (imageIndex) {
|
||||
imageList.current?.scrollToIndex({index: imageIndex, animated: false})
|
||||
}
|
||||
}, [imageList, imageIndex])
|
||||
|
||||
// This is a hack.
|
||||
// RNGH doesn't have an easy way to express that pinch of individual items
|
||||
// should "steal" all pinches from the scroll view. So we're keeping a ref
|
||||
// to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
|
||||
const [pinchGestureRefs] = useState(new Map())
|
||||
for (let imageSrc of images) {
|
||||
if (!pinchGestureRefs.get(imageSrc)) {
|
||||
pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const headerTransform = headerTranslate.getTranslateTransform()
|
||||
const footerTransform = footerTranslate.getTranslateTransform()
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.screen}
|
||||
onLayout={onLayout}
|
||||
edges={edges}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<ModalsContainer />
|
||||
<View style={[styles.container, {opacity, backgroundColor}]}>
|
||||
<Animated.View style={[styles.header, {transform: headerTransform}]}>
|
||||
<View style={[styles.container, {backgroundColor}]}>
|
||||
<Animated.View style={[styles.header, animatedHeaderStyle]}>
|
||||
{typeof HeaderComponent !== 'undefined' ? (
|
||||
React.createElement(HeaderComponent, {
|
||||
imageIndex: currentImageIndex,
|
||||
imageIndex,
|
||||
})
|
||||
) : (
|
||||
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
|
||||
<ImageDefaultHeader onRequestClose={onRequestClose} />
|
||||
)}
|
||||
</Animated.View>
|
||||
<VirtualizedList
|
||||
ref={imageList}
|
||||
data={images}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
scrollEnabled={!isScaled || isDragging}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
getItem={(_, index) => images[index]}
|
||||
getItemCount={() => images.length}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: SCREEN_WIDTH,
|
||||
offset: SCREEN_WIDTH * index,
|
||||
index,
|
||||
})}
|
||||
renderItem={({item: imageSrc}) => (
|
||||
<ImageItem
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestCloseEnhanced}
|
||||
pinchGestureRef={pinchGestureRefs.get(imageSrc)}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
/>
|
||||
)}
|
||||
renderScrollComponent={props => (
|
||||
<ScrollView
|
||||
{...props}
|
||||
waitFor={Array.from(pinchGestureRefs.values())}
|
||||
/>
|
||||
)}
|
||||
onScrollBeginDrag={() => {
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onScrollEndDrag={() => {
|
||||
setIsDragging(false)
|
||||
}}
|
||||
onMomentumScrollEnd={e => {
|
||||
<PagerView
|
||||
scrollEnabled={!isScaled}
|
||||
initialPage={initialImageIndex}
|
||||
onPageSelected={e => {
|
||||
setImageIndex(e.nativeEvent.position)
|
||||
setIsScaled(false)
|
||||
onScroll(e)
|
||||
}}
|
||||
//@ts-ignore
|
||||
keyExtractor={(imageSrc, index) =>
|
||||
keyExtractor
|
||||
? keyExtractor(imageSrc, index)
|
||||
: typeof imageSrc === 'number'
|
||||
? `${imageSrc}`
|
||||
: imageSrc.uri
|
||||
}
|
||||
/>
|
||||
onPageScrollStateChanged={e => {
|
||||
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
|
||||
}}
|
||||
overdrag={true}
|
||||
style={styles.pager}>
|
||||
{images.map(imageSrc => (
|
||||
<View key={imageSrc.uri}>
|
||||
<ImageItem
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestClose}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
{typeof FooterComponent !== 'undefined' && (
|
||||
<Animated.View style={[styles.footer, {transform: footerTransform}]}>
|
||||
<Animated.View style={[styles.footer, animatedFooterStyle]}>
|
||||
{React.createElement(FooterComponent, {
|
||||
imageIndex: currentImageIndex,
|
||||
imageIndex,
|
||||
})}
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -236,11 +128,18 @@ function ImageViewing({
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
pager: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
@@ -257,7 +156,12 @@ const styles = StyleSheet.create({
|
||||
})
|
||||
|
||||
const EnhancedImageViewing = (props: Props) => (
|
||||
<ImageViewing key={props.imageIndex} {...props} />
|
||||
<ImageViewing key={props.initialImageIndex} {...props} />
|
||||
)
|
||||
|
||||
function withClampedSpring(value: any) {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
}
|
||||
|
||||
export default EnhancedImageViewing
|
||||
|
||||
@@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() {
|
||||
return (
|
||||
<ImageView
|
||||
images={[{uri: opts.profileView.avatar || ''}]}
|
||||
imageIndex={0}
|
||||
initialImageIndex={0}
|
||||
visible
|
||||
onRequestClose={onClose}
|
||||
FooterComponent={LightboxFooter}
|
||||
@@ -37,7 +37,7 @@ export const Lightbox = observer(function Lightbox() {
|
||||
return (
|
||||
<ImageView
|
||||
images={opts.images.map(img => ({...img}))}
|
||||
imageIndex={opts.index}
|
||||
initialImageIndex={opts.index}
|
||||
visible
|
||||
onRequestClose={onClose}
|
||||
FooterComponent={LightboxFooter}
|
||||
|
||||
+115
-129
@@ -1,11 +1,5 @@
|
||||
import React, {useState} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
SafeAreaView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={[pal.view, styles.container]}>
|
||||
<SafeAreaView style={s.flex1}>
|
||||
<ScrollView
|
||||
testID="changeEmailModal"
|
||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||
<View style={styles.titleSection}>
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{stage === Stages.InputEmail ? 'Change Your Email' : ''}
|
||||
{stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
|
||||
{stage === Stages.Done ? 'Email Updated' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||
{stage === Stages.InputEmail ? (
|
||||
<>Enter your new email address below.</>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<>
|
||||
An email has been sent to your previous address,{' '}
|
||||
{store.session.currentSession?.email || ''}. It includes a
|
||||
confirmation code which you can enter below.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Your email has been updated but not verified. As a next step,
|
||||
please verify your new email.
|
||||
</>
|
||||
)}
|
||||
<SafeAreaView style={[pal.view, s.flex1]}>
|
||||
<ScrollView
|
||||
testID="changeEmailModal"
|
||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||
<View style={styles.titleSection}>
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{stage === Stages.InputEmail ? 'Change Your Email' : ''}
|
||||
{stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
|
||||
{stage === Stages.Done ? 'Email Updated' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{stage === Stages.InputEmail && (
|
||||
<TextInput
|
||||
testID="emailInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="alice@mail.com"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
accessible={true}
|
||||
accessibilityLabel="Email"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
)}
|
||||
{stage === Stages.ConfirmCode && (
|
||||
<TextInput
|
||||
testID="confirmCodeInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={confirmationCode}
|
||||
onChangeText={setConfirmationCode}
|
||||
accessible={true}
|
||||
accessibilityLabel="Confirmation code"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||
{stage === Stages.InputEmail ? (
|
||||
<>Enter your new email address below.</>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<>
|
||||
An email has been sent to your previous address,{' '}
|
||||
{store.session.currentSession?.email || ''}. It includes a
|
||||
confirmation code which you can enter below.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Your email has been updated but not verified. As a next step,
|
||||
please verify your new email.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<ErrorMessage message={error} style={styles.error} />
|
||||
) : undefined}
|
||||
{stage === Stages.InputEmail && (
|
||||
<TextInput
|
||||
testID="emailInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="alice@mail.com"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
accessible={true}
|
||||
accessibilityLabel="Email"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
)}
|
||||
{stage === Stages.ConfirmCode && (
|
||||
<TextInput
|
||||
testID="confirmCodeInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={confirmationCode}
|
||||
onChangeText={setConfirmationCode}
|
||||
accessible={true}
|
||||
accessibilityLabel="Confirmation code"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[styles.btnContainer]}>
|
||||
{isProcessing ? (
|
||||
<View style={styles.btn}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={{gap: 6}}>
|
||||
{stage === Stages.InputEmail && (
|
||||
<Button
|
||||
testID="requestChangeBtn"
|
||||
type="primary"
|
||||
onPress={onRequestChange}
|
||||
accessibilityLabel="Request Change"
|
||||
accessibilityHint=""
|
||||
label="Request Change"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
)}
|
||||
{stage === Stages.ConfirmCode && (
|
||||
<Button
|
||||
testID="confirmBtn"
|
||||
type="primary"
|
||||
onPress={onConfirm}
|
||||
accessibilityLabel="Confirm Change"
|
||||
accessibilityHint=""
|
||||
label="Confirm Change"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
)}
|
||||
{stage === Stages.Done && (
|
||||
<Button
|
||||
testID="verifyBtn"
|
||||
type="primary"
|
||||
onPress={onVerify}
|
||||
accessibilityLabel="Verify New Email"
|
||||
accessibilityHint=""
|
||||
label="Verify New Email"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
)}
|
||||
{error ? (
|
||||
<ErrorMessage message={error} style={styles.error} />
|
||||
) : undefined}
|
||||
|
||||
<View style={[styles.btnContainer]}>
|
||||
{isProcessing ? (
|
||||
<View style={styles.btn}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={{gap: 6}}>
|
||||
{stage === Stages.InputEmail && (
|
||||
<Button
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={() => store.shell.closeModal()}
|
||||
accessibilityLabel="Cancel"
|
||||
testID="requestChangeBtn"
|
||||
type="primary"
|
||||
onPress={onRequestChange}
|
||||
accessibilityLabel="Request Change"
|
||||
accessibilityHint=""
|
||||
label="Cancel"
|
||||
label="Request Change"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
{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
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={() => store.shell.closeModal()}
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityHint=""
|
||||
label="Cancel"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: isWeb ? 0 : 40,
|
||||
},
|
||||
titleSection: {
|
||||
paddingTop: isWeb ? 0 : 4,
|
||||
paddingBottom: isWeb ? 14 : 10,
|
||||
|
||||
@@ -26,33 +26,6 @@ export function Component({}: {}) {
|
||||
store.shell.closeModal()
|
||||
}, [store])
|
||||
|
||||
if (store.me.invites === null) {
|
||||
return (
|
||||
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
|
||||
<Text type="title-xl" style={[styles.title, pal.text]}>
|
||||
Error
|
||||
</Text>
|
||||
<Text type="lg" style={[styles.description, pal.text]}>
|
||||
An error occurred while loading invite codes.
|
||||
</Text>
|
||||
<View style={styles.flex1} />
|
||||
<View
|
||||
style={[
|
||||
styles.btnContainer,
|
||||
isTabletOrDesktop && styles.btnContainerDesktop,
|
||||
]}>
|
||||
<Button
|
||||
type="primary"
|
||||
label="Done"
|
||||
style={styles.btn}
|
||||
labelStyle={styles.btnLabel}
|
||||
onPress={onClose}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (store.me.invites.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
|
||||
|
||||
+144
-155
@@ -1,7 +1,6 @@
|
||||
import React, {useState} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Pressable,
|
||||
SafeAreaView,
|
||||
StyleSheet,
|
||||
@@ -82,169 +81,163 @@ export const Component = observer(function Component({
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={[pal.view, styles.container]}>
|
||||
<SafeAreaView style={s.flex1}>
|
||||
<ScrollView
|
||||
testID="verifyEmailModal"
|
||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||
{stage === Stages.Reminder && <ReminderIllustration />}
|
||||
<View style={styles.titleSection}>
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
|
||||
{stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
|
||||
{stage === Stages.Email ? 'Verify Your Email' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||
{stage === Stages.Reminder ? (
|
||||
<>
|
||||
Your email has not yet been verified. This is an important
|
||||
security step which we recommend.
|
||||
</>
|
||||
) : stage === Stages.Email ? (
|
||||
<>
|
||||
This is important in case you ever need to change your email or
|
||||
reset your password.
|
||||
</>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<>
|
||||
An email has been sent to{' '}
|
||||
{store.session.currentSession?.email || ''}. It includes a
|
||||
confirmation code which you can enter below.
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<SafeAreaView style={[pal.view, s.flex1]}>
|
||||
<ScrollView
|
||||
testID="verifyEmailModal"
|
||||
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||
{stage === Stages.Reminder && <ReminderIllustration />}
|
||||
<View style={styles.titleSection}>
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
|
||||
{stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
|
||||
{stage === Stages.Email ? 'Verify Your Email' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{stage === Stages.Email ? (
|
||||
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
|
||||
{stage === Stages.Reminder ? (
|
||||
<>
|
||||
<View style={styles.emailContainer}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
color={pal.colors.text}
|
||||
size={16}
|
||||
/>
|
||||
<Text
|
||||
type="xl-medium"
|
||||
style={[pal.text, s.flex1, {minWidth: 0}]}>
|
||||
{store.session.currentSession?.email || ''}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Change my email"
|
||||
accessibilityHint=""
|
||||
onPress={onEmailIncorrect}
|
||||
style={styles.changeEmailLink}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
Change
|
||||
</Text>
|
||||
</Pressable>
|
||||
Your email has not yet been verified. This is an important
|
||||
security step which we recommend.
|
||||
</>
|
||||
) : stage === Stages.Email ? (
|
||||
<>
|
||||
This is important in case you ever need to change your email or
|
||||
reset your password.
|
||||
</>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<TextInput
|
||||
testID="confirmCodeInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={confirmationCode}
|
||||
onChangeText={setConfirmationCode}
|
||||
accessible={true}
|
||||
accessibilityLabel="Confirmation code"
|
||||
<>
|
||||
An email has been sent to{' '}
|
||||
{store.session.currentSession?.email || ''}. It includes a
|
||||
confirmation code which you can enter below.
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{stage === Stages.Email ? (
|
||||
<>
|
||||
<View style={styles.emailContainer}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
color={pal.colors.text}
|
||||
size={16}
|
||||
/>
|
||||
<Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
|
||||
{store.session.currentSession?.email || ''}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Change my email"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
) : undefined}
|
||||
onPress={onEmailIncorrect}
|
||||
style={styles.changeEmailLink}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
Change
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<TextInput
|
||||
testID="confirmCodeInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={confirmationCode}
|
||||
onChangeText={setConfirmationCode}
|
||||
accessible={true}
|
||||
accessibilityLabel="Confirmation code"
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
{error ? (
|
||||
<ErrorMessage message={error} style={styles.error} />
|
||||
) : undefined}
|
||||
{error ? (
|
||||
<ErrorMessage message={error} style={styles.error} />
|
||||
) : undefined}
|
||||
|
||||
<View style={[styles.btnContainer]}>
|
||||
{isProcessing ? (
|
||||
<View style={styles.btn}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={{gap: 6}}>
|
||||
{stage === Stages.Reminder && (
|
||||
<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]}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.btnContainer]}>
|
||||
{isProcessing ? (
|
||||
<View style={styles.btn}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={{gap: 6}}>
|
||||
{stage === Stages.Reminder && (
|
||||
<Button
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={() => store.shell.closeModal()}
|
||||
accessibilityLabel={
|
||||
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
|
||||
}
|
||||
testID="getStartedBtn"
|
||||
type="primary"
|
||||
onPress={() => setStage(Stages.Email)}
|
||||
accessibilityLabel="Get Started"
|
||||
accessibilityHint=""
|
||||
label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
|
||||
label="Get Started"
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
{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
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={() => store.shell.closeModal()}
|
||||
accessibilityLabel={
|
||||
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
|
||||
}
|
||||
accessibilityHint=""
|
||||
label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -274,10 +267,6 @@ function ReminderIllustration() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: isWeb ? 0 : 40,
|
||||
},
|
||||
titleSection: {
|
||||
paddingTop: isWeb ? 0 : 4,
|
||||
paddingBottom: isWeb ? 14 : 10,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Feed = observer(function Feed({
|
||||
onPressTryAgain?: () => void
|
||||
onScroll?: OnScrollCb
|
||||
scrollEventThrottle?: number
|
||||
renderEmptyState?: () => JSX.Element
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
@@ -116,10 +116,7 @@ export const Feed = observer(function Feed({
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_FEED_ITEM) {
|
||||
if (renderEmptyState) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
return <View />
|
||||
return renderEmptyState()
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
|
||||
@@ -160,7 +160,7 @@ const FeedPage = observer(function FeedPageImpl({
|
||||
testID?: string
|
||||
feed: PostsFeedModel
|
||||
isPageFocused: boolean
|
||||
renderEmptyState?: () => JSX.Element
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const store = useStores()
|
||||
|
||||
@@ -322,45 +322,37 @@ export const SettingsScreen = withAuthRequired(
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
{store.me.invitesAvailable !== null && (
|
||||
<>
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
Invite a Friend
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
testID="inviteFriendBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitching && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitching ? undefined : onPressInviteCodes}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Invite"
|
||||
accessibilityHint="Opens invite code list">
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={
|
||||
(store.me.invitesAvailable > 0
|
||||
? primaryText
|
||||
: pal.text) as FontAwesomeIconStyle
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
type="lg"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
Invite a Friend
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
testID="inviteFriendBtn"
|
||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||
onPress={isSwitching ? undefined : onPressInviteCodes}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Invite"
|
||||
accessibilityHint="Opens invite code list">
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={
|
||||
(store.me.invitesAvailable > 0
|
||||
? primaryText
|
||||
: pal.text) as FontAwesomeIconStyle
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
type="lg"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
|
||||
+26
-28
@@ -426,34 +426,32 @@ const InviteCodes = observer(function InviteCodesImpl({
|
||||
store.shell.openModal({name: 'invite-codes'})
|
||||
}, [store, track])
|
||||
return (
|
||||
store.me.invitesAvailable !== null && (
|
||||
<TouchableOpacity
|
||||
testID="menuItemInviteCodes"
|
||||
style={[styles.inviteCodes, style]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
invitesAvailable === 1
|
||||
? 'Invite codes: 1 available'
|
||||
: `Invite codes: ${invitesAvailable} available`
|
||||
}
|
||||
accessibilityHint="Opens list of invite codes">
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={18}
|
||||
/>
|
||||
<Text
|
||||
type="lg-medium"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
<TouchableOpacity
|
||||
testID="menuItemInviteCodes"
|
||||
style={[styles.inviteCodes, style]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
invitesAvailable === 1
|
||||
? 'Invite codes: 1 available'
|
||||
: `Invite codes: ${invitesAvailable} available`
|
||||
}
|
||||
accessibilityHint="Opens list of invite codes">
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={18}
|
||||
/>
|
||||
<Text
|
||||
type="lg-medium"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {DesktopSearch} from './Search'
|
||||
import {DesktopFeeds} from './Feeds'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
|
||||
import {s} from 'lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
@@ -90,41 +89,32 @@ const InviteCodes = observer(function InviteCodesImpl() {
|
||||
const onPress = React.useCallback(() => {
|
||||
store.shell.openModal({name: 'invite-codes'})
|
||||
}, [store])
|
||||
|
||||
return (
|
||||
<View style={[styles.separator, pal.border]}>
|
||||
{store.me.invitesAvailable === null ? (
|
||||
<View style={[s.p10]}>
|
||||
<LoadingPlaceholder width={186} height={32} style={[styles.br40]} />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.inviteCodes]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
invitesAvailable === 1
|
||||
? 'Invite codes: 1 available'
|
||||
: `Invite codes: ${invitesAvailable} available`
|
||||
}
|
||||
accessibilityHint="Opens list of invite codes">
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={16}
|
||||
/>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.inviteCodes, pal.border]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
invitesAvailable === 1
|
||||
? 'Invite codes: 1 available'
|
||||
: `Invite codes: ${invitesAvailable} available`
|
||||
}
|
||||
accessibilityHint="Opens list of invite codes">
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={16}
|
||||
/>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -141,20 +131,16 @@ const styles = StyleSheet.create({
|
||||
|
||||
message: {
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 12,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
messageLine: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
|
||||
separator: {
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
br40: {borderRadius: 40},
|
||||
|
||||
inviteCodes: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
@@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
||||
)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
// initial setup
|
||||
React.useEffect(() => {
|
||||
if (store.me.did) {
|
||||
autocompleteView.setup()
|
||||
}
|
||||
}, [autocompleteView, store.me.did])
|
||||
|
||||
const onChangeQuery = React.useCallback(
|
||||
(text: string) => {
|
||||
setQuery(text)
|
||||
|
||||
@@ -8145,10 +8145,10 @@ detect-port-alt@^1.1.6:
|
||||
address "^1.0.1"
|
||||
debug "^2.6.0"
|
||||
|
||||
detox@^20.11.3:
|
||||
version "20.11.3"
|
||||
resolved "https://registry.yarnpkg.com/detox/-/detox-20.11.3.tgz#56d5ea869977f5a747e1be0901b279ab953f8b7b"
|
||||
integrity sha512-kdoRAtDLFxXpjt1QlniI+WryMtf7Y8mrZ33Ql8cTR9qoCS/CThi4pweYAQm8yUPqAv1ZtT3eIm3EzRwjEosgLA==
|
||||
detox@^20.13.0:
|
||||
version "20.13.0"
|
||||
resolved "https://registry.yarnpkg.com/detox/-/detox-20.13.0.tgz#923111638dfdb16089eea4f07bf4f0b56468d097"
|
||||
integrity sha512-p9MUcoHWFTqSDaoaN+/hnJYdzNYqdelUr/sxzy3zLoS/qehnVJv2yG9pYqz/+gKpJaMIpw2+TVw9imdAx5JpaA==
|
||||
dependencies:
|
||||
ajv "^8.6.3"
|
||||
bunyan "^1.8.12"
|
||||
@@ -19206,10 +19206,9 @@ yocto-queue@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||
|
||||
zeed-dom@^0.9.19:
|
||||
version "0.9.26"
|
||||
resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.9.26.tgz#f0127d1024b34a1233a321bd6d0275b3ba998b30"
|
||||
integrity sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ==
|
||||
zeed-dom@^0.9.19, zeed-dom@estrattonbailey/zeed-dom#publish:
|
||||
version "0.10.8"
|
||||
resolved "https://codeload.github.com/estrattonbailey/zeed-dom/tar.gz/aad32339dc2473b75aa0a90d8baee21c40a1e914"
|
||||
dependencies:
|
||||
css-what "^6.1.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user