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:
Eric Bailey
2023-10-10 19:30:55 -05:00
35 changed files with 875 additions and 866 deletions
+1 -1
View File
@@ -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: {
+2 -2
View File
@@ -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:
+3
View File
@@ -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')
+36
View File
@@ -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
View File
@@ -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',
+92
View File
@@ -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}
}
+49
View File
@@ -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:
+9
View File
@@ -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 "$@"
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+22 -5
View File
@@ -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 {
+3 -2
View File
@@ -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') {
+3 -5
View File
@@ -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)
}
} }
} }
+1 -1
View File
@@ -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
+24
View File
@@ -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() {}
}
+6 -6
View File
@@ -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() {
+13 -10
View File
@@ -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 {
+63 -159
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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,
-27
View File
@@ -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
View File
@@ -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,
+2 -5
View File
@@ -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
+1 -1
View File
@@ -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()
+31 -39
View File
@@ -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
View File
@@ -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>
)
) )
}) })
+29 -43
View File
@@ -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',
}, },
+7
View File
@@ -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)
+7 -8
View File
@@ -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"