Merge remote-tracking branch 'origin/main' into eric/app-864-integrate-post-tags-into-app
* origin/main: (40 commits) 1.52 README: tweaks to high-level context (#1625) Fix stuck lightbox header after double tap (#1627) Fix: add padding to the spinner bottom while loading threads (#1626) Rewrite Android lightbox (#1624) Dont trim before posting (close #1621) (#1622) Only listen to back button on android (#1623) Improve typeahead search with inclusion of followed users (temporary solution) (#1612) Slightly smaller highlighted post text (#1608) Pull upstream bugfixes to bottom-sheet (#1606) Fix animations and gestures getting reset on state updates in the lightbox (#1618) Remove unused lightbox options (#1616) Profile UI tweaks (#1607) Fix invite codes flash on desktop, use loading placeholder (#1591) Update to react-native@0.72.5 (#1599) Fixed a typo on the onboarding recommended screen (#1604) Onboarding & feed fixes (#1602) Improve time to content in the search page (#1603) Fix a potential reference error in bottombarweb (#1600) Fix: only use scroll-positioning control on thread when looking at replies (#1587) ...
This commit is contained in:
@@ -1,24 +1,33 @@
|
|||||||
# Bluesky Social App
|
# Bluesky Social App
|
||||||
|
|
||||||
Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com).
|
Welcome friends! This is the codebase for the Bluesky Social app.
|
||||||
|
|
||||||
|
Get the app itself:
|
||||||
|
|
||||||
- **Web: [bsky.app](https://bsky.app)**
|
- **Web: [bsky.app](https://bsky.app)**
|
||||||
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
|
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
|
||||||
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
|
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
|
||||||
|
|
||||||
Links:
|
## Development Resources
|
||||||
|
|
||||||
- [Build instructions](./docs/build.md)
|
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).
|
||||||
- [ATProto repo](https://github.com/bluesky-social/atproto)
|
|
||||||
- [ATProto docs](https://atproto.com)
|
|
||||||
|
|
||||||
## Rules & guidelines
|
There is a small about 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.
|
||||||
|
|
||||||
ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
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:
|
||||||
|
|
||||||
---
|
- [Overview and Guides](https://atproto.com/guides/overview)
|
||||||
|
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
|
||||||
|
- [Protocol Specifications](https://atproto.com/specs/atp)
|
||||||
|
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
|
||||||
|
|
||||||
|
The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
|
||||||
|
|
||||||
|
## Contributions
|
||||||
|
|
||||||
|
> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
linkRequiresWarning,
|
||||||
|
isPossiblyAUrl,
|
||||||
|
splitApexDomain,
|
||||||
|
} from '../../../src/lib/strings/url-helpers'
|
||||||
|
|
||||||
|
describe('linkRequiresWarning', () => {
|
||||||
|
type Case = [string, string, boolean]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['http://example.com', 'http://example.com', false],
|
||||||
|
['http://example.com', 'example.com', false],
|
||||||
|
['http://example.com', 'example.com/page', false],
|
||||||
|
['http://example.com', '', true],
|
||||||
|
['http://example.com', 'other.com', true],
|
||||||
|
['http://example.com', 'http://other.com', true],
|
||||||
|
['http://example.com', 'some label', true],
|
||||||
|
['http://example.com', 'example.com more', true],
|
||||||
|
['http://example.com', 'http://example.co', true],
|
||||||
|
['http://example.co', 'http://example.com', true],
|
||||||
|
['http://example.com', 'example.co', true],
|
||||||
|
['http://example.co', 'example.com', true],
|
||||||
|
['http://site.pages.dev', 'http://site.page', true],
|
||||||
|
['http://site.page', 'http://site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'site.page', true],
|
||||||
|
['http://site.page', 'site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'http://site.pages', true],
|
||||||
|
['http://site.pages', 'http://site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'site.pages', true],
|
||||||
|
['http://site.pages', 'site.pages.dev', true],
|
||||||
|
|
||||||
|
// bad uri inputs, default to true
|
||||||
|
['', '', true],
|
||||||
|
['example.com', 'example.com', true],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'given input uri %p and text %p, returns %p',
|
||||||
|
(uri, text, expected) => {
|
||||||
|
const output = linkRequiresWarning(uri, text)
|
||||||
|
expect(output).toEqual(expected)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isPossiblyAUrl', () => {
|
||||||
|
type Case = [string, boolean]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['', false],
|
||||||
|
['text', false],
|
||||||
|
['some text', false],
|
||||||
|
['some text', false],
|
||||||
|
['some domain.com', false],
|
||||||
|
['domain.com', true],
|
||||||
|
[' domain.com', true],
|
||||||
|
['domain.com ', true],
|
||||||
|
[' domain.com ', true],
|
||||||
|
['http://domain.com', true],
|
||||||
|
[' http://domain.com', true],
|
||||||
|
['http://domain.com ', true],
|
||||||
|
[' http://domain.com ', true],
|
||||||
|
['https://domain.com', true],
|
||||||
|
[' https://domain.com', true],
|
||||||
|
['https://domain.com ', true],
|
||||||
|
[' https://domain.com ', true],
|
||||||
|
['http://domain.com/foo', true],
|
||||||
|
['http://domain.com stuff', true],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
|
||||||
|
const output = isPossiblyAUrl(str)
|
||||||
|
expect(output).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('splitApexDomain', () => {
|
||||||
|
type Case = [string, string, string]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['', '', ''],
|
||||||
|
['example.com', '', 'example.com'],
|
||||||
|
['foo.example.com', 'foo.', 'example.com'],
|
||||||
|
['foo.bar.example.com', 'foo.bar.', 'example.com'],
|
||||||
|
['example.co.uk', '', 'example.co.uk'],
|
||||||
|
['foo.example.co.uk', 'foo.', 'example.co.uk'],
|
||||||
|
['example.nonsense', '', 'example.nonsense'],
|
||||||
|
['foo.example.nonsense', '', 'foo.example.nonsense'],
|
||||||
|
['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'],
|
||||||
|
['example.com.example.com', 'example.com.', 'example.com'],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'given input uri %p, returns %p,%p',
|
||||||
|
(str, expected1, expected2) => {
|
||||||
|
const output = splitApexDomain(str)
|
||||||
|
expect(output[0]).toEqual(expected1)
|
||||||
|
expect(output[1]).toEqual(expected2)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
+3
-3
@@ -6,7 +6,7 @@ module.exports = function () {
|
|||||||
slug: 'bluesky',
|
slug: 'bluesky',
|
||||||
scheme: 'bluesky',
|
scheme: 'bluesky',
|
||||||
owner: 'blueskysocial',
|
owner: 'blueskysocial',
|
||||||
version: '1.51.0',
|
version: '1.52.0',
|
||||||
runtimeVersion: {
|
runtimeVersion: {
|
||||||
policy: 'appVersion',
|
policy: 'appVersion',
|
||||||
},
|
},
|
||||||
@@ -19,7 +19,7 @@ module.exports = function () {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
buildNumber: '5',
|
buildNumber: '1',
|
||||||
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: 39,
|
versionCode: 40,
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
foregroundImage: './assets/adaptive-icon.png',
|
foregroundImage: './assets/adaptive-icon.png',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
- brew tap wix/brew
|
- brew tap wix/brew
|
||||||
- brew install applesimutils
|
- brew install applesimutils
|
||||||
- After initial setup:
|
- After initial setup:
|
||||||
|
- Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required)
|
||||||
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
|
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
|
||||||
- Start the dev servers
|
- Start the dev servers
|
||||||
- `git clone git@github.com:bluesky-social/atproto.git`
|
- `git clone git@github.com:bluesky-social/atproto.git`
|
||||||
- `cd atproto`
|
- `cd atproto`
|
||||||
- `pnpm i`
|
- `pnpm i`
|
||||||
|
- `pnpm build`
|
||||||
- `cd packages/dev-env && pnpm start`
|
- `cd packages/dev-env && pnpm start`
|
||||||
- Run the dev app
|
- Run the dev app
|
||||||
- iOS: `yarn ios`
|
- iOS: `yarn ios`
|
||||||
@@ -119,6 +121,7 @@ upload-sourcemaps \
|
|||||||
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
|
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
|
||||||
|
|
||||||
### OTA updates
|
### OTA updates
|
||||||
|
|
||||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||||
|
|
||||||
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_id": "blueskyweb-example",
|
||||||
|
"project_number": "100000000000",
|
||||||
|
"firebase_url": "https://blueskyweb-example.firebaseio.com"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "xyz.blueskyweb.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "123456789000.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "123456789000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"analytics_service": {
|
||||||
|
"status": 1
|
||||||
|
},
|
||||||
|
"appinvite_service": {
|
||||||
|
"status": 1,
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
},
|
||||||
|
"ads_service": {
|
||||||
|
"status": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
+6
-4
@@ -25,7 +25,7 @@
|
|||||||
"build:apk": "eas build -p android --profile dev-android-apk"
|
"build:apk": "eas build -p android --profile dev-android-apk"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.6.19",
|
"@atproto/api": "^0.6.20",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||||
"@gorhom/bottom-sheet": "^4.4.7",
|
"@gorhom/bottom-sheet": "^4.5.1",
|
||||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||||
"@miblanchard/react-native-slider": "^2.3.1",
|
"@miblanchard/react-native-slider": "^2.3.1",
|
||||||
"@react-native-async-storage/async-storage": "1.18.2",
|
"@react-native-async-storage/async-storage": "1.18.2",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"@react-native-community/datetimepicker": "7.2.0",
|
"@react-native-community/datetimepicker": "7.2.0",
|
||||||
"@react-native-menu/menu": "^0.8.0",
|
"@react-native-menu/menu": "^0.8.0",
|
||||||
"@react-native-picker/picker": "2.4.10",
|
"@react-native-picker/picker": "2.4.10",
|
||||||
"@react-navigation/bottom-tabs": "^6.5.7",
|
"@react-navigation/bottom-tabs": "^6.5.9",
|
||||||
"@react-navigation/drawer": "^6.6.2",
|
"@react-navigation/drawer": "^6.6.2",
|
||||||
"@react-navigation/native": "^6.1.6",
|
"@react-navigation/native": "^6.1.6",
|
||||||
"@react-navigation/native-stack": "^6.9.12",
|
"@react-navigation/native-stack": "^6.9.12",
|
||||||
@@ -116,11 +116,12 @@
|
|||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
"patch-package": "^6.5.1",
|
"patch-package": "^6.5.1",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
|
"psl": "^1.9.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-circular-progressbar": "^2.1.0",
|
"react-circular-progressbar": "^2.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-native": "0.72.4",
|
"react-native": "0.72.5",
|
||||||
"react-native-appstate-hook": "^1.0.6",
|
"react-native-appstate-hook": "^1.0.6",
|
||||||
"react-native-draggable-flatlist": "^4.0.1",
|
"react-native-draggable-flatlist": "^4.0.1",
|
||||||
"react-native-drawer-layout": "^3.2.0",
|
"react-native-drawer-layout": "^3.2.0",
|
||||||
@@ -176,6 +177,7 @@
|
|||||||
"@types/lodash.samplesize": "^4.2.7",
|
"@types/lodash.samplesize": "^4.2.7",
|
||||||
"@types/lodash.set": "^4.3.7",
|
"@types/lodash.set": "^4.3.7",
|
||||||
"@types/lodash.shuffle": "^4.2.7",
|
"@types/lodash.shuffle": "^4.2.7",
|
||||||
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react-avatar-editor": "^13.0.0",
|
"@types/react-avatar-editor": "^13.0.0",
|
||||||
"@types/react-responsive": "^8.0.5",
|
"@types/react-responsive": "^8.0.5",
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ function TabsNavigator() {
|
|||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
initialRouteName="HomeTab"
|
initialRouteName="HomeTab"
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||||||
| undefined
|
| undefined
|
||||||
let reply
|
let reply
|
||||||
let rt = new RichText(
|
let rt = new RichText(
|
||||||
{text: opts.rawText.trim()},
|
{text: opts.rawText.trimEnd()},
|
||||||
{
|
{
|
||||||
cleanNewlines: true,
|
cleanNewlines: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-10
@@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS(
|
|||||||
serviceUrl: string,
|
serviceUrl: string,
|
||||||
resolveHandle: (name: string) => Promise<string>,
|
resolveHandle: (name: string) => Promise<string>,
|
||||||
) {
|
) {
|
||||||
|
// TODO: remove this when the test suite no longer relies on it
|
||||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||||
// local dev
|
// local dev
|
||||||
const aliceDid = await resolveHandle('alice.test')
|
const aliceDid = await resolveHandle('alice.test')
|
||||||
@@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS(
|
|||||||
} else {
|
} else {
|
||||||
// production
|
// production
|
||||||
return {
|
return {
|
||||||
pinned: [
|
pinned: [PROD_DEFAULT_FEED('whats-hot')],
|
||||||
PROD_DEFAULT_FEED('whats-hot'),
|
saved: [PROD_DEFAULT_FEED('whats-hot')],
|
||||||
PROD_DEFAULT_FEED('with-friends'),
|
|
||||||
],
|
|
||||||
saved: [
|
|
||||||
PROD_DEFAULT_FEED('bsky-team'),
|
|
||||||
PROD_DEFAULT_FEED('with-friends'),
|
|
||||||
PROD_DEFAULT_FEED('whats-hot'),
|
|
||||||
PROD_DEFAULT_FEED('hot-classic'),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {useCallback, useState} from 'react'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {AccountData} from 'state/models/session'
|
||||||
|
import {reset as resetNavigation} from '../../Navigation'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
|
||||||
|
export function useAccountSwitcher(): [
|
||||||
|
boolean,
|
||||||
|
(v: boolean) => void,
|
||||||
|
(acct: AccountData) => Promise<void>,
|
||||||
|
] {
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
|
||||||
|
const store = useStores()
|
||||||
|
const [isSwitching, setIsSwitching] = useState(false)
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const onPressSwitchAccount = useCallback(
|
||||||
|
async (acct: AccountData) => {
|
||||||
|
track('Settings:SwitchAccountButtonClicked')
|
||||||
|
setIsSwitching(true)
|
||||||
|
const success = await store.session.resumeSession(acct)
|
||||||
|
store.shell.closeAllActiveElements()
|
||||||
|
if (success) {
|
||||||
|
resetNavigation()
|
||||||
|
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
|
||||||
|
} else {
|
||||||
|
Toast.show('Sorry! We need you to enter your password.')
|
||||||
|
navigation.navigate('HomeTab')
|
||||||
|
navigation.dispatch(StackActions.popToTop())
|
||||||
|
store.session.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[track, setIsSwitching, navigation, store],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [isSwitching, setIsSwitching, onPressSwitchAccount]
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
|
|
||||||
export function useFollowDid({did}: {did: string}) {
|
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const state = store.me.follows.getFollowState(did)
|
const state = store.me.follows.getFollowState(profile.did)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
@@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) {
|
|||||||
toggle: React.useCallback(async () => {
|
toggle: React.useCallback(async () => {
|
||||||
if (state === FollowState.Following) {
|
if (state === FollowState.Following) {
|
||||||
try {
|
try {
|
||||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
await store.agent.deleteFollow(
|
||||||
store.me.follows.removeFollow(did)
|
store.me.follows.getFollowUri(profile.did),
|
||||||
|
)
|
||||||
|
store.me.follows.removeFollow(profile.did)
|
||||||
return {
|
return {
|
||||||
state: FollowState.NotFollowing,
|
state: FollowState.NotFollowing,
|
||||||
following: false,
|
following: false,
|
||||||
@@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) {
|
|||||||
}
|
}
|
||||||
} else if (state === FollowState.NotFollowing) {
|
} else if (state === FollowState.NotFollowing) {
|
||||||
try {
|
try {
|
||||||
const res = await store.agent.follow(did)
|
const res = await store.agent.follow(profile.did)
|
||||||
store.me.follows.addFollow(did, res.uri)
|
store.me.follows.addFollow(profile.did, {
|
||||||
|
followRecordUri: res.uri,
|
||||||
|
did: profile.did,
|
||||||
|
handle: profile.handle,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatar: profile.avatar,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
state: FollowState.Following,
|
state: FollowState.Following,
|
||||||
following: true,
|
following: true,
|
||||||
@@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) {
|
|||||||
state: FollowState.Unknown,
|
state: FollowState.Unknown,
|
||||||
following: false,
|
following: false,
|
||||||
}
|
}
|
||||||
}, [store, did, state]),
|
}, [store, profile, state]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,18 @@ import {useState, useCallback, useRef} from 'react'
|
|||||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||||
import {RootStoreModel} from 'state/index'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {useWebMediaQueries} from './useWebMediaQueries'
|
||||||
|
|
||||||
const DY_LIMIT_UP = isDesktopWeb ? 30 : 10
|
|
||||||
const DY_LIMIT_DOWN = isDesktopWeb ? 150 : 10
|
|
||||||
const Y_LIMIT = 10
|
const Y_LIMIT = 10
|
||||||
|
|
||||||
|
const useDeviceLimits = () => {
|
||||||
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
return {
|
||||||
|
dyLimitUp: isDesktop ? 30 : 10,
|
||||||
|
dyLimitDown: isDesktop ? 150 : 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type OnScrollCb = (
|
export type OnScrollCb = (
|
||||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||||
) => void
|
) => void
|
||||||
@@ -18,6 +24,8 @@ export function useOnMainScroll(
|
|||||||
): [OnScrollCb, boolean, ResetCb] {
|
): [OnScrollCb, boolean, ResetCb] {
|
||||||
let lastY = useRef(0)
|
let lastY = useRef(0)
|
||||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||||
|
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
useCallback(
|
useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
@@ -25,15 +33,11 @@ export function useOnMainScroll(
|
|||||||
const dy = y - (lastY.current || 0)
|
const dy = y - (lastY.current || 0)
|
||||||
lastY.current = y
|
lastY.current = y
|
||||||
|
|
||||||
if (
|
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
|
||||||
!store.shell.minimalShellMode &&
|
|
||||||
dy > DY_LIMIT_DOWN &&
|
|
||||||
y > Y_LIMIT
|
|
||||||
) {
|
|
||||||
store.shell.setMinimalShellMode(true)
|
store.shell.setMinimalShellMode(true)
|
||||||
} else if (
|
} else if (
|
||||||
store.shell.minimalShellMode &&
|
store.shell.minimalShellMode &&
|
||||||
(dy < DY_LIMIT_UP * -1 || y <= Y_LIMIT)
|
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
|
||||||
) {
|
) {
|
||||||
store.shell.setMinimalShellMode(false)
|
store.shell.setMinimalShellMode(false)
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,7 @@ export function useOnMainScroll(
|
|||||||
setIsScrolledDown(false)
|
setIsScrolledDown(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, isScrolledDown],
|
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
|
||||||
),
|
),
|
||||||
isScrolledDown,
|
isScrolledDown,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
import {BackHandler} from 'react-native'
|
import {BackHandler} from 'react-native'
|
||||||
import {RootStoreModel} from 'state/index'
|
import {RootStoreModel} from 'state/index'
|
||||||
|
|
||||||
export function init(store: RootStoreModel) {
|
export function init(store: RootStoreModel) {
|
||||||
BackHandler.addEventListener('hardwareBackPress', () => {
|
// only register back handler on android, otherwise it throws an error
|
||||||
return store.shell.closeAnyActiveElement()
|
if (isAndroid) {
|
||||||
})
|
const backHandler = BackHandler.addEventListener(
|
||||||
|
'hardwareBackPress',
|
||||||
|
() => {
|
||||||
|
return store.shell.closeAnyActiveElement()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
backHandler.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,20 @@ export function enforceLen(str: string, len: number, ellipsis = false): string {
|
|||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/52171480
|
||||||
|
export function toHashCode(str: string, seed = 0): number {
|
||||||
|
let h1 = 0xdeadbeef ^ seed,
|
||||||
|
h2 = 0x41c6ce57 ^ seed
|
||||||
|
for (let i = 0, ch; i < str.length; i++) {
|
||||||
|
ch = str.charCodeAt(i)
|
||||||
|
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||||
|
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||||
|
}
|
||||||
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
|
||||||
|
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
||||||
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
|
||||||
|
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
||||||
|
|
||||||
|
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {PROD_SERVICE} from 'state/index'
|
import {PROD_SERVICE} from 'state/index'
|
||||||
import TLDs from 'tlds'
|
import TLDs from 'tlds'
|
||||||
|
import psl from 'psl'
|
||||||
|
|
||||||
export function isValidDomain(str: string): boolean {
|
export function isValidDomain(str: string): boolean {
|
||||||
return !!TLDs.find(tld => {
|
return !!TLDs.find(tld => {
|
||||||
@@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
|||||||
}
|
}
|
||||||
return videoId
|
return videoId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkRequiresWarning(uri: string, label: string) {
|
||||||
|
const labelDomain = labelToDomain(label)
|
||||||
|
if (!labelDomain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const urip = new URL(uri)
|
||||||
|
return labelDomain !== urip.hostname
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelToDomain(label: string): string | undefined {
|
||||||
|
// any spaces just immediately consider the label a non-url
|
||||||
|
if (/\s/.test(label)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(label).hostname
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return new URL('https://' + label).hostname
|
||||||
|
} catch {}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPossiblyAUrl(str: string): boolean {
|
||||||
|
str = str.trim()
|
||||||
|
if (str.startsWith('http://')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (str.startsWith('https://')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const [firstWord] = str.split(/[\s\/]/)
|
||||||
|
return isValidDomain(firstWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitApexDomain(hostname: string): [string, string] {
|
||||||
|
const hostnamep = psl.parse(hostname)
|
||||||
|
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
||||||
|
return ['', hostname]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
|
||||||
|
hostnamep.domain,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -264,8 +264,8 @@ export const defaultTheme: Theme = {
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
'post-text-lg': {
|
'post-text-lg': {
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.2,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
'button-lg': {
|
'button-lg': {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const isMobileWeb =
|
|||||||
isWeb &&
|
isWeb &&
|
||||||
// @ts-ignore we know window exists -prf
|
// @ts-ignore we know window exists -prf
|
||||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||||
export const isDesktopWeb = isWeb && !isMobileWeb
|
|
||||||
|
|
||||||
export const deviceLocales = dedupArray(
|
export const deviceLocales = dedupArray(
|
||||||
getLocales?.().map?.(locale => locale.languageCode),
|
getLocales?.().map?.(locale => locale.languageCode),
|
||||||
|
|||||||
Vendored
+69
-34
@@ -1,7 +1,14 @@
|
|||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable} from 'mobx'
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyGraphGetFollows as GetFollows,
|
||||||
|
moderateProfile,
|
||||||
|
} from '@atproto/api'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
|
|
||||||
|
const MAX_SYNC_PAGES = 10
|
||||||
|
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||||
|
|
||||||
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
||||||
|
|
||||||
export enum FollowState {
|
export enum FollowState {
|
||||||
@@ -10,6 +17,14 @@ export enum FollowState {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FollowInfo {
|
||||||
|
did: string
|
||||||
|
followRecordUri: string | undefined
|
||||||
|
handle: string
|
||||||
|
displayName: string | undefined
|
||||||
|
avatar: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This model is used to maintain a synced local cache of the user's
|
* This model is used to maintain a synced local cache of the user's
|
||||||
* follows. It should be periodically refreshed and updated any time
|
* follows. It should be periodically refreshed and updated any time
|
||||||
@@ -17,9 +32,8 @@ export enum FollowState {
|
|||||||
*/
|
*/
|
||||||
export class MyFollowsCache {
|
export class MyFollowsCache {
|
||||||
// data
|
// data
|
||||||
followDidToRecordMap: Record<string, string | boolean> = {}
|
byDid: Record<string, FollowInfo> = {}
|
||||||
lastSync = 0
|
lastSync = 0
|
||||||
myDid?: string
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
@@ -35,16 +49,45 @@ export class MyFollowsCache {
|
|||||||
// =
|
// =
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.followDidToRecordMap = {}
|
this.byDid = {}
|
||||||
this.lastSync = 0
|
}
|
||||||
this.myDid = undefined
|
|
||||||
|
/**
|
||||||
|
* Syncs a subset of the user's follows
|
||||||
|
* for performance reasons, caps out at 1000 follows
|
||||||
|
*/
|
||||||
|
async syncIfNeeded() {
|
||||||
|
if (this.lastSync > Date.now() - SYNC_TTL) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor
|
||||||
|
for (let i = 0; i < MAX_SYNC_PAGES; i++) {
|
||||||
|
const res: GetFollows.Response = await this.rootStore.agent.getFollows({
|
||||||
|
actor: this.rootStore.me.did,
|
||||||
|
cursor,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
res.data.follows = res.data.follows.filter(
|
||||||
|
profile =>
|
||||||
|
!moderateProfile(profile, this.rootStore.preferences.moderationOpts)
|
||||||
|
.account.filter,
|
||||||
|
)
|
||||||
|
this.hydrateMany(res.data.follows)
|
||||||
|
if (!res.data.cursor) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = res.data.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSync = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
getFollowState(did: string): FollowState {
|
getFollowState(did: string): FollowState {
|
||||||
if (typeof this.followDidToRecordMap[did] === 'undefined') {
|
if (typeof this.byDid[did] === 'undefined') {
|
||||||
return FollowState.Unknown
|
return FollowState.Unknown
|
||||||
}
|
}
|
||||||
if (typeof this.followDidToRecordMap[did] === 'string') {
|
if (typeof this.byDid[did].followRecordUri === 'string') {
|
||||||
return FollowState.Following
|
return FollowState.Following
|
||||||
}
|
}
|
||||||
return FollowState.NotFollowing
|
return FollowState.NotFollowing
|
||||||
@@ -53,49 +96,41 @@ export class MyFollowsCache {
|
|||||||
async fetchFollowState(did: string): Promise<FollowState> {
|
async fetchFollowState(did: string): Promise<FollowState> {
|
||||||
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
|
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
|
||||||
const res = await this.rootStore.agent.getProfile({actor: did})
|
const res = await this.rootStore.agent.getProfile({actor: did})
|
||||||
if (res.data.viewer?.following) {
|
this.hydrate(did, res.data)
|
||||||
this.addFollow(did, res.data.viewer.following)
|
|
||||||
} else {
|
|
||||||
this.removeFollow(did)
|
|
||||||
}
|
|
||||||
return this.getFollowState(did)
|
return this.getFollowState(did)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFollowUri(did: string): string {
|
getFollowUri(did: string): string {
|
||||||
const v = this.followDidToRecordMap[did]
|
const v = this.byDid[did]
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
throw new Error('Not a followed user')
|
throw new Error('Not a followed user')
|
||||||
}
|
}
|
||||||
|
|
||||||
addFollow(did: string, recordUri: string) {
|
addFollow(did: string, info: FollowInfo) {
|
||||||
this.followDidToRecordMap[did] = recordUri
|
this.byDid[did] = info
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFollow(did: string) {
|
removeFollow(did: string) {
|
||||||
this.followDidToRecordMap[did] = false
|
if (this.byDid[did]) {
|
||||||
}
|
this.byDid[did].followRecordUri = undefined
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this to incrementally update the cache as views provide information
|
|
||||||
*/
|
|
||||||
hydrate(did: string, recordUri: string | undefined) {
|
|
||||||
if (recordUri) {
|
|
||||||
this.followDidToRecordMap[did] = recordUri
|
|
||||||
} else {
|
|
||||||
this.followDidToRecordMap[did] = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
hydrate(did: string, profile: Profile) {
|
||||||
* Use this to incrementally update the cache as views provide information
|
this.byDid[did] = {
|
||||||
*/
|
did,
|
||||||
hydrateProfiles(profiles: Profile[]) {
|
followRecordUri: profile.viewer?.following,
|
||||||
|
handle: profile.handle,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatar: profile.avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateMany(profiles: Profile[]) {
|
||||||
for (const profile of profiles) {
|
for (const profile of profiles) {
|
||||||
if (profile.viewer) {
|
this.hydrate(profile.did, profile)
|
||||||
this.hydrate(profile.did, profile.viewer.following)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||||||
import {
|
import {
|
||||||
AppBskyFeedGetPostThread as GetPostThread,
|
AppBskyFeedGetPostThread as GetPostThread,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
PostModeration,
|
PostModeration,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
@@ -76,6 +77,13 @@ export class PostThreadModel {
|
|||||||
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isCachedPostAReply() {
|
||||||
|
if (AppBskyFeedPost.isRecord(this.thread?.post.record)) {
|
||||||
|
return !!this.thread?.post.record.reply
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// public api
|
// public api
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class ProfileModel {
|
|||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.followersCount++
|
this.followersCount++
|
||||||
this.viewer.following = res.uri
|
this.viewer.following = res.uri
|
||||||
this.rootStore.me.follows.addFollow(this.did, res.uri)
|
this.rootStore.me.follows.hydrate(this.did, this)
|
||||||
})
|
})
|
||||||
track('Profile:Follow', {
|
track('Profile:Follow', {
|
||||||
username: this.handle,
|
username: this.handle,
|
||||||
@@ -290,8 +290,8 @@ export class ProfileModel {
|
|||||||
this.labels = res.data.labels
|
this.labels = res.data.labels
|
||||||
if (res.data.viewer) {
|
if (res.data.viewer) {
|
||||||
Object.assign(this.viewer, res.data.viewer)
|
Object.assign(this.viewer, res.data.viewer)
|
||||||
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
|
|
||||||
}
|
}
|
||||||
|
this.rootStore.me.follows.hydrate(this.did, res.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async _createRichText() {
|
async _createRichText() {
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
AppBskyActorDefs,
|
|
||||||
AppBskyGraphGetFollows as GetFollows,
|
|
||||||
moderateProfile,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import sampleSize from 'lodash.samplesize'
|
import sampleSize from 'lodash.samplesize'
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
@@ -43,35 +39,13 @@ export class FoafsModel {
|
|||||||
try {
|
try {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|
||||||
// fetch & hydrate up to 1000 follows
|
// fetch some of the user's follows
|
||||||
{
|
await this.rootStore.me.follows.syncIfNeeded()
|
||||||
let cursor
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const res: GetFollows.Response =
|
|
||||||
await this.rootStore.agent.getFollows({
|
|
||||||
actor: this.rootStore.me.did,
|
|
||||||
cursor,
|
|
||||||
limit: 100,
|
|
||||||
})
|
|
||||||
res.data.follows = res.data.follows.filter(
|
|
||||||
profile =>
|
|
||||||
!moderateProfile(
|
|
||||||
profile,
|
|
||||||
this.rootStore.preferences.moderationOpts,
|
|
||||||
).account.filter,
|
|
||||||
)
|
|
||||||
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
|
|
||||||
if (!res.data.cursor) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = res.data.cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// grab 10 of the users followed by the user
|
// grab 10 of the users followed by the user
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.sources = sampleSize(
|
this.sources = sampleSize(
|
||||||
Object.keys(this.rootStore.me.follows.followDidToRecordMap),
|
Object.keys(this.rootStore.me.follows.byDid),
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -100,7 +74,7 @@ export class FoafsModel {
|
|||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const res = results[i]
|
const res = results[i]
|
||||||
if (res.status === 'fulfilled') {
|
if (res.status === 'fulfilled') {
|
||||||
this.rootStore.me.follows.hydrateProfiles(res.value.data.follows)
|
this.rootStore.me.follows.hydrateMany(res.value.data.follows)
|
||||||
}
|
}
|
||||||
const profile = profiles.data.profiles[i]
|
const profile = profiles.data.profiles[i]
|
||||||
const source = this.sources[i]
|
const source = this.sources[i]
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export class OnboardingModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
|
this.rootStore.me.mainFeed.refresh() // load the selected content
|
||||||
this.step = 'Home'
|
this.step = 'Home'
|
||||||
track('Onboarding:Complete')
|
track('Onboarding:Complete')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class SuggestedActorsModel {
|
|||||||
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
|
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
|
||||||
.account.filter,
|
.account.filter,
|
||||||
)
|
)
|
||||||
this.rootStore.me.follows.hydrateProfiles(actors)
|
this.rootStore.me.follows.hydrateMany(actors)
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
if (replace) {
|
if (replace) {
|
||||||
@@ -118,7 +118,7 @@ export class SuggestedActorsModel {
|
|||||||
actor: actor,
|
actor: actor,
|
||||||
})
|
})
|
||||||
const {suggestions: moreSuggestions} = res.data
|
const {suggestions: moreSuggestions} = res.data
|
||||||
this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
|
this.rootStore.me.follows.hydrateMany(moreSuggestions)
|
||||||
// dedupe
|
// dedupe
|
||||||
const toInsert = moreSuggestions.filter(
|
const toInsert = moreSuggestions.filter(
|
||||||
s => !this.suggestions.find(s2 => s2.did === s.did),
|
s => !this.suggestions.find(s2 => s2.did === s.did),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import AwaitLock from 'await-lock'
|
|||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {isInvalidHandle} from 'lib/strings/handles'
|
import {isInvalidHandle} from 'lib/strings/handles'
|
||||||
|
|
||||||
|
type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
|
||||||
|
|
||||||
export class UserAutocompleteModel {
|
export class UserAutocompleteModel {
|
||||||
// state
|
// state
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -12,9 +14,8 @@ export class UserAutocompleteModel {
|
|||||||
lock = new AwaitLock()
|
lock = new AwaitLock()
|
||||||
|
|
||||||
// data
|
// data
|
||||||
follows: AppBskyActorDefs.ProfileViewBasic[] = []
|
|
||||||
searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
|
|
||||||
knownHandles: Set<string> = new Set()
|
knownHandles: Set<string> = new Set()
|
||||||
|
_suggestions: ProfileViewBasic[] = []
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
@@ -27,29 +28,35 @@ export class UserAutocompleteModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get suggestions() {
|
get follows(): ProfileViewBasic[] {
|
||||||
|
return Object.values(this.rootStore.me.follows.byDid).map(item => ({
|
||||||
|
did: item.did,
|
||||||
|
handle: item.handle,
|
||||||
|
displayName: item.displayName,
|
||||||
|
avatar: item.avatar,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
get suggestions(): ProfileViewBasic[] {
|
||||||
if (!this.isActive) {
|
if (!this.isActive) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (this.prefix) {
|
return this._suggestions
|
||||||
return this.searchRes.map(user => ({
|
|
||||||
handle: user.handle,
|
|
||||||
displayName: user.displayName,
|
|
||||||
avatar: user.avatar,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return this.follows.map(follow => ({
|
|
||||||
handle: follow.handle,
|
|
||||||
displayName: follow.displayName,
|
|
||||||
avatar: follow.avatar,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// public api
|
// public api
|
||||||
// =
|
// =
|
||||||
|
|
||||||
async setup() {
|
async setup() {
|
||||||
await this._getFollows()
|
await this.rootStore.me.follows.syncIfNeeded()
|
||||||
|
runInAction(() => {
|
||||||
|
for (const did in this.rootStore.me.follows.byDid) {
|
||||||
|
const info = this.rootStore.me.follows.byDid[did]
|
||||||
|
if (!isInvalidHandle(info.handle)) {
|
||||||
|
this.knownHandles.add(info.handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setActive(v: boolean) {
|
setActive(v: boolean) {
|
||||||
@@ -57,7 +64,7 @@ export class UserAutocompleteModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setPrefix(prefix: string) {
|
async setPrefix(prefix: string) {
|
||||||
const origPrefix = prefix.trim()
|
const origPrefix = prefix.trim().toLocaleLowerCase()
|
||||||
this.prefix = origPrefix
|
this.prefix = origPrefix
|
||||||
await this.lock.acquireAsync()
|
await this.lock.acquireAsync()
|
||||||
try {
|
try {
|
||||||
@@ -65,9 +72,27 @@ export class UserAutocompleteModel {
|
|||||||
if (this.prefix !== origPrefix) {
|
if (this.prefix !== origPrefix) {
|
||||||
return // another prefix was set before we got our chance
|
return // another prefix was set before we got our chance
|
||||||
}
|
}
|
||||||
await this._search()
|
|
||||||
|
// reset to follow results
|
||||||
|
this._computeSuggestions([])
|
||||||
|
|
||||||
|
// ask backend
|
||||||
|
const res = await this.rootStore.agent.searchActorsTypeahead({
|
||||||
|
term: this.prefix,
|
||||||
|
limit: 8,
|
||||||
|
})
|
||||||
|
this._computeSuggestions(res.data.actors)
|
||||||
|
|
||||||
|
// update known handles
|
||||||
|
runInAction(() => {
|
||||||
|
for (const u of res.data.actors) {
|
||||||
|
this.knownHandles.add(u.handle)
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
this.searchRes = []
|
runInAction(() => {
|
||||||
|
this._computeSuggestions([])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.lock.release()
|
this.lock.release()
|
||||||
@@ -77,28 +102,40 @@ export class UserAutocompleteModel {
|
|||||||
// internal
|
// internal
|
||||||
// =
|
// =
|
||||||
|
|
||||||
async _getFollows() {
|
_computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
|
||||||
const res = await this.rootStore.agent.getFollows({
|
if (this.prefix) {
|
||||||
actor: this.rootStore.me.did || '',
|
const items: ProfileViewBasic[] = []
|
||||||
})
|
for (const item of this.follows) {
|
||||||
runInAction(() => {
|
if (prefixMatch(this.prefix, item)) {
|
||||||
this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle))
|
items.push(item)
|
||||||
for (const f of this.follows) {
|
}
|
||||||
this.knownHandles.add(f.handle)
|
if (items.length >= 8) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
for (const item of searchRes) {
|
||||||
}
|
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||||
|
items.push({
|
||||||
async _search() {
|
did: item.did,
|
||||||
const res = await this.rootStore.agent.searchActorsTypeahead({
|
handle: item.handle,
|
||||||
term: this.prefix,
|
displayName: item.displayName,
|
||||||
limit: 8,
|
avatar: item.avatar,
|
||||||
})
|
})
|
||||||
runInAction(() => {
|
}
|
||||||
this.searchRes = res.data.actors
|
|
||||||
for (const u of this.searchRes) {
|
|
||||||
this.knownHandles.add(u.handle)
|
|
||||||
}
|
}
|
||||||
})
|
this._suggestions = items
|
||||||
|
} else {
|
||||||
|
this._suggestions = this.follows
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
|
||||||
|
if (info.handle.includes(prefix)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export class PostsFeedModel {
|
|||||||
return this.hasLoaded && !this.hasContent
|
return this.hasLoaded && !this.hasContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isLoadingMore() {
|
||||||
|
return this.isLoading && !this.isRefreshing
|
||||||
|
}
|
||||||
|
|
||||||
setHasNewLatest(v: boolean) {
|
setHasNewLatest(v: boolean) {
|
||||||
this.hasNewLatest = v
|
this.hasNewLatest = v
|
||||||
}
|
}
|
||||||
@@ -307,12 +311,12 @@ export class PostsFeedModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _appendAll(res: FeedAPIResponse, replace = false) {
|
async _appendAll(res: FeedAPIResponse, replace = false) {
|
||||||
this.hasMore = !!res.cursor
|
this.hasMore = !!res.cursor && res.feed.length > 0
|
||||||
if (replace) {
|
if (replace) {
|
||||||
this.emptyFetches = 0
|
this.emptyFetches = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rootStore.me.follows.hydrateProfiles(
|
this.rootStore.me.follows.hydrateMany(
|
||||||
res.feed.map(item => item.post.author),
|
res.feed.map(item => item.post.author),
|
||||||
)
|
)
|
||||||
for (const item of res.feed) {
|
for (const item of res.feed) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class InvitedUsers {
|
|||||||
profile => !profile.viewer?.following,
|
profile => !profile.viewer?.following,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
this.rootStore.me.follows.hydrateProfiles(this.profiles)
|
this.rootStore.me.follows.hydrateMany(this.profiles)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.rootStore.log.error(
|
this.rootStore.log.error(
|
||||||
'Failed to fetch profiles for invited users',
|
'Failed to fetch profiles for invited users',
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class LikesModel {
|
|||||||
_appendAll(res: GetLikes.Response) {
|
_appendAll(res: GetLikes.Response) {
|
||||||
this.loadMoreCursor = res.data.cursor
|
this.loadMoreCursor = res.data.cursor
|
||||||
this.hasMore = !!this.loadMoreCursor
|
this.hasMore = !!this.loadMoreCursor
|
||||||
this.rootStore.me.follows.hydrateProfiles(
|
this.rootStore.me.follows.hydrateMany(
|
||||||
res.data.likes.map(like => like.actor),
|
res.data.likes.map(like => like.actor),
|
||||||
)
|
)
|
||||||
this.likes = this.likes.concat(res.data.likes)
|
this.likes = this.likes.concat(res.data.likes)
|
||||||
|
|||||||
@@ -130,6 +130,6 @@ export class RepostedByModel {
|
|||||||
this.loadMoreCursor = res.data.cursor
|
this.loadMoreCursor = res.data.cursor
|
||||||
this.hasMore = !!this.loadMoreCursor
|
this.hasMore = !!this.loadMoreCursor
|
||||||
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
|
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
|
||||||
this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
|
this.rootStore.me.follows.hydrateMany(res.data.repostedBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,6 @@ export class UserFollowersModel {
|
|||||||
this.loadMoreCursor = res.data.cursor
|
this.loadMoreCursor = res.data.cursor
|
||||||
this.hasMore = !!this.loadMoreCursor
|
this.hasMore = !!this.loadMoreCursor
|
||||||
this.followers = this.followers.concat(res.data.followers)
|
this.followers = this.followers.concat(res.data.followers)
|
||||||
this.rootStore.me.follows.hydrateProfiles(res.data.followers)
|
this.rootStore.me.follows.hydrateMany(res.data.followers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,6 @@ export class UserFollowsModel {
|
|||||||
this.loadMoreCursor = res.data.cursor
|
this.loadMoreCursor = res.data.cursor
|
||||||
this.hasMore = !!this.loadMoreCursor
|
this.hasMore = !!this.loadMoreCursor
|
||||||
this.follows = this.follows.concat(res.data.follows)
|
this.follows = this.follows.concat(res.data.follows)
|
||||||
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
|
this.rootStore.me.follows.hydrateMany(res.data.follows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ export class MeModel {
|
|||||||
savedFeeds: SavedFeedsModel
|
savedFeeds: SavedFeedsModel
|
||||||
notifications: NotificationsFeedModel
|
notifications: NotificationsFeedModel
|
||||||
follows: MyFollowsCache
|
follows: MyFollowsCache
|
||||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
invites: ComAtprotoServerDefs.InviteCode[] | null = []
|
||||||
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
|
return this.invites?.filter(isInviteAvailable).length || null
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
@@ -180,7 +180,9 @@ 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)
|
||||||
}
|
}
|
||||||
await this.rootStore.invitedUsers.fetch(this.invites)
|
if (this.invites) {
|
||||||
|
await this.rootStore.invitedUsers.fetch(this.invites)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences'
|
|||||||
import {resetToTab} from '../../Navigation'
|
import {resetToTab} from '../../Navigation'
|
||||||
import {ImageSizesCache} from './cache/image-sizes'
|
import {ImageSizesCache} from './cache/image-sizes'
|
||||||
import {MutedThreads} from './muted-threads'
|
import {MutedThreads} from './muted-threads'
|
||||||
|
import {Reminders} from './ui/reminders'
|
||||||
import {reset as resetNavigation} from '../../Navigation'
|
import {reset as resetNavigation} from '../../Navigation'
|
||||||
import {RecentTagsModel} from './ui/tags-autocomplete'
|
import {RecentTagsModel} from './ui/tags-autocomplete'
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export class RootStoreModel {
|
|||||||
linkMetas = new LinkMetasCache(this)
|
linkMetas = new LinkMetasCache(this)
|
||||||
imageSizes = new ImageSizesCache()
|
imageSizes = new ImageSizesCache()
|
||||||
mutedThreads = new MutedThreads()
|
mutedThreads = new MutedThreads()
|
||||||
|
reminders = new Reminders(this)
|
||||||
recentTags = new RecentTagsModel()
|
recentTags = new RecentTagsModel()
|
||||||
|
|
||||||
constructor(agent: BskyAgent) {
|
constructor(agent: BskyAgent) {
|
||||||
@@ -79,6 +81,7 @@ export class RootStoreModel {
|
|||||||
preferences: this.preferences.serialize(),
|
preferences: this.preferences.serialize(),
|
||||||
invitedUsers: this.invitedUsers.serialize(),
|
invitedUsers: this.invitedUsers.serialize(),
|
||||||
mutedThreads: this.mutedThreads.serialize(),
|
mutedThreads: this.mutedThreads.serialize(),
|
||||||
|
reminders: this.reminders.serialize(),
|
||||||
recentTags: this.recentTags.serialize(),
|
recentTags: this.recentTags.serialize(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,9 @@ export class RootStoreModel {
|
|||||||
if (hasProp(v, 'recentTags')) {
|
if (hasProp(v, 'recentTags')) {
|
||||||
this.recentTags.hydrate(v.recentTags)
|
this.recentTags.hydrate(v.recentTags)
|
||||||
}
|
}
|
||||||
|
if (hasProp(v, 'reminders')) {
|
||||||
|
this.reminders.hydrate(v.reminders)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const accountData = z.object({
|
|||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
displayName: z.string().optional(),
|
displayName: z.string().optional(),
|
||||||
aviUrl: z.string().optional(),
|
aviUrl: z.string().optional(),
|
||||||
|
emailConfirmed: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
export type AccountData = z.infer<typeof accountData>
|
export type AccountData = z.infer<typeof accountData>
|
||||||
|
|
||||||
@@ -106,6 +107,10 @@ export class SessionModel {
|
|||||||
return this.accounts.filter(acct => acct.did !== this.data?.did)
|
return this.accounts.filter(acct => acct.did !== this.data?.did)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get emailNeedsConfirmation() {
|
||||||
|
return !this.currentSession?.emailConfirmed
|
||||||
|
}
|
||||||
|
|
||||||
get isSandbox() {
|
get isSandbox() {
|
||||||
if (!this.data) {
|
if (!this.data) {
|
||||||
return false
|
return false
|
||||||
@@ -217,6 +222,7 @@ export class SessionModel {
|
|||||||
? addedInfo.displayName
|
? addedInfo.displayName
|
||||||
: existingAccount?.displayName || '',
|
: existingAccount?.displayName || '',
|
||||||
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
|
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
|
||||||
|
emailConfirmed: session?.emailConfirmed,
|
||||||
}
|
}
|
||||||
if (!existingAccount) {
|
if (!existingAccount) {
|
||||||
this.accounts.push(newAccount)
|
this.accounts.push(newAccount)
|
||||||
@@ -246,6 +252,8 @@ export class SessionModel {
|
|||||||
did: acct.did,
|
did: acct.did,
|
||||||
displayName: acct.displayName,
|
displayName: acct.displayName,
|
||||||
aviUrl: acct.aviUrl,
|
aviUrl: acct.aviUrl,
|
||||||
|
email: acct.email,
|
||||||
|
emailConfirmed: acct.emailConfirmed,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +305,8 @@ export class SessionModel {
|
|||||||
refreshJwt: account.refreshJwt || '',
|
refreshJwt: account.refreshJwt || '',
|
||||||
did: account.did,
|
did: account.did,
|
||||||
handle: account.handle,
|
handle: account.handle,
|
||||||
|
email: account.email,
|
||||||
|
emailConfirmed: account.emailConfirmed,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const addedInfo = await this.loadAccountInfo(agent, account.did)
|
const addedInfo = await this.loadAccountInfo(agent, account.did)
|
||||||
@@ -452,4 +462,10 @@ export class SessionModel {
|
|||||||
await this.rootStore.me.load()
|
await this.rootStore.me.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLocalAccountData(changes: Partial<AccountData>) {
|
||||||
|
this.accounts = this.accounts.map(acct =>
|
||||||
|
acct.did === this.data?.did ? {...acct, ...changes} : acct,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ export class PreferencesModel {
|
|||||||
const oldPinned = this.pinnedFeeds
|
const oldPinned = this.pinnedFeeds
|
||||||
this.savedFeeds = saved
|
this.savedFeeds = saved
|
||||||
this.pinnedFeeds = pinned
|
this.pinnedFeeds = pinned
|
||||||
|
await this.lock.acquireAsync()
|
||||||
try {
|
try {
|
||||||
const res = await cb()
|
const res = await cb()
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
@@ -430,6 +431,8 @@ export class PreferencesModel {
|
|||||||
this.pinnedFeeds = oldPinned
|
this.pinnedFeeds = oldPinned
|
||||||
})
|
})
|
||||||
throw e
|
throw e
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +444,7 @@ export class PreferencesModel {
|
|||||||
|
|
||||||
async addSavedFeed(v: string) {
|
async addSavedFeed(v: string) {
|
||||||
return this._optimisticUpdateSavedFeeds(
|
return this._optimisticUpdateSavedFeeds(
|
||||||
[...this.savedFeeds, v],
|
[...this.savedFeeds.filter(uri => uri !== v), v],
|
||||||
this.pinnedFeeds,
|
this.pinnedFeeds,
|
||||||
() => this.rootStore.agent.addSavedFeed(v),
|
() => this.rootStore.agent.addSavedFeed(v),
|
||||||
)
|
)
|
||||||
@@ -457,8 +460,8 @@ export class PreferencesModel {
|
|||||||
|
|
||||||
async addPinnedFeed(v: string) {
|
async addPinnedFeed(v: string) {
|
||||||
return this._optimisticUpdateSavedFeeds(
|
return this._optimisticUpdateSavedFeeds(
|
||||||
this.savedFeeds,
|
[...this.savedFeeds.filter(uri => uri !== v), v],
|
||||||
[...this.pinnedFeeds, v],
|
[...this.pinnedFeeds.filter(uri => uri !== v), v],
|
||||||
() => this.rootStore.agent.addPinnedFeed(v),
|
() => this.rootStore.agent.addPinnedFeed(v),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -473,71 +476,121 @@ export class PreferencesModel {
|
|||||||
|
|
||||||
async setBirthDate(birthDate: Date) {
|
async setBirthDate(birthDate: Date) {
|
||||||
this.birthDate = birthDate
|
this.birthDate = birthDate
|
||||||
await this.rootStore.agent.setPersonalDetails({birthDate})
|
await this.lock.acquireAsync()
|
||||||
|
try {
|
||||||
|
await this.rootStore.agent.setPersonalDetails({birthDate})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleHomeFeedHideReplies() {
|
async toggleHomeFeedHideReplies() {
|
||||||
this.homeFeed.hideReplies = !this.homeFeed.hideReplies
|
this.homeFeed.hideReplies = !this.homeFeed.hideReplies
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
hideReplies: this.homeFeed.hideReplies,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
hideReplies: this.homeFeed.hideReplies,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleHomeFeedHideRepliesByUnfollowed() {
|
async toggleHomeFeedHideRepliesByUnfollowed() {
|
||||||
this.homeFeed.hideRepliesByUnfollowed =
|
this.homeFeed.hideRepliesByUnfollowed =
|
||||||
!this.homeFeed.hideRepliesByUnfollowed
|
!this.homeFeed.hideRepliesByUnfollowed
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setHomeFeedHideRepliesByLikeCount(threshold: number) {
|
async setHomeFeedHideRepliesByLikeCount(threshold: number) {
|
||||||
this.homeFeed.hideRepliesByLikeCount = threshold
|
this.homeFeed.hideRepliesByLikeCount = threshold
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleHomeFeedHideReposts() {
|
async toggleHomeFeedHideReposts() {
|
||||||
this.homeFeed.hideReposts = !this.homeFeed.hideReposts
|
this.homeFeed.hideReposts = !this.homeFeed.hideReposts
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
hideReposts: this.homeFeed.hideReposts,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
hideReposts: this.homeFeed.hideReposts,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleHomeFeedHideQuotePosts() {
|
async toggleHomeFeedHideQuotePosts() {
|
||||||
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
|
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
hideQuotePosts: this.homeFeed.hideQuotePosts,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
hideQuotePosts: this.homeFeed.hideQuotePosts,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleHomeFeedMergeFeedEnabled() {
|
async toggleHomeFeedMergeFeedEnabled() {
|
||||||
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
|
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
|
||||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
await this.lock.acquireAsync()
|
||||||
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
|
try {
|
||||||
})
|
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||||
|
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setThreadSort(v: string) {
|
async setThreadSort(v: string) {
|
||||||
if (THREAD_SORT_VALUES.includes(v)) {
|
if (THREAD_SORT_VALUES.includes(v)) {
|
||||||
this.thread.sort = v
|
this.thread.sort = v
|
||||||
await this.rootStore.agent.setThreadViewPrefs({sort: v})
|
await this.lock.acquireAsync()
|
||||||
|
try {
|
||||||
|
await this.rootStore.agent.setThreadViewPrefs({sort: v})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async togglePrioritizedFollowedUsers() {
|
async togglePrioritizedFollowedUsers() {
|
||||||
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
|
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
|
||||||
await this.rootStore.agent.setThreadViewPrefs({
|
await this.lock.acquireAsync()
|
||||||
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
|
try {
|
||||||
})
|
await this.rootStore.agent.setThreadViewPrefs({
|
||||||
|
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleThreadTreeViewEnabled() {
|
async toggleThreadTreeViewEnabled() {
|
||||||
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
|
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
|
||||||
await this.rootStore.agent.setThreadViewPrefs({
|
await this.lock.acquireAsync()
|
||||||
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
|
try {
|
||||||
})
|
await this.rootStore.agent.setThreadViewPrefs({
|
||||||
|
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleRequireAltTextEnabled() {
|
toggleRequireAltTextEnabled() {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {makeAutoObservable} from 'mobx'
|
||||||
|
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()
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel) {
|
||||||
|
makeAutoObservable(
|
||||||
|
this,
|
||||||
|
{serialize: false, hydrate: false},
|
||||||
|
{autoBind: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return {
|
||||||
|
lastEmailConfirm: this.lastEmailConfirm
|
||||||
|
? this.lastEmailConfirm.toISOString()
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate(v: unknown) {
|
||||||
|
if (
|
||||||
|
isObj(v) &&
|
||||||
|
hasProp(v, 'lastEmailConfirm') &&
|
||||||
|
typeof v.lastEmailConfirm === 'string'
|
||||||
|
) {
|
||||||
|
this.lastEmailConfirm = new Date(v.lastEmailConfirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get shouldRequestEmailConfirmation() {
|
||||||
|
const sess = this.rootStore.session.currentSession
|
||||||
|
if (!sess) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (sess.emailConfirmed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.rootStore.onboarding.isActive) {
|
||||||
|
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
|
||||||
|
// this feature rolls out)
|
||||||
|
const code = toHashCode(sess.did) % 7
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailConfirmationRequested() {
|
||||||
|
this.lastEmailConfirm = new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ export class SearchUIModel {
|
|||||||
} while (profilesSearch.length)
|
} while (profilesSearch.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rootStore.me.follows.hydrateProfiles(profiles)
|
this.rootStore.me.follows.hydrateMany(profiles)
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.profiles = profiles
|
this.profiles = profiles
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ConfirmModal {
|
|||||||
onPressCancel?: () => void | Promise<void>
|
onPressCancel?: () => void | Promise<void>
|
||||||
confirmBtnText?: string
|
confirmBtnText?: string
|
||||||
confirmBtnStyle?: StyleProp<ViewStyle>
|
confirmBtnStyle?: StyleProp<ViewStyle>
|
||||||
|
cancelBtnText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditProfileModal {
|
export interface EditProfileModal {
|
||||||
@@ -140,6 +141,25 @@ export interface BirthDateSettingsModal {
|
|||||||
name: 'birth-date-settings'
|
name: 'birth-date-settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailModal {
|
||||||
|
name: 'verify-email'
|
||||||
|
showReminder?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeEmailModal {
|
||||||
|
name: 'change-email'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchAccountModal {
|
||||||
|
name: 'switch-account'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkWarningModal {
|
||||||
|
name: 'link-warning'
|
||||||
|
text: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
// Account
|
// Account
|
||||||
| AddAppPasswordModal
|
| AddAppPasswordModal
|
||||||
@@ -148,6 +168,9 @@ export type Modal =
|
|||||||
| EditProfileModal
|
| EditProfileModal
|
||||||
| ProfilePreviewModal
|
| ProfilePreviewModal
|
||||||
| BirthDateSettingsModal
|
| BirthDateSettingsModal
|
||||||
|
| VerifyEmailModal
|
||||||
|
| ChangeEmailModal
|
||||||
|
| SwitchAccountModal
|
||||||
|
|
||||||
// Curation
|
// Curation
|
||||||
| ContentFilteringSettingsModal
|
| ContentFilteringSettingsModal
|
||||||
@@ -174,6 +197,7 @@ export type Modal =
|
|||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
|
| LinkWarningModal
|
||||||
|
|
||||||
interface LightboxModel {}
|
interface LightboxModel {}
|
||||||
|
|
||||||
@@ -250,6 +274,7 @@ export class ShellUiModel {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.setupClock()
|
this.setupClock()
|
||||||
|
this.setupLoginModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): unknown {
|
serialize(): unknown {
|
||||||
@@ -375,4 +400,13 @@ export class ShellUiModel {
|
|||||||
})
|
})
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupLoginModals() {
|
||||||
|
this.rootStore.onSessionReady(() => {
|
||||||
|
if (this.rootStore.reminders.shouldRequestEmailConfirmation) {
|
||||||
|
this.openModal({name: 'verify-email', showReminder: true})
|
||||||
|
this.rootStore.reminders.setEmailConfirmationRequested()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
|||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {CenteredView} from '../util/Views'
|
import {CenteredView} from '../util/Views'
|
||||||
import {isMobileWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
export const SplashScreen = ({
|
export const SplashScreen = ({
|
||||||
onPressSignin,
|
onPressSignin,
|
||||||
@@ -16,6 +17,9 @@ export const SplashScreen = ({
|
|||||||
onPressCreateAccount: () => void
|
onPressCreateAccount: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
|
const styles = useStyles()
|
||||||
|
const isMobileWeb = isWeb && isTabletOrMobile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView style={[styles.container, pal.view]}>
|
<CenteredView style={[styles.container, pal.view]}>
|
||||||
@@ -55,13 +59,14 @@ export const SplashScreen = ({
|
|||||||
</View>
|
</View>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</View>
|
</View>
|
||||||
<Footer />
|
<Footer styles={styles} />
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Footer() {
|
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.footer, pal.view, pal.border]}>
|
<View style={[styles.footer, pal.view, pal.border]}>
|
||||||
<TextLink
|
<TextLink
|
||||||
@@ -82,78 +87,81 @@ function Footer() {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const useStyles = () => {
|
||||||
const styles = StyleSheet.create({
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
container: {
|
const isMobileWeb = isWeb && isTabletOrMobile
|
||||||
height: '100%',
|
return StyleSheet.create({
|
||||||
},
|
container: {
|
||||||
containerInner: {
|
height: '100%',
|
||||||
height: '100%',
|
},
|
||||||
justifyContent: 'center',
|
containerInner: {
|
||||||
// @ts-ignore web only
|
height: '100%',
|
||||||
paddingBottom: '20vh',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 20,
|
// @ts-ignore web only
|
||||||
},
|
paddingBottom: '20vh',
|
||||||
containerInnerMobile: {
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 50,
|
},
|
||||||
},
|
containerInnerMobile: {
|
||||||
title: {
|
paddingBottom: 50,
|
||||||
textAlign: 'center',
|
},
|
||||||
color: colors.blue3,
|
title: {
|
||||||
fontSize: 68,
|
textAlign: 'center',
|
||||||
fontWeight: 'bold',
|
color: colors.blue3,
|
||||||
paddingBottom: 10,
|
fontSize: 68,
|
||||||
},
|
fontWeight: 'bold',
|
||||||
titleMobile: {
|
paddingBottom: 10,
|
||||||
textAlign: 'center',
|
},
|
||||||
color: colors.blue3,
|
titleMobile: {
|
||||||
fontSize: 58,
|
textAlign: 'center',
|
||||||
fontWeight: 'bold',
|
color: colors.blue3,
|
||||||
},
|
fontSize: 58,
|
||||||
subtitle: {
|
fontWeight: 'bold',
|
||||||
textAlign: 'center',
|
},
|
||||||
color: colors.gray5,
|
subtitle: {
|
||||||
fontSize: 52,
|
textAlign: 'center',
|
||||||
fontWeight: 'bold',
|
color: colors.gray5,
|
||||||
paddingBottom: 30,
|
fontSize: 52,
|
||||||
},
|
fontWeight: 'bold',
|
||||||
subtitleMobile: {
|
paddingBottom: 30,
|
||||||
textAlign: 'center',
|
},
|
||||||
color: colors.gray5,
|
subtitleMobile: {
|
||||||
fontSize: 42,
|
textAlign: 'center',
|
||||||
fontWeight: 'bold',
|
color: colors.gray5,
|
||||||
paddingBottom: 30,
|
fontSize: 42,
|
||||||
},
|
fontWeight: 'bold',
|
||||||
btns: {
|
paddingBottom: 30,
|
||||||
flexDirection: isMobileWeb ? 'column' : 'row',
|
},
|
||||||
gap: 20,
|
btns: {
|
||||||
justifyContent: 'center',
|
flexDirection: isMobileWeb ? 'column' : 'row',
|
||||||
paddingBottom: 40,
|
gap: 20,
|
||||||
},
|
justifyContent: 'center',
|
||||||
btn: {
|
paddingBottom: 40,
|
||||||
borderRadius: 30,
|
},
|
||||||
paddingHorizontal: 24,
|
btn: {
|
||||||
paddingVertical: 12,
|
borderRadius: 30,
|
||||||
minWidth: 220,
|
paddingHorizontal: 24,
|
||||||
},
|
paddingVertical: 12,
|
||||||
btnLabel: {
|
minWidth: 220,
|
||||||
textAlign: 'center',
|
},
|
||||||
fontSize: 18,
|
btnLabel: {
|
||||||
},
|
textAlign: 'center',
|
||||||
notice: {
|
fontSize: 18,
|
||||||
paddingHorizontal: 40,
|
},
|
||||||
textAlign: 'center',
|
notice: {
|
||||||
},
|
paddingHorizontal: 40,
|
||||||
footer: {
|
textAlign: 'center',
|
||||||
position: 'absolute',
|
},
|
||||||
left: 0,
|
footer: {
|
||||||
right: 0,
|
position: 'absolute',
|
||||||
bottom: 0,
|
left: 0,
|
||||||
padding: 20,
|
right: 0,
|
||||||
borderTopWidth: 1,
|
bottom: 0,
|
||||||
flexDirection: 'row',
|
padding: 20,
|
||||||
},
|
borderTopWidth: 1,
|
||||||
footerLink: {
|
flexDirection: 'row',
|
||||||
marginRight: 20,
|
},
|
||||||
},
|
footerLink: {
|
||||||
})
|
marginRight: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
|||||||
tdStyles.title2,
|
tdStyles.title2,
|
||||||
isTabletOrMobile && tdStyles.title2Small,
|
isTabletOrMobile && tdStyles.title2Small,
|
||||||
]}>
|
]}>
|
||||||
Recomended
|
Recommended
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await item.save()
|
|
||||||
await item.pin()
|
await item.pin()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show('There was an issue contacting your server')
|
Toast.show('There was an issue contacting your server')
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FollowButton
|
<FollowButton
|
||||||
did={profile.did}
|
profile={profile}
|
||||||
labelStyle={styles.followButton}
|
labelStyle={styles.followButton}
|
||||||
onToggleFollow={async isFollow => {
|
onToggleFollow={async isFollow => {
|
||||||
if (isFollow) {
|
if (isFollow) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
|||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
BackHandler,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -51,14 +52,10 @@ import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
|
|||||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||||
import {TagInput} from './TagInput'
|
import {TagInput} from './TagInput'
|
||||||
|
|
||||||
type Props = ComposerOpts & {
|
type Props = ComposerOpts
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ComposePost = observer(function ComposePost({
|
export const ComposePost = observer(function ComposePost({
|
||||||
replyTo,
|
replyTo,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
|
||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
mention: initMention,
|
mention: initMention,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -93,6 +90,9 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||||
const gallery = useMemo(() => new GalleryModel(store), [store])
|
const gallery = useMemo(() => new GalleryModel(store), [store])
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
store.shell.closeComposer()
|
||||||
|
}, [store])
|
||||||
|
|
||||||
const autocompleteView = useMemo<UserAutocompleteModel>(
|
const autocompleteView = useMemo<UserAutocompleteModel>(
|
||||||
() => new UserAutocompleteModel(store),
|
() => new UserAutocompleteModel(store),
|
||||||
@@ -136,6 +136,23 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}, [store, onClose, graphemeLength, gallery])
|
}, [store, onClose, graphemeLength, gallery])
|
||||||
|
// android back button
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const backHandler = BackHandler.addEventListener(
|
||||||
|
'hardwareBackPress',
|
||||||
|
() => {
|
||||||
|
onPressCancel()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
backHandler.remove()
|
||||||
|
}
|
||||||
|
}, [onPressCancel])
|
||||||
|
|
||||||
// initial setup
|
// initial setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
|
||||||
import {openCamera} from 'lib/media/picker'
|
import {openCamera} from 'lib/media/picker'
|
||||||
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
||||||
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
|
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {isMobileWeb, isNative} from 'platform/detection'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
@@ -43,7 +43,8 @@ export function OpenCameraBtn({gallery}: Props) {
|
|||||||
}
|
}
|
||||||
}, [gallery, track, store, requestCameraAccessIfNeeded])
|
}, [gallery, track, store, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
if (isDesktopWeb) {
|
const shouldShowCameraButton = isNative || isMobileWeb
|
||||||
|
if (!shouldShowCameraButton) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
|
||||||
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
|
import {isNative} from 'platform/detection'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
@@ -23,12 +23,12 @@ export function SelectPhotoBtn({gallery}: Props) {
|
|||||||
const onPressSelectPhotos = useCallback(async () => {
|
const onPressSelectPhotos = useCallback(async () => {
|
||||||
track('Composer:GalleryOpened')
|
track('Composer:GalleryOpened')
|
||||||
|
|
||||||
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
|
if (isNative && !(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gallery.pick()
|
gallery.pick()
|
||||||
}, [track, gallery, requestPhotoAccessIfNeeded])
|
}, [track, requestPhotoAccessIfNeeded, gallery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
onUpdate({editor: editorProp}) {
|
onUpdate({editor: editorProp}) {
|
||||||
const json = editorProp.getJSON()
|
const json = editorProp.getJSON()
|
||||||
|
|
||||||
const newRt = new RichText({text: editorJsonToText(json).trim()})
|
const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
|
||||||
newRt.detectFacetsWithoutResolution()
|
newRt.detectFacetsWithoutResolution()
|
||||||
setRichText(newRt)
|
setRichText(newRt)
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +1,403 @@
|
|||||||
/**
|
import React, {MutableRefObject, useState} from 'react'
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, {useCallback, useRef, useState} from 'react'
|
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||||
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
ScrollView,
|
|
||||||
Dimensions,
|
|
||||||
StyleSheet,
|
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
NativeMethodsMixin,
|
|
||||||
} from 'react-native'
|
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import Animated, {
|
||||||
|
measure,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
withDecay,
|
||||||
|
withSpring,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {
|
||||||
|
GestureDetector,
|
||||||
|
Gesture,
|
||||||
|
GestureType,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
import usePanResponder from '../../hooks/usePanResponder'
|
import {
|
||||||
|
createTransform,
|
||||||
|
readTransform,
|
||||||
|
applyRounding,
|
||||||
|
prependPan,
|
||||||
|
prependPinch,
|
||||||
|
prependTransform,
|
||||||
|
TransformMatrix,
|
||||||
|
} from '../../transforms'
|
||||||
|
import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||||
|
|
||||||
import {getImageStyles, getImageTransform} from '../../utils'
|
|
||||||
import {ImageSource} from '../../@types'
|
|
||||||
import {ImageLoading} from './ImageLoading'
|
|
||||||
|
|
||||||
const SWIPE_CLOSE_OFFSET = 75
|
|
||||||
const SWIPE_CLOSE_VELOCITY = 1.75
|
|
||||||
const SCREEN = Dimensions.get('window')
|
const SCREEN = Dimensions.get('window')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const MIN_DOUBLE_TAP_SCALE = 2
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
const initialTransform = createTransform()
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (isZoomed: boolean) => void
|
onZoom: (isZoomed: boolean) => void
|
||||||
onLongPress: (image: ImageSource) => void
|
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||||
delayLongPress: number
|
isScrollViewBeingDragged: boolean
|
||||||
swipeToCloseEnabled?: boolean
|
|
||||||
doubleTapToZoomEnabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
|
||||||
|
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
imageSrc,
|
imageSrc,
|
||||||
onZoom,
|
onZoom,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onLongPress,
|
isScrollViewBeingDragged,
|
||||||
delayLongPress,
|
pinchGestureRef,
|
||||||
swipeToCloseEnabled = true,
|
|
||||||
doubleTapToZoomEnabled = true,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const imageDimensions = useImageDimensions(imageSrc)
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const committedTransform = useSharedValue(initialTransform)
|
||||||
const scrollValueY = new Animated.Value(0)
|
const panTranslation = useSharedValue({x: 0, y: 0})
|
||||||
const [isLoaded, setLoadEnd] = useState(false)
|
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
||||||
|
const pinchScale = useSharedValue(1)
|
||||||
|
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
||||||
|
const dismissSwipeTranslateY = useSharedValue(0)
|
||||||
|
const containerRef = useAnimatedRef()
|
||||||
|
|
||||||
const onLoaded = useCallback(() => setLoadEnd(true), [])
|
// Keep track of when we're entering or leaving scaled rendering.
|
||||||
const onZoomPerformed = useCallback(
|
// Note: DO NOT move any logic reading animated values outside this function.
|
||||||
(isZoomed: boolean) => {
|
useAnimatedReaction(
|
||||||
onZoom(isZoomed)
|
() => {
|
||||||
if (imageContainer?.current) {
|
if (pinchScale.value !== 1) {
|
||||||
imageContainer.current.setNativeProps({
|
// We're currently pinching.
|
||||||
scrollEnabled: !isZoomed,
|
return true
|
||||||
})
|
}
|
||||||
|
const [, , committedScale] = readTransform(committedTransform.value)
|
||||||
|
if (committedScale !== 1) {
|
||||||
|
// We started from a pinched in state.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// We're at rest.
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
(nextIsScaled, prevIsScaled) => {
|
||||||
|
if (nextIsScaled !== prevIsScaled) {
|
||||||
|
runOnJS(handleZoom)(nextIsScaled)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onZoom],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onLongPressHandler = useCallback(() => {
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
onLongPress(imageSrc)
|
setIsScaled(nextIsScaled)
|
||||||
}, [imageSrc, onLongPress])
|
onZoom(nextIsScaled)
|
||||||
|
}
|
||||||
|
|
||||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
initialScale: scale || 1,
|
// Apply the active adjustments on top of the committed transform before the gestures.
|
||||||
initialTranslate: translate || {x: 0, y: 0},
|
// This is matrix multiplication, so operations are applied in the reverse order.
|
||||||
onZoom: onZoomPerformed,
|
let t = createTransform()
|
||||||
doubleTapToZoomEnabled,
|
prependPan(t, panTranslation.value)
|
||||||
onLongPress: onLongPressHandler,
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||||
delayLongPress,
|
prependTransform(t, committedTransform.value)
|
||||||
})
|
const [translateX, translateY, scale] = readTransform(t)
|
||||||
|
|
||||||
const imagesStyles = getImageStyles(
|
const dismissDistance = dismissSwipeTranslateY.value
|
||||||
imageDimensions,
|
const dismissProgress = Math.min(
|
||||||
translateValue,
|
Math.abs(dismissDistance) / (SCREEN.height / 2),
|
||||||
scaleValue,
|
1,
|
||||||
)
|
)
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
return {
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
opacity: 1 - dismissProgress,
|
||||||
outputRange: [0.7, 1, 0.7],
|
transform: [
|
||||||
})
|
{translateX},
|
||||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
{translateY: translateY + dismissDistance},
|
||||||
|
{scale},
|
||||||
const onScrollEndDrag = ({
|
],
|
||||||
nativeEvent,
|
|
||||||
}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
|
||||||
|
|
||||||
if (
|
|
||||||
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
|
|
||||||
offsetY > SWIPE_CLOSE_OFFSET) ||
|
|
||||||
offsetY > SCREEN_HEIGHT / 2
|
|
||||||
) {
|
|
||||||
onRequestClose()
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
|
||||||
|
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||||
|
function getExtraTranslationToStayInBounds(
|
||||||
|
candidateTransform: TransformMatrix,
|
||||||
|
) {
|
||||||
|
'worklet'
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return [0, 0]
|
||||||
|
}
|
||||||
|
const [nextTranslateX, nextTranslateY, nextScale] =
|
||||||
|
readTransform(candidateTransform)
|
||||||
|
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
|
||||||
|
const clampedTranslateX = clampTranslation(
|
||||||
|
nextTranslateX,
|
||||||
|
scaledDimensions.width,
|
||||||
|
SCREEN.width,
|
||||||
|
)
|
||||||
|
const clampedTranslateY = clampTranslation(
|
||||||
|
nextTranslateY,
|
||||||
|
scaledDimensions.height,
|
||||||
|
SCREEN.height,
|
||||||
|
)
|
||||||
|
const dx = clampedTranslateX - nextTranslateX
|
||||||
|
const dy = clampedTranslateY - nextTranslateY
|
||||||
|
return [dx, dy]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
// This is a hack.
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
// 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()
|
||||||
|
})
|
||||||
|
|
||||||
scrollValueY.setValue(offsetY)
|
const pinch = Gesture.Pinch()
|
||||||
}
|
.withRef(pinchGestureRef)
|
||||||
|
.onStart(e => {
|
||||||
|
pinchOrigin.value = {
|
||||||
|
x: e.focalX - SCREEN.width / 2,
|
||||||
|
y: e.focalY - SCREEN.height / 2,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onChange(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Don't let the picture zoom in so close that it gets blurry.
|
||||||
|
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
|
||||||
|
const [, , committedScale] = readTransform(committedTransform.value)
|
||||||
|
const maxCommittedScale =
|
||||||
|
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
|
const minPinchScale = 1 / committedScale
|
||||||
|
const maxPinchScale = maxCommittedScale / committedScale
|
||||||
|
const nextPinchScale = Math.min(
|
||||||
|
Math.max(minPinchScale, e.scale),
|
||||||
|
maxPinchScale,
|
||||||
|
)
|
||||||
|
pinchScale.value = nextPinchScale
|
||||||
|
|
||||||
|
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
|
||||||
|
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
|
||||||
|
const t = createTransform()
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
pinchTranslation.value = {
|
||||||
|
x: pinchTranslation.value.x + dx,
|
||||||
|
y: pinchTranslation.value.y + dy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
// Commit just the pinch.
|
||||||
|
let t = createTransform()
|
||||||
|
prependPinch(
|
||||||
|
t,
|
||||||
|
pinchScale.value,
|
||||||
|
pinchOrigin.value,
|
||||||
|
pinchTranslation.value,
|
||||||
|
)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
applyRounding(t)
|
||||||
|
committedTransform.value = t
|
||||||
|
|
||||||
|
// Reset just the pinch.
|
||||||
|
pinchScale.value = 1
|
||||||
|
pinchOrigin.value = {x: 0, y: 0}
|
||||||
|
pinchTranslation.value = {x: 0, y: 0}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.averageTouches(true)
|
||||||
|
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
|
||||||
|
.minPointers(isScaled ? 1 : 2)
|
||||||
|
.onChange(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextPanTranslation = {x: e.translationX, y: e.translationY}
|
||||||
|
let t = createTransform()
|
||||||
|
prependPan(t, nextPanTranslation)
|
||||||
|
prependPinch(
|
||||||
|
t,
|
||||||
|
pinchScale.value,
|
||||||
|
pinchOrigin.value,
|
||||||
|
pinchTranslation.value,
|
||||||
|
)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
|
||||||
|
// Prevent panning from going out of bounds.
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||||
|
nextPanTranslation.x += dx
|
||||||
|
nextPanTranslation.y += dy
|
||||||
|
panTranslation.value = nextPanTranslation
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
// Commit just the pan.
|
||||||
|
let t = createTransform()
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
applyRounding(t)
|
||||||
|
committedTransform.value = t
|
||||||
|
|
||||||
|
// Reset just the pan.
|
||||||
|
panTranslation.value = {x: 0, y: 0}
|
||||||
|
})
|
||||||
|
|
||||||
|
const doubleTap = Gesture.Tap()
|
||||||
|
.numberOfTaps(2)
|
||||||
|
.onEnd(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [, , committedScale] = readTransform(committedTransform.value)
|
||||||
|
if (committedScale !== 1) {
|
||||||
|
// Go back to 1:1 using the identity vector.
|
||||||
|
let t = createTransform()
|
||||||
|
committedTransform.value = withClampedSpring(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
|
||||||
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
const candidateScale = Math.max(
|
||||||
|
imageAspect / screenAspect,
|
||||||
|
screenAspect / imageAspect,
|
||||||
|
MIN_DOUBLE_TAP_SCALE,
|
||||||
|
)
|
||||||
|
// But don't zoom in so close that the picture gets blurry.
|
||||||
|
const maxScale =
|
||||||
|
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
|
const scale = Math.min(candidateScale, maxScale)
|
||||||
|
|
||||||
|
// Calculate where we would be if the user pinched into the double tapped point.
|
||||||
|
// We won't use this transform directly because it may go out of bounds.
|
||||||
|
const candidateTransform = createTransform()
|
||||||
|
const origin = {
|
||||||
|
x: e.absoluteX - SCREEN.width / 2,
|
||||||
|
y: e.absoluteY - SCREEN.height / 2,
|
||||||
|
}
|
||||||
|
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
|
||||||
|
|
||||||
|
// Now we know how much we went out of bounds, so we can shoot correctly.
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
|
||||||
|
const finalTransform = createTransform()
|
||||||
|
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
|
||||||
|
committedTransform.value = withClampedSpring(finalTransform)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dismissSwipePan = Gesture.Pan()
|
||||||
|
.enabled(!isScaled)
|
||||||
|
.activeOffsetY([-10, 10])
|
||||||
|
.failOffsetX([-10, 10])
|
||||||
|
.maxPointers(1)
|
||||||
|
.onUpdate(e => {
|
||||||
|
dismissSwipeTranslateY.value = e.translationY
|
||||||
|
})
|
||||||
|
.onEnd(e => {
|
||||||
|
if (Math.abs(e.velocityY) > 1000) {
|
||||||
|
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
|
||||||
|
runOnJS(onRequestClose)()
|
||||||
|
} else {
|
||||||
|
dismissSwipeTranslateY.value = withSpring(0, {
|
||||||
|
stiffness: 700,
|
||||||
|
damping: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = !isLoaded || !imageDimensions
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<Animated.View ref={containerRef} style={styles.container}>
|
||||||
ref={imageContainer}
|
{isLoading && (
|
||||||
style={styles.listItem}
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
pagingEnabled
|
)}
|
||||||
nestedScrollEnabled
|
<GestureDetector
|
||||||
showsHorizontalScrollIndicator={false}
|
gesture={Gesture.Exclusive(
|
||||||
showsVerticalScrollIndicator={false}
|
consumeHScroll,
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
dismissSwipePan,
|
||||||
scrollEnabled={swipeToCloseEnabled}
|
Gesture.Simultaneous(pinch, pan),
|
||||||
{...(swipeToCloseEnabled && {
|
doubleTap,
|
||||||
onScroll,
|
)}>
|
||||||
onScrollEndDrag,
|
<AnimatedImage
|
||||||
})}>
|
source={imageSrc}
|
||||||
<AnimatedImage
|
contentFit="contain"
|
||||||
{...panHandlers}
|
style={[styles.image, animatedStyle]}
|
||||||
source={imageSrc}
|
accessibilityLabel={imageSrc.alt}
|
||||||
style={imageStylesWithOpacity}
|
accessibilityHint=""
|
||||||
onLoad={onLoaded}
|
onLoad={() => setIsLoaded(true)}
|
||||||
accessibilityLabel={imageSrc.alt}
|
/>
|
||||||
accessibilityHint=""
|
</GestureDetector>
|
||||||
/>
|
</Animated.View>
|
||||||
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
|
||||||
</ScrollView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
listItem: {
|
container: {
|
||||||
width: SCREEN_WIDTH,
|
width: SCREEN.width,
|
||||||
height: SCREEN_HEIGHT,
|
height: SCREEN.height,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
imageScrollContainer: {
|
image: {
|
||||||
height: SCREEN_HEIGHT * 2,
|
flex: 1,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getScaledDimensions(
|
||||||
|
imageDimensions: ImageDimensions,
|
||||||
|
scale: number,
|
||||||
|
): ImageDimensions {
|
||||||
|
'worklet'
|
||||||
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
const isLandscape = imageAspect > screenAspect
|
||||||
|
if (isLandscape) {
|
||||||
|
return {
|
||||||
|
width: scale * SCREEN.width,
|
||||||
|
height: (scale * SCREEN.width) / imageAspect,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
width: scale * SCREEN.height * imageAspect,
|
||||||
|
height: scale * SCREEN.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTranslation(
|
||||||
|
value: number,
|
||||||
|
scaledSize: number,
|
||||||
|
screenSize: number,
|
||||||
|
): number {
|
||||||
|
'worklet'
|
||||||
|
// Figure out how much the user should be allowed to pan, and constrain the translation.
|
||||||
|
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
|
||||||
|
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
|
||||||
|
return clampedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function withClampedSpring(value: any) {
|
||||||
|
'worklet'
|
||||||
|
return withSpring(value, {overshootClamping: true})
|
||||||
|
}
|
||||||
|
|
||||||
export default React.memo(ImageItem)
|
export default React.memo(ImageItem)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useCallback, useRef, useState} from 'react'
|
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
@@ -16,71 +16,52 @@ import {
|
|||||||
View,
|
View,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
|
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 useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
|
|
||||||
import {getImageStyles, getImageTransform} from '../../utils'
|
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||||
import {ImageSource} from '../../@types'
|
|
||||||
import {ImageLoading} from './ImageLoading'
|
import {ImageLoading} from './ImageLoading'
|
||||||
|
|
||||||
|
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 SCREEN_WIDTH = SCREEN.width
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const SCREEN_HEIGHT = SCREEN.height
|
||||||
|
const MIN_ZOOM = 2
|
||||||
const MAX_SCALE = 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
|
||||||
onLongPress: (image: ImageSource) => void
|
pinchGestureRef: MutableRefObject<GestureType>
|
||||||
delayLongPress: number
|
isScrollViewBeingDragged: boolean
|
||||||
swipeToCloseEnabled?: boolean
|
|
||||||
doubleTapToZoomEnabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
|
||||||
const ImageItem = ({
|
let lastTapTS: number | null = null
|
||||||
imageSrc,
|
|
||||||
onZoom,
|
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||||
onRequestClose,
|
|
||||||
onLongPress,
|
|
||||||
delayLongPress,
|
|
||||||
swipeToCloseEnabled = true,
|
|
||||||
doubleTapToZoomEnabled = true,
|
|
||||||
}: Props) => {
|
|
||||||
const scrollViewRef = useRef<ScrollView>(null)
|
const scrollViewRef = useRef<ScrollView>(null)
|
||||||
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 handleDoubleTap = useDoubleTapToZoom(
|
|
||||||
scrollViewRef,
|
|
||||||
scaled,
|
|
||||||
SCREEN,
|
|
||||||
imageDimensions,
|
|
||||||
)
|
|
||||||
|
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||||
const scrollValueY = new Animated.Value(0)
|
const [scrollValueY] = useState(() => new Animated.Value(0))
|
||||||
const scaleValue = new Animated.Value(scale || 1)
|
|
||||||
const translateValue = new Animated.ValueXY(translate)
|
|
||||||
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
||||||
|
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
const imageOpacity = scrollValueY.interpolate({
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||||
outputRange: [0.5, 1, 0.5],
|
outputRange: [0.5, 1, 0.5],
|
||||||
})
|
})
|
||||||
const imagesStyles = getImageStyles(
|
const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1)
|
||||||
imageDimensions,
|
|
||||||
translateValue,
|
|
||||||
scaleValue,
|
|
||||||
)
|
|
||||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
||||||
|
|
||||||
const onScrollEndDrag = useCallback(
|
const onScrollEndDrag = useCallback(
|
||||||
@@ -91,15 +72,11 @@ const ImageItem = ({
|
|||||||
onZoom(currentScaled)
|
onZoom(currentScaled)
|
||||||
setScaled(currentScaled)
|
setScaled(currentScaled)
|
||||||
|
|
||||||
if (
|
if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||||
!currentScaled &&
|
|
||||||
swipeToCloseEnabled &&
|
|
||||||
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
|
|
||||||
) {
|
|
||||||
onRequestClose()
|
onRequestClose()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onRequestClose, onZoom, swipeToCloseEnabled],
|
[onRequestClose, onZoom],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
@@ -112,9 +89,40 @@ const ImageItem = ({
|
|||||||
scrollValueY.setValue(offsetY)
|
scrollValueY.setValue(offsetY)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLongPressHandler = useCallback(() => {
|
const handleDoubleTap = useCallback(
|
||||||
onLongPress(imageSrc)
|
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
||||||
}, [imageSrc, onLongPress])
|
const nowTS = new Date().getTime()
|
||||||
|
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||||
|
|
||||||
|
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
||||||
|
let nextZoomRect = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: SCREEN.width,
|
||||||
|
height: SCREEN.height,
|
||||||
|
}
|
||||||
|
|
||||||
|
const willZoom = !scaled
|
||||||
|
if (willZoom) {
|
||||||
|
const {pageX, pageY} = event.nativeEvent
|
||||||
|
nextZoomRect = getZoomRectAfterDoubleTap(
|
||||||
|
imageDimensions,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
scrollResponderRef?.scrollResponderZoomTo({
|
||||||
|
...nextZoomRect, // This rect is in screen coordinates
|
||||||
|
animated: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
lastTapTS = nowTS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageDimensions, scaled],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -126,17 +134,13 @@ const ImageItem = ({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxScrollViewZoom}
|
maximumZoomScale={maxScrollViewZoom}
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
scrollEnabled={swipeToCloseEnabled}
|
scrollEnabled={true}
|
||||||
|
onScroll={onScroll}
|
||||||
onScrollEndDrag={onScrollEndDrag}
|
onScrollEndDrag={onScrollEndDrag}
|
||||||
scrollEventThrottle={1}
|
scrollEventThrottle={1}>
|
||||||
{...(swipeToCloseEnabled && {
|
|
||||||
onScroll,
|
|
||||||
})}>
|
|
||||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
|
onPress={handleDoubleTap}
|
||||||
onLongPress={onLongPressHandler}
|
|
||||||
delayLongPress={delayLongPress}
|
|
||||||
accessibilityRole="image"
|
accessibilityRole="image"
|
||||||
accessibilityLabel={imageSrc.alt}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
@@ -161,4 +165,149 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getZoomRectAfterDoubleTap = (
|
||||||
|
imageDimensions: ImageDimensions | null,
|
||||||
|
touchX: number,
|
||||||
|
touchY: number,
|
||||||
|
): {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
} => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: SCREEN.width,
|
||||||
|
height: SCREEN.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, let's figure out how much we want to zoom in.
|
||||||
|
// We want to try to zoom in at least close enough to get rid of black bars.
|
||||||
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
const zoom = Math.max(
|
||||||
|
imageAspect / screenAspect,
|
||||||
|
screenAspect / imageAspect,
|
||||||
|
MIN_ZOOM,
|
||||||
|
)
|
||||||
|
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||||
|
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||||
|
|
||||||
|
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
|
||||||
|
// We already know the zoom level, so this gives us the rectangle size.
|
||||||
|
let rectWidth = SCREEN.width / zoom
|
||||||
|
let rectHeight = SCREEN.height / zoom
|
||||||
|
|
||||||
|
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
|
||||||
|
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
||||||
|
let minX = 0
|
||||||
|
let minY = 0
|
||||||
|
let maxX = SCREEN.width - rectWidth
|
||||||
|
let maxY = SCREEN.height - rectHeight
|
||||||
|
if (imageAspect >= screenAspect) {
|
||||||
|
// The image has horizontal black bars. Exclude them from the safe area.
|
||||||
|
const renderedHeight = SCREEN.width / imageAspect
|
||||||
|
const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
|
||||||
|
minY += horizontalBarHeight
|
||||||
|
maxY -= horizontalBarHeight
|
||||||
|
} else {
|
||||||
|
// The image has vertical black bars. Exclude them from the safe area.
|
||||||
|
const renderedWidth = SCREEN.height * imageAspect
|
||||||
|
const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
|
||||||
|
minX += verticalBarWidth
|
||||||
|
maxX -= verticalBarWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, we can position the rect according to its size and the safe area.
|
||||||
|
let rectX
|
||||||
|
if (maxX >= minX) {
|
||||||
|
// Content fills the screen horizontally so we have horizontal wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
rectX = touchX - touchX / zoom
|
||||||
|
rectX = Math.min(rectX, maxX)
|
||||||
|
rectX = Math.max(rectX, minX)
|
||||||
|
} else {
|
||||||
|
// Keep the rect centered on the screen so that black bars are balanced.
|
||||||
|
rectX = SCREEN.width / 2 - rectWidth / 2
|
||||||
|
}
|
||||||
|
let rectY
|
||||||
|
if (maxY >= minY) {
|
||||||
|
// Content fills the screen vertically so we have vertical wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
rectY = touchY - touchY / zoom
|
||||||
|
rectY = Math.min(rectY, maxY)
|
||||||
|
rectY = Math.max(rectY, minY)
|
||||||
|
} else {
|
||||||
|
// Keep the rect centered on the screen so that black bars are balanced.
|
||||||
|
rectY = SCREEN.height / 2 - rectHeight / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rectX,
|
||||||
|
y: rectY,
|
||||||
|
height: rectHeight,
|
||||||
|
width: rectWidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,17 +1,16 @@
|
|||||||
// default implementation fallback for web
|
// default implementation fallback for web
|
||||||
|
|
||||||
import React from 'react'
|
import React, {MutableRefObject} 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
|
||||||
onLongPress: (image: ImageSource) => void
|
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||||
delayLongPress: number
|
isScrollViewBeingDragged: boolean
|
||||||
swipeToCloseEnabled?: boolean
|
|
||||||
doubleTapToZoomEnabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = (_props: Props) => {
|
const ImageItem = (_props: Props) => {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {Animated} from 'react-native'
|
|
||||||
|
|
||||||
const INITIAL_POSITION = {x: 0, y: 0}
|
|
||||||
const ANIMATION_CONFIG = {
|
|
||||||
duration: 200,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const useAnimatedComponents = () => {
|
|
||||||
const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
|
|
||||||
const footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
|
|
||||||
|
|
||||||
const toggleVisible = (isVisible: boolean) => {
|
|
||||||
if (isVisible) {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
|
||||||
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
|
||||||
]).start()
|
|
||||||
} else {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(headerTranslate.y, {
|
|
||||||
...ANIMATION_CONFIG,
|
|
||||||
toValue: -300,
|
|
||||||
}),
|
|
||||||
Animated.timing(footerTranslate.y, {
|
|
||||||
...ANIMATION_CONFIG,
|
|
||||||
toValue: 300,
|
|
||||||
}),
|
|
||||||
]).start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerTransform = headerTranslate.getTranslateTransform()
|
|
||||||
const footerTransform = footerTranslate.getTranslateTransform()
|
|
||||||
|
|
||||||
return [headerTransform, footerTransform, toggleVisible] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useAnimatedComponents
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, {useCallback} from 'react'
|
|
||||||
import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
|
|
||||||
|
|
||||||
import {Dimensions} from '../@types'
|
|
||||||
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
|
||||||
const MIN_ZOOM = 2
|
|
||||||
|
|
||||||
let lastTapTS: number | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is iOS only.
|
|
||||||
* Same functionality for Android implemented inside usePanResponder hook.
|
|
||||||
*/
|
|
||||||
function useDoubleTapToZoom(
|
|
||||||
scrollViewRef: React.RefObject<ScrollView>,
|
|
||||||
scaled: boolean,
|
|
||||||
screen: Dimensions,
|
|
||||||
imageDimensions: Dimensions | null,
|
|
||||||
) {
|
|
||||||
const handleDoubleTap = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
|
||||||
const nowTS = new Date().getTime()
|
|
||||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
|
||||||
|
|
||||||
const getZoomRectAfterDoubleTap = (
|
|
||||||
touchX: number,
|
|
||||||
touchY: number,
|
|
||||||
): {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
} => {
|
|
||||||
if (!imageDimensions) {
|
|
||||||
return {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: screen.width,
|
|
||||||
height: screen.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, let's figure out how much we want to zoom in.
|
|
||||||
// We want to try to zoom in at least close enough to get rid of black bars.
|
|
||||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
|
||||||
const screenAspect = screen.width / screen.height
|
|
||||||
const zoom = Math.max(
|
|
||||||
imageAspect / screenAspect,
|
|
||||||
screenAspect / imageAspect,
|
|
||||||
MIN_ZOOM,
|
|
||||||
)
|
|
||||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
|
||||||
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
|
||||||
|
|
||||||
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
|
|
||||||
// We already know the zoom level, so this gives us the rectangle size.
|
|
||||||
let rectWidth = screen.width / zoom
|
|
||||||
let rectHeight = screen.height / zoom
|
|
||||||
|
|
||||||
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
|
|
||||||
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
|
||||||
let minX = 0
|
|
||||||
let minY = 0
|
|
||||||
let maxX = screen.width - rectWidth
|
|
||||||
let maxY = screen.height - rectHeight
|
|
||||||
if (imageAspect >= screenAspect) {
|
|
||||||
// The image has horizontal black bars. Exclude them from the safe area.
|
|
||||||
const renderedHeight = screen.width / imageAspect
|
|
||||||
const horizontalBarHeight = (screen.height - renderedHeight) / 2
|
|
||||||
minY += horizontalBarHeight
|
|
||||||
maxY -= horizontalBarHeight
|
|
||||||
} else {
|
|
||||||
// The image has vertical black bars. Exclude them from the safe area.
|
|
||||||
const renderedWidth = screen.height * imageAspect
|
|
||||||
const verticalBarWidth = (screen.width - renderedWidth) / 2
|
|
||||||
minX += verticalBarWidth
|
|
||||||
maxX -= verticalBarWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, we can position the rect according to its size and the safe area.
|
|
||||||
let rectX
|
|
||||||
if (maxX >= minX) {
|
|
||||||
// Content fills the screen horizontally so we have horizontal wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
rectX = touchX - touchX / zoom
|
|
||||||
rectX = Math.min(rectX, maxX)
|
|
||||||
rectX = Math.max(rectX, minX)
|
|
||||||
} else {
|
|
||||||
// Keep the rect centered on the screen so that black bars are balanced.
|
|
||||||
rectX = screen.width / 2 - rectWidth / 2
|
|
||||||
}
|
|
||||||
let rectY
|
|
||||||
if (maxY >= minY) {
|
|
||||||
// Content fills the screen vertically so we have vertical wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
rectY = touchY - touchY / zoom
|
|
||||||
rectY = Math.min(rectY, maxY)
|
|
||||||
rectY = Math.max(rectY, minY)
|
|
||||||
} else {
|
|
||||||
// Keep the rect centered on the screen so that black bars are balanced.
|
|
||||||
rectY = screen.height / 2 - rectHeight / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: rectX,
|
|
||||||
y: rectY,
|
|
||||||
height: rectHeight,
|
|
||||||
width: rectWidth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
|
||||||
let nextZoomRect = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: screen.width,
|
|
||||||
height: screen.height,
|
|
||||||
}
|
|
||||||
|
|
||||||
const willZoom = !scaled
|
|
||||||
if (willZoom) {
|
|
||||||
const {pageX, pageY} = event.nativeEvent
|
|
||||||
nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
scrollResponderRef?.scrollResponderZoomTo({
|
|
||||||
...nextZoomRect, // This rect is in screen coordinates
|
|
||||||
animated: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
lastTapTS = nowTS
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
|
|
||||||
)
|
|
||||||
|
|
||||||
return handleDoubleTap
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useDoubleTapToZoom
|
|
||||||
@@ -8,11 +8,29 @@
|
|||||||
|
|
||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {Image, ImageURISource} from 'react-native'
|
import {Image, ImageURISource} from 'react-native'
|
||||||
|
|
||||||
import {createCache} from '../utils'
|
|
||||||
import {Dimensions, ImageSource} from '../@types'
|
import {Dimensions, ImageSource} from '../@types'
|
||||||
|
|
||||||
const CACHE_SIZE = 50
|
const CACHE_SIZE = 50
|
||||||
|
|
||||||
|
type CacheStorageItem = {key: string; value: any}
|
||||||
|
|
||||||
|
const createCache = (cacheSize: number) => ({
|
||||||
|
_storage: [] as CacheStorageItem[],
|
||||||
|
get(key: string): any {
|
||||||
|
const {value} =
|
||||||
|
this._storage.find(({key: storageKey}) => storageKey === key) || {}
|
||||||
|
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
set(key: string, value: any) {
|
||||||
|
if (this._storage.length >= cacheSize) {
|
||||||
|
this._storage.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._storage.push({key, value})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const imageDimensionsCache = createCache(CACHE_SIZE)
|
const imageDimensionsCache = createCache(CACHE_SIZE)
|
||||||
|
|
||||||
const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useState} from 'react'
|
|
||||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
|
||||||
|
|
||||||
import {Dimensions} from '../@types'
|
|
||||||
|
|
||||||
const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
|
|
||||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
|
||||||
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
const {
|
|
||||||
nativeEvent: {
|
|
||||||
contentOffset: {x: scrollX},
|
|
||||||
},
|
|
||||||
} = event
|
|
||||||
|
|
||||||
if (screen.width) {
|
|
||||||
const nextIndex = Math.round(scrollX / screen.width)
|
|
||||||
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [currentImageIndex, onScroll] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useImageIndexChange
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useEffect} from 'react'
|
|
||||||
import {Image} from 'react-native'
|
|
||||||
import {ImageSource} from '../@types'
|
|
||||||
|
|
||||||
const useImagePrefetch = (images: ImageSource[]) => {
|
|
||||||
useEffect(() => {
|
|
||||||
images.forEach(image => {
|
|
||||||
//@ts-ignore
|
|
||||||
if (image.uri) {
|
|
||||||
//@ts-ignore
|
|
||||||
return Image.prefetch(image.uri)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [images])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useImagePrefetch
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useEffect} from 'react'
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
GestureResponderEvent,
|
|
||||||
GestureResponderHandlers,
|
|
||||||
NativeTouchEvent,
|
|
||||||
PanResponder,
|
|
||||||
PanResponderGestureState,
|
|
||||||
} from 'react-native'
|
|
||||||
|
|
||||||
import {Position} from '../@types'
|
|
||||||
import {
|
|
||||||
getDistanceBetweenTouches,
|
|
||||||
getImageTranslate,
|
|
||||||
getImageDimensionsByTranslate,
|
|
||||||
} from '../utils'
|
|
||||||
|
|
||||||
const SCREEN = Dimensions.get('window')
|
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
|
||||||
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
|
|
||||||
const ANDROID_BAR_HEIGHT = 24
|
|
||||||
|
|
||||||
const MIN_ZOOM = 2
|
|
||||||
const MAX_SCALE = 2
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
|
||||||
const OUT_BOUND_MULTIPLIER = 0.75
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialScale: number
|
|
||||||
initialTranslate: Position
|
|
||||||
onZoom: (isZoomed: boolean) => void
|
|
||||||
doubleTapToZoomEnabled: boolean
|
|
||||||
onLongPress: () => void
|
|
||||||
delayLongPress: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const usePanResponder = ({
|
|
||||||
initialScale,
|
|
||||||
initialTranslate,
|
|
||||||
onZoom,
|
|
||||||
doubleTapToZoomEnabled,
|
|
||||||
onLongPress,
|
|
||||||
delayLongPress,
|
|
||||||
}: Props): Readonly<
|
|
||||||
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
|
|
||||||
> => {
|
|
||||||
let numberInitialTouches = 1
|
|
||||||
let initialTouches: NativeTouchEvent[] = []
|
|
||||||
let currentScale = initialScale
|
|
||||||
let currentTranslate = initialTranslate
|
|
||||||
let tmpScale = 0
|
|
||||||
let tmpTranslate: Position | null = null
|
|
||||||
let isDoubleTapPerformed = false
|
|
||||||
let lastTapTS: number | null = null
|
|
||||||
let longPressHandlerRef: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
const meaningfulShift = MIN_DIMENSION * 0.01
|
|
||||||
const scaleValue = new Animated.Value(initialScale)
|
|
||||||
const translateValue = new Animated.ValueXY(initialTranslate)
|
|
||||||
|
|
||||||
const imageDimensions = getImageDimensionsByTranslate(
|
|
||||||
initialTranslate,
|
|
||||||
SCREEN,
|
|
||||||
)
|
|
||||||
|
|
||||||
const getBounds = (scale: number) => {
|
|
||||||
const scaledImageDimensions = {
|
|
||||||
width: imageDimensions.width * scale,
|
|
||||||
height: imageDimensions.height * scale,
|
|
||||||
}
|
|
||||||
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
|
|
||||||
|
|
||||||
const left = initialTranslate.x - translateDelta.x
|
|
||||||
const right = left - (scaledImageDimensions.width - SCREEN.width)
|
|
||||||
const top = initialTranslate.y - translateDelta.y
|
|
||||||
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
|
|
||||||
|
|
||||||
return [top, left, bottom, right]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTransformAfterDoubleTap = (
|
|
||||||
touchX: number,
|
|
||||||
touchY: number,
|
|
||||||
): [number, Position] => {
|
|
||||||
let nextScale = initialScale
|
|
||||||
let nextTranslateX = initialTranslate.x
|
|
||||||
let nextTranslateY = initialTranslate.y
|
|
||||||
|
|
||||||
// First, let's figure out how much we want to zoom in.
|
|
||||||
// We want to try to zoom in at least close enough to get rid of black bars.
|
|
||||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
|
||||||
const screenAspect = SCREEN.width / SCREEN.height
|
|
||||||
let zoom = Math.max(
|
|
||||||
imageAspect / screenAspect,
|
|
||||||
screenAspect / imageAspect,
|
|
||||||
MIN_ZOOM,
|
|
||||||
)
|
|
||||||
// Don't zoom so hard that the original image's pixels become blurry.
|
|
||||||
zoom = Math.min(zoom, MAX_SCALE / initialScale)
|
|
||||||
nextScale = initialScale * zoom
|
|
||||||
|
|
||||||
// Next, let's see if we need to adjust the scaled image translation.
|
|
||||||
// Ideally, we want the tapped point to stay under the finger after the scaling.
|
|
||||||
const dx = SCREEN.width / 2 - touchX
|
|
||||||
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
|
|
||||||
// Before we try to adjust the translation, check how much wiggle room we have.
|
|
||||||
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
|
||||||
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
|
|
||||||
if (leftBound > rightBound) {
|
|
||||||
// Content fills the screen horizontally so we have horizontal wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
nextTranslateX += dx * zoom - dx
|
|
||||||
nextTranslateX = Math.min(nextTranslateX, leftBound)
|
|
||||||
nextTranslateX = Math.max(nextTranslateX, rightBound)
|
|
||||||
}
|
|
||||||
if (topBound > bottomBound) {
|
|
||||||
// Content fills the screen vertically so we have vertical wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
nextTranslateY += dy * zoom - dy
|
|
||||||
nextTranslateY = Math.min(nextTranslateY, topBound)
|
|
||||||
nextTranslateY = Math.max(nextTranslateY, bottomBound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
nextScale,
|
|
||||||
{
|
|
||||||
x: nextTranslateX,
|
|
||||||
y: nextTranslateY,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fitsScreenByWidth = () =>
|
|
||||||
imageDimensions.width * currentScale < SCREEN_WIDTH
|
|
||||||
const fitsScreenByHeight = () =>
|
|
||||||
imageDimensions.height * currentScale < SCREEN_HEIGHT
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scaleValue.addListener(({value}) => {
|
|
||||||
if (typeof onZoom === 'function') {
|
|
||||||
onZoom(value !== initialScale)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => scaleValue.removeAllListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
const cancelLongPressHandle = () => {
|
|
||||||
longPressHandlerRef && clearTimeout(longPressHandlerRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
const panResponder = PanResponder.create({
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
|
||||||
onStartShouldSetPanResponderCapture: () => true,
|
|
||||||
onMoveShouldSetPanResponder: () => true,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => true,
|
|
||||||
onPanResponderGrant: (
|
|
||||||
_: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
numberInitialTouches = gestureState.numberActiveTouches
|
|
||||||
|
|
||||||
if (gestureState.numberActiveTouches > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
|
|
||||||
},
|
|
||||||
onPanResponderStart: (
|
|
||||||
event: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
initialTouches = event.nativeEvent.touches
|
|
||||||
numberInitialTouches = gestureState.numberActiveTouches
|
|
||||||
|
|
||||||
if (gestureState.numberActiveTouches > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tapTS = Date.now()
|
|
||||||
// Handle double tap event by calculating diff between first and second taps timestamps
|
|
||||||
|
|
||||||
isDoubleTapPerformed = Boolean(
|
|
||||||
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
|
||||||
let nextScale = initialScale
|
|
||||||
let nextTranslate = initialTranslate
|
|
||||||
|
|
||||||
const willZoom = currentScale === initialScale
|
|
||||||
if (willZoom) {
|
|
||||||
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
|
|
||||||
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
|
|
||||||
touchX,
|
|
||||||
touchY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onZoom(willZoom)
|
|
||||||
|
|
||||||
Animated.parallel(
|
|
||||||
[
|
|
||||||
Animated.timing(translateValue.x, {
|
|
||||||
toValue: nextTranslate.x,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateValue.y, {
|
|
||||||
toValue: nextTranslate.y,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(scaleValue, {
|
|
||||||
toValue: nextScale,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{stopTogether: false},
|
|
||||||
).start(() => {
|
|
||||||
currentScale = nextScale
|
|
||||||
currentTranslate = nextTranslate
|
|
||||||
})
|
|
||||||
|
|
||||||
lastTapTS = null
|
|
||||||
} else {
|
|
||||||
lastTapTS = Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderMove: (
|
|
||||||
event: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
const {dx, dy} = gestureState
|
|
||||||
|
|
||||||
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't need to handle move because double tap in progress (was handled in onStart)
|
|
||||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
numberInitialTouches === 1 &&
|
|
||||||
gestureState.numberActiveTouches === 2
|
|
||||||
) {
|
|
||||||
numberInitialTouches = 2
|
|
||||||
initialTouches = event.nativeEvent.touches
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTapGesture =
|
|
||||||
numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
|
|
||||||
const isPinchGesture =
|
|
||||||
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
|
|
||||||
|
|
||||||
if (isPinchGesture) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
|
|
||||||
const initialDistance = getDistanceBetweenTouches(initialTouches)
|
|
||||||
const currentDistance = getDistanceBetweenTouches(
|
|
||||||
event.nativeEvent.touches,
|
|
||||||
)
|
|
||||||
|
|
||||||
let nextScale = (currentDistance / initialDistance) * currentScale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case image is scaling smaller than initial size ->
|
|
||||||
* slow down this transition by applying OUT_BOUND_MULTIPLIER
|
|
||||||
*/
|
|
||||||
if (nextScale < initialScale) {
|
|
||||||
nextScale =
|
|
||||||
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case image is scaling down -> move it in direction of initial position
|
|
||||||
*/
|
|
||||||
if (currentScale > initialScale && currentScale > nextScale) {
|
|
||||||
const k = (currentScale - initialScale) / (currentScale - nextScale)
|
|
||||||
|
|
||||||
const nextTranslateX =
|
|
||||||
nextScale < initialScale
|
|
||||||
? initialTranslate.x
|
|
||||||
: currentTranslate.x -
|
|
||||||
(currentTranslate.x - initialTranslate.x) / k
|
|
||||||
|
|
||||||
const nextTranslateY =
|
|
||||||
nextScale < initialScale
|
|
||||||
? initialTranslate.y
|
|
||||||
: currentTranslate.y -
|
|
||||||
(currentTranslate.y - initialTranslate.y) / k
|
|
||||||
|
|
||||||
translateValue.x.setValue(nextTranslateX)
|
|
||||||
translateValue.y.setValue(nextTranslateY)
|
|
||||||
|
|
||||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
|
||||||
}
|
|
||||||
|
|
||||||
scaleValue.setValue(nextScale)
|
|
||||||
tmpScale = nextScale
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTapGesture && currentScale > initialScale) {
|
|
||||||
const {x, y} = currentTranslate
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
||||||
const {dx, dy} = gestureState
|
|
||||||
const [topBound, leftBound, bottomBound, rightBound] =
|
|
||||||
getBounds(currentScale)
|
|
||||||
|
|
||||||
let nextTranslateX = x + dx
|
|
||||||
let nextTranslateY = y + dy
|
|
||||||
|
|
||||||
if (nextTranslateX > leftBound) {
|
|
||||||
nextTranslateX =
|
|
||||||
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateX < rightBound) {
|
|
||||||
nextTranslateX =
|
|
||||||
nextTranslateX -
|
|
||||||
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateY > topBound) {
|
|
||||||
nextTranslateY =
|
|
||||||
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateY < bottomBound) {
|
|
||||||
nextTranslateY =
|
|
||||||
nextTranslateY -
|
|
||||||
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fitsScreenByWidth()) {
|
|
||||||
nextTranslateX = x
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fitsScreenByHeight()) {
|
|
||||||
nextTranslateY = y
|
|
||||||
}
|
|
||||||
|
|
||||||
translateValue.x.setValue(nextTranslateX)
|
|
||||||
translateValue.y.setValue(nextTranslateY)
|
|
||||||
|
|
||||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderRelease: () => {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
|
|
||||||
if (isDoubleTapPerformed) {
|
|
||||||
isDoubleTapPerformed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmpScale > 0) {
|
|
||||||
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
|
|
||||||
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
|
|
||||||
Animated.timing(scaleValue, {
|
|
||||||
toValue: tmpScale,
|
|
||||||
duration: 100,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start()
|
|
||||||
}
|
|
||||||
|
|
||||||
currentScale = tmpScale
|
|
||||||
tmpScale = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmpTranslate) {
|
|
||||||
const {x, y} = tmpTranslate
|
|
||||||
const [topBound, leftBound, bottomBound, rightBound] =
|
|
||||||
getBounds(currentScale)
|
|
||||||
|
|
||||||
let nextTranslateX = x
|
|
||||||
let nextTranslateY = y
|
|
||||||
|
|
||||||
if (!fitsScreenByWidth()) {
|
|
||||||
if (nextTranslateX > leftBound) {
|
|
||||||
nextTranslateX = leftBound
|
|
||||||
} else if (nextTranslateX < rightBound) {
|
|
||||||
nextTranslateX = rightBound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fitsScreenByHeight()) {
|
|
||||||
if (nextTranslateY > topBound) {
|
|
||||||
nextTranslateY = topBound
|
|
||||||
} else if (nextTranslateY < bottomBound) {
|
|
||||||
nextTranslateY = bottomBound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(translateValue.x, {
|
|
||||||
toValue: nextTranslateX,
|
|
||||||
duration: 100,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateValue.y, {
|
|
||||||
toValue: nextTranslateY,
|
|
||||||
duration: 100,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start()
|
|
||||||
|
|
||||||
currentTranslate = {x: nextTranslateX, y: nextTranslateY}
|
|
||||||
tmpTranslate = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderTerminationRequest: () => false,
|
|
||||||
onShouldBlockNativeResponder: () => false,
|
|
||||||
})
|
|
||||||
|
|
||||||
return [panResponder.panHandlers, scaleValue, translateValue]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePanResponder
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useState} from 'react'
|
|
||||||
|
|
||||||
const useRequestClose = (onRequestClose: () => void) => {
|
|
||||||
const [opacity, setOpacity] = useState(1)
|
|
||||||
|
|
||||||
return [
|
|
||||||
opacity,
|
|
||||||
() => {
|
|
||||||
setOpacity(0)
|
|
||||||
onRequestClose()
|
|
||||||
setTimeout(() => setOpacity(1), 0)
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useRequestClose
|
|
||||||
@@ -10,14 +10,17 @@
|
|||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
ComponentType,
|
ComponentType,
|
||||||
|
createRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
NativeScrollEvent,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
VirtualizedList,
|
VirtualizedList,
|
||||||
@@ -29,10 +32,8 @@ 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 useAnimatedComponents from './hooks/useAnimatedComponents'
|
|
||||||
import useImageIndexChange from './hooks/useImageIndexChange'
|
|
||||||
import useRequestClose from './hooks/useRequestClose'
|
|
||||||
import {ImageSource} from './@types'
|
import {ImageSource} from './@types'
|
||||||
|
import {ScrollView, GestureType} from 'react-native-gesture-handler'
|
||||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -41,22 +42,21 @@ type Props = {
|
|||||||
imageIndex: number
|
imageIndex: number
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onLongPress?: (image: ImageSource) => void
|
|
||||||
onImageIndexChange?: (imageIndex: number) => void
|
|
||||||
presentationStyle?: ModalProps['presentationStyle']
|
presentationStyle?: ModalProps['presentationStyle']
|
||||||
animationType?: ModalProps['animationType']
|
animationType?: ModalProps['animationType']
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
swipeToCloseEnabled?: boolean
|
|
||||||
doubleTapToZoomEnabled?: boolean
|
|
||||||
delayLongPress?: number
|
|
||||||
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 DEFAULT_DELAY_LONG_PRESS = 800
|
|
||||||
const SCREEN = Dimensions.get('screen')
|
const SCREEN = Dimensions.get('screen')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
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,
|
||||||
@@ -64,35 +64,65 @@ function ImageViewing({
|
|||||||
imageIndex,
|
imageIndex,
|
||||||
visible,
|
visible,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onLongPress = () => {},
|
|
||||||
onImageIndexChange,
|
|
||||||
backgroundColor = DEFAULT_BG_COLOR,
|
backgroundColor = DEFAULT_BG_COLOR,
|
||||||
swipeToCloseEnabled,
|
|
||||||
doubleTapToZoomEnabled,
|
|
||||||
delayLongPress = DEFAULT_DELAY_LONG_PRESS,
|
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
FooterComponent,
|
FooterComponent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
||||||
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [headerTransform, footerTransform, toggleBarsVisible] =
|
const [opacity, setOpacity] = useState(1)
|
||||||
useAnimatedComponents()
|
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
||||||
|
const [headerTranslate] = useState(
|
||||||
useEffect(() => {
|
() => new Animated.ValueXY(INITIAL_POSITION),
|
||||||
if (onImageIndexChange) {
|
|
||||||
onImageIndexChange(currentImageIndex)
|
|
||||||
}
|
|
||||||
}, [currentImageIndex, onImageIndexChange])
|
|
||||||
|
|
||||||
const onZoom = useCallback(
|
|
||||||
(isScaled: boolean) => {
|
|
||||||
// @ts-ignore
|
|
||||||
imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
|
|
||||||
toggleBarsVisible(!isScaled)
|
|
||||||
},
|
|
||||||
[toggleBarsVisible],
|
|
||||||
)
|
)
|
||||||
|
const [footerTranslate] = useState(
|
||||||
|
() => new Animated.ValueXY(INITIAL_POSITION),
|
||||||
|
)
|
||||||
|
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
} = event
|
||||||
|
|
||||||
|
if (SCREEN.width) {
|
||||||
|
const nextIndex = Math.round(scrollX / SCREEN.width)
|
||||||
|
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') {
|
||||||
@@ -107,10 +137,23 @@ function ImageViewing({
|
|||||||
}
|
}
|
||||||
}, [imageList, imageIndex])
|
}, [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}
|
||||||
@@ -134,6 +177,7 @@ function ImageViewing({
|
|||||||
data={images}
|
data={images}
|
||||||
horizontal
|
horizontal
|
||||||
pagingEnabled
|
pagingEnabled
|
||||||
|
scrollEnabled={!isScaled || isDragging}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
getItem={(_, index) => images[index]}
|
getItem={(_, index) => images[index]}
|
||||||
@@ -148,13 +192,26 @@ function ImageViewing({
|
|||||||
onZoom={onZoom}
|
onZoom={onZoom}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
onRequestClose={onRequestCloseEnhanced}
|
onRequestClose={onRequestCloseEnhanced}
|
||||||
onLongPress={onLongPress}
|
pinchGestureRef={pinchGestureRefs.get(imageSrc)}
|
||||||
delayLongPress={delayLongPress}
|
isScrollViewBeingDragged={isDragging}
|
||||||
swipeToCloseEnabled={swipeToCloseEnabled}
|
|
||||||
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
onMomentumScrollEnd={onScroll}
|
renderScrollComponent={props => (
|
||||||
|
<ScrollView
|
||||||
|
{...props}
|
||||||
|
waitFor={Array.from(pinchGestureRefs.values())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onScrollBeginDrag={() => {
|
||||||
|
setIsDragging(true)
|
||||||
|
}}
|
||||||
|
onScrollEndDrag={() => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}}
|
||||||
|
onMomentumScrollEnd={e => {
|
||||||
|
setIsScaled(false)
|
||||||
|
onScroll(e)
|
||||||
|
}}
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keyExtractor={(imageSrc, index) =>
|
keyExtractor={(imageSrc, index) =>
|
||||||
keyExtractor
|
keyExtractor
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {Position} from './@types'
|
||||||
|
|
||||||
|
export type TransformMatrix = [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
]
|
||||||
|
|
||||||
|
// These are affine transforms. See explanation of every cell here:
|
||||||
|
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
|
||||||
|
|
||||||
|
export function createTransform(): TransformMatrix {
|
||||||
|
'worklet'
|
||||||
|
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRounding(t: TransformMatrix) {
|
||||||
|
'worklet'
|
||||||
|
t[2] = Math.round(t[2])
|
||||||
|
t[5] = Math.round(t[5])
|
||||||
|
// For example: 0.985, 0.99, 0.995, then 1:
|
||||||
|
t[0] = Math.round(t[0] * 200) / 200
|
||||||
|
t[4] = Math.round(t[0] * 200) / 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
|
||||||
|
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
|
||||||
|
|
||||||
|
// All write operations are applied in-place to avoid unnecessary allocations.
|
||||||
|
|
||||||
|
export function readTransform(t: TransformMatrix): [number, number, number] {
|
||||||
|
'worklet'
|
||||||
|
const scale = t[0]
|
||||||
|
const translateX = t[2]
|
||||||
|
const translateY = t[5]
|
||||||
|
return [translateX, translateY, scale]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
|
||||||
|
'worklet'
|
||||||
|
t[2] += t[0] * x + t[1] * y
|
||||||
|
t[5] += t[3] * x + t[4] * y
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependScale(t: TransformMatrix, value: number) {
|
||||||
|
'worklet'
|
||||||
|
t[0] *= value
|
||||||
|
t[1] *= value
|
||||||
|
t[3] *= value
|
||||||
|
t[4] *= value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
|
||||||
|
'worklet'
|
||||||
|
// In-place matrix multiplication.
|
||||||
|
const a00 = ta[0],
|
||||||
|
a01 = ta[1],
|
||||||
|
a02 = ta[2]
|
||||||
|
const a10 = ta[3],
|
||||||
|
a11 = ta[4],
|
||||||
|
a12 = ta[5]
|
||||||
|
const a20 = ta[6],
|
||||||
|
a21 = ta[7],
|
||||||
|
a22 = ta[8]
|
||||||
|
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
|
||||||
|
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
|
||||||
|
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
|
||||||
|
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
|
||||||
|
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
|
||||||
|
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
|
||||||
|
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
|
||||||
|
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
|
||||||
|
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependPan(t: TransformMatrix, translation: Position) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependPinch(
|
||||||
|
t: TransformMatrix,
|
||||||
|
scale: number,
|
||||||
|
origin: Position,
|
||||||
|
translation: Position,
|
||||||
|
) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y)
|
||||||
|
prependTranslate(t, origin.x, origin.y)
|
||||||
|
prependScale(t, scale)
|
||||||
|
prependTranslate(t, -origin.x, -origin.y)
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {Animated, NativeTouchEvent} from 'react-native'
|
|
||||||
import {Dimensions, Position} from './@types'
|
|
||||||
|
|
||||||
type CacheStorageItem = {key: string; value: any}
|
|
||||||
|
|
||||||
export const createCache = (cacheSize: number) => ({
|
|
||||||
_storage: [] as CacheStorageItem[],
|
|
||||||
get(key: string): any {
|
|
||||||
const {value} =
|
|
||||||
this._storage.find(({key: storageKey}) => storageKey === key) || {}
|
|
||||||
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
set(key: string, value: any) {
|
|
||||||
if (this._storage.length >= cacheSize) {
|
|
||||||
this._storage.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
this._storage.push({key, value})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
|
|
||||||
arr.reduce((result, item) => {
|
|
||||||
const batch = result.pop() || []
|
|
||||||
|
|
||||||
if (batch.length < batchSize) {
|
|
||||||
batch.push(item)
|
|
||||||
result.push(batch)
|
|
||||||
} else {
|
|
||||||
result.push(batch, [item])
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
export const getImageTransform = (
|
|
||||||
image: Dimensions | null,
|
|
||||||
screen: Dimensions,
|
|
||||||
) => {
|
|
||||||
if (!image?.width || !image?.height) {
|
|
||||||
return [] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const wScale = screen.width / image.width
|
|
||||||
const hScale = screen.height / image.height
|
|
||||||
const scale = Math.min(wScale, hScale)
|
|
||||||
const {x, y} = getImageTranslate(image, screen)
|
|
||||||
|
|
||||||
return [{x, y}, scale] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getImageStyles = (
|
|
||||||
image: Dimensions | null,
|
|
||||||
translate: Animated.ValueXY,
|
|
||||||
scale?: Animated.Value,
|
|
||||||
) => {
|
|
||||||
if (!image?.width || !image?.height) {
|
|
||||||
return {width: 0, height: 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
const transform = translate.getTranslateTransform()
|
|
||||||
|
|
||||||
if (scale) {
|
|
||||||
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
|
|
||||||
transform.push({scale}, {perspective: new Animated.Value(1000)})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
transform,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getImageTranslate = (
|
|
||||||
image: Dimensions,
|
|
||||||
screen: Dimensions,
|
|
||||||
): Position => {
|
|
||||||
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
|
|
||||||
const imageSize = axis === 'x' ? image.width : image.height
|
|
||||||
const screenSize = axis === 'x' ? screen.width : screen.height
|
|
||||||
|
|
||||||
return (screenSize - imageSize) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: getTranslateForAxis('x'),
|
|
||||||
y: getTranslateForAxis('y'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getImageDimensionsByTranslate = (
|
|
||||||
translate: Position,
|
|
||||||
screen: Dimensions,
|
|
||||||
): Dimensions => ({
|
|
||||||
width: screen.width - translate.x * 2,
|
|
||||||
height: screen.height - translate.y * 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getImageTranslateForScale = (
|
|
||||||
currentTranslate: Position,
|
|
||||||
targetScale: number,
|
|
||||||
screen: Dimensions,
|
|
||||||
): Position => {
|
|
||||||
const {width, height} = getImageDimensionsByTranslate(
|
|
||||||
currentTranslate,
|
|
||||||
screen,
|
|
||||||
)
|
|
||||||
|
|
||||||
const targetImageDimensions = {
|
|
||||||
width: width * targetScale,
|
|
||||||
height: height * targetScale,
|
|
||||||
}
|
|
||||||
|
|
||||||
return getImageTranslate(targetImageDimensions, screen)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDistanceBetweenTouches = (
|
|
||||||
touches: NativeTouchEvent[],
|
|
||||||
): number => {
|
|
||||||
const [a, b] = touches
|
|
||||||
|
|
||||||
if (a == null || b == null) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.sqrt(
|
|
||||||
Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,94 +15,10 @@ import * as MediaLibrary from 'expo-media-library'
|
|||||||
|
|
||||||
export const Lightbox = observer(function Lightbox() {
|
export const Lightbox = observer(function Lightbox() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const [isAltExpanded, setAltExpanded] = React.useState(false)
|
|
||||||
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
|
|
||||||
|
|
||||||
const onClose = React.useCallback(() => {
|
const onClose = React.useCallback(() => {
|
||||||
store.shell.closeLightbox()
|
store.shell.closeLightbox()
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
const saveImageToAlbumWithToasts = React.useCallback(
|
|
||||||
async (uri: string) => {
|
|
||||||
if (!permissionResponse || permissionResponse.granted === false) {
|
|
||||||
Toast.show('Permission to access camera roll is required.')
|
|
||||||
if (permissionResponse?.canAskAgain) {
|
|
||||||
requestPermission()
|
|
||||||
} else {
|
|
||||||
Toast.show(
|
|
||||||
'Permission to access camera roll was denied. Please enable it in your system settings.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveImageToMediaLibrary({uri})
|
|
||||||
Toast.show('Saved to your camera roll.')
|
|
||||||
} catch (e: any) {
|
|
||||||
Toast.show(`Failed to save image: ${String(e)}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[permissionResponse, requestPermission],
|
|
||||||
)
|
|
||||||
|
|
||||||
const LightboxFooter = React.useCallback(
|
|
||||||
({imageIndex}: {imageIndex: number}) => {
|
|
||||||
const lightbox = store.shell.activeLightbox
|
|
||||||
if (!lightbox) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let altText = ''
|
|
||||||
let uri = ''
|
|
||||||
if (lightbox.name === 'images') {
|
|
||||||
const opts = lightbox as models.ImagesLightbox
|
|
||||||
uri = opts.images[imageIndex].uri
|
|
||||||
altText = opts.images[imageIndex].alt || ''
|
|
||||||
} else if (lightbox.name === 'profile-image') {
|
|
||||||
const opts = lightbox as models.ProfileImageLightbox
|
|
||||||
uri = opts.profileView.avatar || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.footer]}>
|
|
||||||
{altText ? (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setAltExpanded(!isAltExpanded)}
|
|
||||||
accessibilityRole="button">
|
|
||||||
<Text
|
|
||||||
style={[s.gray3, styles.footerText]}
|
|
||||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
|
||||||
{altText}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.footerBtns}>
|
|
||||||
<Button
|
|
||||||
type="primary-outline"
|
|
||||||
style={styles.footerBtn}
|
|
||||||
onPress={() => saveImageToAlbumWithToasts(uri)}>
|
|
||||||
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
|
|
||||||
<Text type="xl" style={s.white}>
|
|
||||||
Save
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary-outline"
|
|
||||||
style={styles.footerBtn}
|
|
||||||
onPress={() => shareImageModal({uri})}>
|
|
||||||
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
|
|
||||||
<Text type="xl" style={s.white}>
|
|
||||||
Share
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!store.shell.activeLightbox) {
|
if (!store.shell.activeLightbox) {
|
||||||
return null
|
return null
|
||||||
} else if (store.shell.activeLightbox.name === 'profile-image') {
|
} else if (store.shell.activeLightbox.name === 'profile-image') {
|
||||||
@@ -132,6 +48,92 @@ export const Lightbox = observer(function Lightbox() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const LightboxFooter = observer(function LightboxFooter({
|
||||||
|
imageIndex,
|
||||||
|
}: {
|
||||||
|
imageIndex: number
|
||||||
|
}) {
|
||||||
|
const store = useStores()
|
||||||
|
const [isAltExpanded, setAltExpanded] = React.useState(false)
|
||||||
|
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
|
||||||
|
|
||||||
|
const saveImageToAlbumWithToasts = React.useCallback(
|
||||||
|
async (uri: string) => {
|
||||||
|
if (!permissionResponse || permissionResponse.granted === false) {
|
||||||
|
Toast.show('Permission to access camera roll is required.')
|
||||||
|
if (permissionResponse?.canAskAgain) {
|
||||||
|
requestPermission()
|
||||||
|
} else {
|
||||||
|
Toast.show(
|
||||||
|
'Permission to access camera roll was denied. Please enable it in your system settings.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveImageToMediaLibrary({uri})
|
||||||
|
Toast.show('Saved to your camera roll.')
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show(`Failed to save image: ${String(e)}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[permissionResponse, requestPermission],
|
||||||
|
)
|
||||||
|
|
||||||
|
const lightbox = store.shell.activeLightbox
|
||||||
|
if (!lightbox) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let altText = ''
|
||||||
|
let uri = ''
|
||||||
|
if (lightbox.name === 'images') {
|
||||||
|
const opts = lightbox as models.ImagesLightbox
|
||||||
|
uri = opts.images[imageIndex].uri
|
||||||
|
altText = opts.images[imageIndex].alt || ''
|
||||||
|
} else if (lightbox.name === 'profile-image') {
|
||||||
|
const opts = lightbox as models.ProfileImageLightbox
|
||||||
|
uri = opts.profileView.avatar || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.footer]}>
|
||||||
|
{altText ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setAltExpanded(!isAltExpanded)}
|
||||||
|
accessibilityRole="button">
|
||||||
|
<Text
|
||||||
|
style={[s.gray3, styles.footerText]}
|
||||||
|
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||||
|
{altText}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.footerBtns}>
|
||||||
|
<Button
|
||||||
|
type="primary-outline"
|
||||||
|
style={styles.footerBtn}
|
||||||
|
onPress={() => saveImageToAlbumWithToasts(uri)}>
|
||||||
|
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
|
||||||
|
<Text type="xl" style={s.white}>
|
||||||
|
Save
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary-outline"
|
||||||
|
style={styles.footerBtn}
|
||||||
|
onPress={() => shareImageModal({uri})}>
|
||||||
|
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
|
||||||
|
<Text type="xl" style={s.white}>
|
||||||
|
Share
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
footer: {
|
footer: {
|
||||||
paddingTop: 16,
|
paddingTop: 16,
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ function LightboxInner({
|
|||||||
{imgs[index].alt ? (
|
{imgs[index].alt ? (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Expand alt text"
|
accessibilityLabel="Expand alt text"
|
||||||
accessibilityHint="If alt text is long, toggles alt text expanded state"
|
accessibilityHint="If alt text is long, toggles alt text expanded state"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, {useState} from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
SafeAreaView,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import {ScrollView, TextInput} from './util'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import * as Toast from '../util/Toast'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s, colors} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
|
||||||
|
enum Stages {
|
||||||
|
InputEmail,
|
||||||
|
ConfirmCode,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapPoints = ['90%']
|
||||||
|
|
||||||
|
export const Component = observer(function Component({}: {}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const [stage, setStage] = useState<Stages>(Stages.InputEmail)
|
||||||
|
const [email, setEmail] = useState<string>(
|
||||||
|
store.session.currentSession?.email || '',
|
||||||
|
)
|
||||||
|
const [confirmationCode, setConfirmationCode] = useState<string>('')
|
||||||
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string>('')
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const onRequestChange = async () => {
|
||||||
|
if (email === store.session.currentSession?.email) {
|
||||||
|
setError('Enter your new email above')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
const res = await store.agent.com.atproto.server.requestEmailUpdate()
|
||||||
|
if (res.data.tokenRequired) {
|
||||||
|
setStage(Stages.ConfirmCode)
|
||||||
|
} else {
|
||||||
|
await store.agent.com.atproto.server.updateEmail({email: email.trim()})
|
||||||
|
store.session.updateLocalAccountData({
|
||||||
|
email: email.trim(),
|
||||||
|
emailConfirmed: false,
|
||||||
|
})
|
||||||
|
Toast.show('Email updated')
|
||||||
|
setStage(Stages.Done)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
let err = cleanError(String(e))
|
||||||
|
// TEMP
|
||||||
|
// while rollout is occuring, we're giving a temporary error message
|
||||||
|
// you can remove this any time after Oct2023
|
||||||
|
// -prf
|
||||||
|
if (err === 'email must be confirmed (temporary)') {
|
||||||
|
err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`
|
||||||
|
}
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
setError('')
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
await store.agent.com.atproto.server.updateEmail({
|
||||||
|
email: email.trim(),
|
||||||
|
token: confirmationCode.trim(),
|
||||||
|
})
|
||||||
|
store.session.updateLocalAccountData({
|
||||||
|
email: email.trim(),
|
||||||
|
emailConfirmed: false,
|
||||||
|
})
|
||||||
|
Toast.show('Email updated')
|
||||||
|
setStage(Stages.Done)
|
||||||
|
} catch (e) {
|
||||||
|
setError(cleanError(String(e)))
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVerify = async () => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
store.shell.openModal({name: 'verify-email'})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<ErrorMessage message={error} style={styles.error} />
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<View style={[styles.btnContainer]}>
|
||||||
|
{isProcessing ? (
|
||||||
|
<View style={styles.btn}>
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{gap: 6}}>
|
||||||
|
{stage === Stages.InputEmail && (
|
||||||
|
<Button
|
||||||
|
testID="requestChangeBtn"
|
||||||
|
type="primary"
|
||||||
|
onPress={onRequestChange}
|
||||||
|
accessibilityLabel="Request Change"
|
||||||
|
accessibilityHint=""
|
||||||
|
label="Request Change"
|
||||||
|
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||||
|
labelStyle={[s.f18]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stage === Stages.ConfirmCode && (
|
||||||
|
<Button
|
||||||
|
testID="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>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isWeb ? 0 : 40,
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
paddingTop: isWeb ? 0 : 4,
|
||||||
|
paddingBottom: isWeb ? 14 : 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 5,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
emailContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 32,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: colors.blue3,
|
||||||
|
},
|
||||||
|
btnContainer: {
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ export function Component({
|
|||||||
onPressCancel,
|
onPressCancel,
|
||||||
confirmBtnText,
|
confirmBtnText,
|
||||||
confirmBtnStyle,
|
confirmBtnStyle,
|
||||||
|
cancelBtnText,
|
||||||
}: ConfirmModal) {
|
}: ConfirmModal) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
@@ -84,7 +85,7 @@ export function Component({
|
|||||||
accessibilityLabel="Cancel"
|
accessibilityLabel="Cancel"
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<Text type="button-lg" style={pal.textLight}>
|
<Text type="button-lg" style={pal.textLight}>
|
||||||
Cancel
|
{cancelBtnText ?? 'Cancel'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
height: 100,
|
height: 120,
|
||||||
textAlignVertical: 'top',
|
textAlignVertical: 'top',
|
||||||
},
|
},
|
||||||
btn: {
|
btn: {
|
||||||
|
|||||||
@@ -26,6 +26,33 @@ 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">
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||||
|
import {ScrollView} from './util'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s, colors} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
|
||||||
|
|
||||||
|
export const snapPoints = ['50%']
|
||||||
|
|
||||||
|
export const Component = observer(function Component({
|
||||||
|
text,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
href: string
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const potentiallyMisleading = isPossiblyAUrl(text)
|
||||||
|
|
||||||
|
const onPressVisit = () => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
Linking.openURL(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[s.flex1, pal.view]}>
|
||||||
|
<ScrollView
|
||||||
|
testID="linkWarningModal"
|
||||||
|
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
{potentiallyMisleading ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="circle-exclamation"
|
||||||
|
color={pal.colors.text}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
|
Potentially Misleading Link
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
|
Leaving Bluesky
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{gap: 10}}>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
This link is taking you to the following website:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<LinkBox href={href} />
|
||||||
|
|
||||||
|
{potentiallyMisleading && (
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Make sure this is where you intend to go!
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
|
||||||
|
<Button
|
||||||
|
testID="confirmBtn"
|
||||||
|
type="primary"
|
||||||
|
onPress={onPressVisit}
|
||||||
|
accessibilityLabel="Visit Site"
|
||||||
|
accessibilityHint=""
|
||||||
|
label="Visit Site"
|
||||||
|
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>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function LinkBox({href}: {href: string}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const [scheme, hostname, rest] = React.useMemo(() => {
|
||||||
|
try {
|
||||||
|
const urlp = new URL(href)
|
||||||
|
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
|
||||||
|
return [
|
||||||
|
urlp.protocol + '//' + subdomain,
|
||||||
|
apexdomain,
|
||||||
|
urlp.pathname + urlp.search + urlp.hash,
|
||||||
|
]
|
||||||
|
} catch {
|
||||||
|
return ['', href, '']
|
||||||
|
}
|
||||||
|
}, [href])
|
||||||
|
return (
|
||||||
|
<View style={[pal.view, pal.border, styles.linkBox]}>
|
||||||
|
<Text type="lg" style={pal.textLight}>
|
||||||
|
{scheme}
|
||||||
|
<Text type="lg-bold" style={pal.text}>
|
||||||
|
{hostname}
|
||||||
|
</Text>
|
||||||
|
{rest}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isWeb ? 0 : 40,
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingTop: isWeb ? 0 : 4,
|
||||||
|
paddingBottom: isWeb ? 14 : 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkBox: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 32,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: colors.blue3,
|
||||||
|
},
|
||||||
|
btnContainer: {
|
||||||
|
paddingTop: 20,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -30,6 +30,10 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
|
|||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
import * as ModerationDetailsModal from './ModerationDetails'
|
import * as ModerationDetailsModal from './ModerationDetails'
|
||||||
import * as BirthDateSettingsModal from './BirthDateSettings'
|
import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||||
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
|
import * as SwitchAccountModal from './SwitchAccount'
|
||||||
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
|
||||||
const DEFAULT_SNAPPOINTS = ['90%']
|
const DEFAULT_SNAPPOINTS = ['90%']
|
||||||
|
|
||||||
@@ -136,6 +140,18 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
|||||||
} else if (activeModal?.name === 'birth-date-settings') {
|
} else if (activeModal?.name === 'birth-date-settings') {
|
||||||
snapPoints = BirthDateSettingsModal.snapPoints
|
snapPoints = BirthDateSettingsModal.snapPoints
|
||||||
element = <BirthDateSettingsModal.Component />
|
element = <BirthDateSettingsModal.Component />
|
||||||
|
} else if (activeModal?.name === 'verify-email') {
|
||||||
|
snapPoints = VerifyEmailModal.snapPoints
|
||||||
|
element = <VerifyEmailModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'change-email') {
|
||||||
|
snapPoints = ChangeEmailModal.snapPoints
|
||||||
|
element = <ChangeEmailModal.Component />
|
||||||
|
} else if (activeModal?.name === 'switch-account') {
|
||||||
|
snapPoints = SwitchAccountModal.snapPoints
|
||||||
|
element = <SwitchAccountModal.Component />
|
||||||
|
} else if (activeModal?.name === 'link-warning') {
|
||||||
|
snapPoints = LinkWarningModal.snapPoints
|
||||||
|
element = <LinkWarningModal.Component {...activeModal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
|
|||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
import * as ModerationDetailsModal from './ModerationDetails'
|
import * as ModerationDetailsModal from './ModerationDetails'
|
||||||
import * as BirthDateSettingsModal from './BirthDateSettings'
|
import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||||
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
|
||||||
export const ModalsContainer = observer(function ModalsContainer() {
|
export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
@@ -110,6 +113,12 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
element = <ModerationDetailsModal.Component {...modal} />
|
element = <ModerationDetailsModal.Component {...modal} />
|
||||||
} else if (modal.name === 'birth-date-settings') {
|
} else if (modal.name === 'birth-date-settings') {
|
||||||
element = <BirthDateSettingsModal.Component />
|
element = <BirthDateSettingsModal.Component />
|
||||||
|
} else if (modal.name === 'verify-email') {
|
||||||
|
element = <VerifyEmailModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'change-email') {
|
||||||
|
element = <ChangeEmailModal.Component />
|
||||||
|
} else if (modal.name === 'link-warning') {
|
||||||
|
element = <LinkWarningModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -147,11 +156,11 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
width: 500,
|
width: 600,
|
||||||
// @ts-ignore web only
|
// @ts-ignore web only
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
// @ts-ignore web only
|
// @ts-ignore web only
|
||||||
maxHeight: '100vh',
|
maxHeight: '90vh',
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
import {AccountDropdownBtn} from '../util/AccountDropdownBtn'
|
||||||
|
import {Link} from '../util/Link'
|
||||||
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
import {BottomSheetScrollView} from '@gorhom/bottom-sheet'
|
||||||
|
import {Haptics} from 'lib/haptics'
|
||||||
|
|
||||||
|
export const snapPoints = ['40%', '90%']
|
||||||
|
|
||||||
|
export function Component({}: {}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
|
||||||
|
const store = useStores()
|
||||||
|
const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
Haptics.default()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onPressSignout = React.useCallback(() => {
|
||||||
|
track('Settings:SignOutButtonClicked')
|
||||||
|
store.session.logout()
|
||||||
|
}, [track, store])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={[styles.container, pal.view]}
|
||||||
|
contentContainerStyle={[styles.innerContainer, pal.view]}>
|
||||||
|
<Text type="title-xl" style={[styles.title, pal.text]}>
|
||||||
|
Switch Account
|
||||||
|
</Text>
|
||||||
|
{isSwitching ? (
|
||||||
|
<View style={[pal.view, styles.linkCard]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Link href={makeProfileLink(store.me)} title="Your profile" noFeedback>
|
||||||
|
<View style={[pal.view, styles.linkCard]}>
|
||||||
|
<View style={styles.avi}>
|
||||||
|
<UserAvatar size={40} avatar={store.me.avatar} />
|
||||||
|
</View>
|
||||||
|
<View style={[s.flex1]}>
|
||||||
|
<Text type="md-bold" style={pal.text} numberOfLines={1}>
|
||||||
|
{store.me.displayName || store.me.handle}
|
||||||
|
</Text>
|
||||||
|
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||||
|
{store.me.handle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="signOutBtn"
|
||||||
|
onPress={isSwitching ? undefined : onPressSignout}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Sign out"
|
||||||
|
accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
|
||||||
|
<Text type="lg" style={pal.link}>
|
||||||
|
Sign out
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{store.session.switchableAccounts.map(account => (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID={`switchToAccountBtn-${account.handle}`}
|
||||||
|
key={account.did}
|
||||||
|
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
|
||||||
|
onPress={
|
||||||
|
isSwitching ? undefined : () => onPressSwitchAccount(account)
|
||||||
|
}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Switch to ${account.handle}`}
|
||||||
|
accessibilityHint="Switches the account you are logged in to">
|
||||||
|
<View style={styles.avi}>
|
||||||
|
<UserAvatar size={40} avatar={account.aviUrl} />
|
||||||
|
</View>
|
||||||
|
<View style={[s.flex1]}>
|
||||||
|
<Text type="md-bold" style={pal.text}>
|
||||||
|
{account.displayName || account.handle}
|
||||||
|
</Text>
|
||||||
|
<Text type="sm" style={pal.textLight}>
|
||||||
|
{account.handle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<AccountDropdownBtn handle={account.handle} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
innerContainer: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
linkCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
dimmed: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import React, {useState} from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Pressable,
|
||||||
|
SafeAreaView,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import {Svg, Circle, Path} from 'react-native-svg'
|
||||||
|
import {ScrollView, TextInput} from './util'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import * as Toast from '../util/Toast'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s, colors} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
|
||||||
|
export const snapPoints = ['90%']
|
||||||
|
|
||||||
|
enum Stages {
|
||||||
|
Reminder,
|
||||||
|
Email,
|
||||||
|
ConfirmCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Component = observer(function Component({
|
||||||
|
showReminder,
|
||||||
|
}: {
|
||||||
|
showReminder?: boolean
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const [stage, setStage] = useState<Stages>(
|
||||||
|
showReminder ? Stages.Reminder : Stages.Email,
|
||||||
|
)
|
||||||
|
const [confirmationCode, setConfirmationCode] = useState<string>('')
|
||||||
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string>('')
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const onSendEmail = async () => {
|
||||||
|
setError('')
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
await store.agent.com.atproto.server.requestEmailConfirmation()
|
||||||
|
setStage(Stages.ConfirmCode)
|
||||||
|
} catch (e) {
|
||||||
|
setError(cleanError(String(e)))
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
setError('')
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
await store.agent.com.atproto.server.confirmEmail({
|
||||||
|
email: (store.session.currentSession?.email || '').trim(),
|
||||||
|
token: confirmationCode.trim(),
|
||||||
|
})
|
||||||
|
store.session.updateLocalAccountData({emailConfirmed: true})
|
||||||
|
Toast.show('Email verified')
|
||||||
|
store.shell.closeModal()
|
||||||
|
} catch (e) {
|
||||||
|
setError(cleanError(String(e)))
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEmailIncorrect = () => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
store.shell.openModal({name: 'change-email'})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</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=""
|
||||||
|
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}
|
||||||
|
|
||||||
|
<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]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function ReminderIllustration() {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
|
return (
|
||||||
|
<View style={[pal.viewLight, {borderRadius: 8, marginBottom: 20}]}>
|
||||||
|
<Svg viewBox="0 0 112 84" fill="none" height={200}>
|
||||||
|
<Path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M26 26.4264V55C26 60.5229 30.4772 65 36 65H76C81.5228 65 86 60.5229 86 55V27.4214L63.5685 49.8528C59.6633 53.7581 53.3316 53.7581 49.4264 49.8528L26 26.4264Z"
|
||||||
|
fill={palInverted.colors.background}
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M83.666 19.5784C85.47 21.7297 84.4897 24.7895 82.5044 26.7748L60.669 48.6102C58.3259 50.9533 54.5269 50.9533 52.1838 48.6102L29.9502 26.3766C27.8241 24.2505 26.8952 20.8876 29.0597 18.8005C30.8581 17.0665 33.3045 16 36 16H76C79.0782 16 81.8316 17.3908 83.666 19.5784Z"
|
||||||
|
fill={palInverted.colors.background}
|
||||||
|
/>
|
||||||
|
<Circle cx="82" cy="61" r="13" fill="#20BC07" />
|
||||||
|
<Path d="M75 61L80 66L89 57" stroke="white" strokeWidth="2" />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isWeb ? 0 : 40,
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
paddingTop: isWeb ? 0 : 4,
|
||||||
|
paddingBottom: isWeb ? 14 : 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 5,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
emailContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
changeEmailLink: {
|
||||||
|
marginHorizontal: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 32,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: colors.blue3,
|
||||||
|
},
|
||||||
|
btnContainer: {
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
|
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
|
||||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
import {PostThreadModel} from 'state/models/content/post-thread'
|
||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {ago} from 'lib/strings/time'
|
import {niceDate} from 'lib/strings/time'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {pluralize} from 'lib/strings/helpers'
|
import {pluralize} from 'lib/strings/helpers'
|
||||||
@@ -38,6 +38,8 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {formatCount} from '../util/numeric/format'
|
import {formatCount} from '../util/numeric/format'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
import {TimeElapsed} from '../util/TimeElapsed'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
|
||||||
const MAX_AUTHORS = 5
|
const MAX_AUTHORS = 5
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||||||
}, [item])
|
}, [item])
|
||||||
|
|
||||||
const onToggleAuthorsExpanded = () => {
|
const onToggleAuthorsExpanded = () => {
|
||||||
setAuthorsExpanded(!isAuthorsExpanded)
|
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const authors: Author[] = useMemo(() => {
|
const authors: Author[] = useMemo(() => {
|
||||||
@@ -179,7 +181,6 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react-native-a11y/no-nested-touchables
|
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${item.author.handle}`}
|
testID={`feedItem-by-${item.author.handle}`}
|
||||||
style={[
|
style={[
|
||||||
@@ -211,9 +212,9 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
<Pressable
|
<ExpandListPressable
|
||||||
onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined}
|
hasMultipleAuthors={authors.length > 1}
|
||||||
accessible={false}>
|
onToggleAuthorsExpanded={onToggleAuthorsExpanded}>
|
||||||
<CondensedAuthorsList
|
<CondensedAuthorsList
|
||||||
visible={!isAuthorsExpanded}
|
visible={!isAuthorsExpanded}
|
||||||
authors={authors}
|
authors={authors}
|
||||||
@@ -239,9 +240,17 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Text style={[pal.text]}> {action}</Text>
|
<Text style={[pal.text]}> {action}</Text>
|
||||||
<Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text>
|
<TimeElapsed timestamp={item.indexedAt}>
|
||||||
|
{({timeElapsed}) => (
|
||||||
|
<Text
|
||||||
|
style={[pal.textLight, styles.pointer]}
|
||||||
|
title={niceDate(item.indexedAt)}>
|
||||||
|
{' ' + timeElapsed}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TimeElapsed>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</ExpandListPressable>
|
||||||
{item.isLike || item.isRepost || item.isQuote ? (
|
{item.isLike || item.isRepost || item.isQuote ? (
|
||||||
<AdditionalPostText additionalPost={item.additionalPost} />
|
<AdditionalPostText additionalPost={item.additionalPost} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -250,6 +259,29 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ExpandListPressable({
|
||||||
|
hasMultipleAuthors,
|
||||||
|
children,
|
||||||
|
onToggleAuthorsExpanded,
|
||||||
|
}: {
|
||||||
|
hasMultipleAuthors: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
onToggleAuthorsExpanded: () => void
|
||||||
|
}) {
|
||||||
|
if (hasMultipleAuthors) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggleAuthorsExpanded}
|
||||||
|
style={[styles.expandedAuthorsTrigger]}
|
||||||
|
accessible={false}>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function CondensedAuthorsList({
|
function CondensedAuthorsList({
|
||||||
visible,
|
visible,
|
||||||
authors,
|
authors,
|
||||||
@@ -419,6 +451,12 @@ const styles = StyleSheet.create({
|
|||||||
overflowHidden: {
|
overflowHidden: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
pointer: isWeb
|
||||||
|
? {
|
||||||
|
// @ts-ignore web only
|
||||||
|
cursor: 'pointer',
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
|
||||||
outer: {
|
outer: {
|
||||||
padding: 10,
|
padding: 10,
|
||||||
@@ -466,7 +504,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: 4,
|
paddingTop: 4,
|
||||||
paddingLeft: 36,
|
paddingLeft: 36,
|
||||||
},
|
},
|
||||||
|
expandedAuthorsTrigger: {
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
expandedAuthorsCloseBtn: {
|
expandedAuthorsCloseBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function InvitedUser({
|
|||||||
<FollowButton
|
<FollowButton
|
||||||
unfollowedType="primary"
|
unfollowedType="primary"
|
||||||
followedType="primary-light"
|
followedType="primary-light"
|
||||||
did={profile.did}
|
profile={profile}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
testID="dismissBtn"
|
testID="dismissBtn"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {ViewHeader} from '../util/ViewHeader'
|
|||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isNative, isDesktopWeb} from 'platform/detection'
|
import {isNative} from 'platform/detection'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
@@ -78,7 +78,7 @@ export const PostThread = observer(function PostThread({
|
|||||||
treeView: boolean
|
treeView: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isTablet} = useWebMediaQueries()
|
const {isTablet, isDesktop} = useWebMediaQueries()
|
||||||
const ref = useRef<FlatList>(null)
|
const ref = useRef<FlatList>(null)
|
||||||
const hasScrolledIntoView = useRef<boolean>(false)
|
const hasScrolledIntoView = useRef<boolean>(false)
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
@@ -189,7 +189,7 @@ export const PostThread = observer(function PostThread({
|
|||||||
} else if (item === REPLY_PROMPT) {
|
} else if (item === REPLY_PROMPT) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />}
|
{isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
} else if (item === DELETED) {
|
} else if (item === DELETED) {
|
||||||
@@ -261,7 +261,20 @@ export const PostThread = observer(function PostThread({
|
|||||||
}
|
}
|
||||||
return <></>
|
return <></>
|
||||||
},
|
},
|
||||||
[onRefresh, onPressReply, pal, posts, isTablet, treeView],
|
[
|
||||||
|
isTablet,
|
||||||
|
isDesktop,
|
||||||
|
onPressReply,
|
||||||
|
pal.border,
|
||||||
|
pal.viewLight,
|
||||||
|
pal.textLight,
|
||||||
|
pal.view,
|
||||||
|
pal.text,
|
||||||
|
pal.colors.border,
|
||||||
|
posts,
|
||||||
|
onRefresh,
|
||||||
|
treeView,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
// loading
|
// loading
|
||||||
@@ -354,7 +367,7 @@ export const PostThread = observer(function PostThread({
|
|||||||
data={posts}
|
data={posts}
|
||||||
initialNumToRender={posts.length}
|
initialNumToRender={posts.length}
|
||||||
maintainVisibleContentPosition={
|
maintainVisibleContentPosition={
|
||||||
isNative && view.isFromCache
|
isNative && view.isFromCache && view.isCachedPostAReply
|
||||||
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -426,5 +439,7 @@ const styles = StyleSheet.create({
|
|||||||
parentSpinner: {
|
parentSpinner: {
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
childSpinner: {},
|
childSpinner: {
|
||||||
|
paddingBottom: 200,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||||||
import {formatCount} from '../util/numeric/format'
|
import {formatCount} from '../util/numeric/format'
|
||||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {Tag} from 'view/com/Tag'
|
import {Tag} from 'view/com/Tag'
|
||||||
|
|
||||||
@@ -52,6 +51,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const [deleted, setDeleted] = React.useState(false)
|
const [deleted, setDeleted] = React.useState(false)
|
||||||
|
const styles = useStyles()
|
||||||
const record = item.postRecord
|
const record = item.postRecord
|
||||||
const hasEngagement = item.post.likeCount || item.post.repostCount
|
const hasEngagement = item.post.likeCount || item.post.repostCount
|
||||||
|
|
||||||
@@ -586,6 +586,7 @@ function PostOuterWrapper({
|
|||||||
}>) {
|
}>) {
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
const styles = useStyles()
|
||||||
if (treeView && item._depth > 1) {
|
if (treeView && item._depth > 1) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -654,95 +655,98 @@ function ExpandedPostDetails({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const useStyles = () => {
|
||||||
outer: {
|
const {isDesktop} = useWebMediaQueries()
|
||||||
borderTopWidth: 1,
|
return StyleSheet.create({
|
||||||
paddingLeft: 8,
|
outer: {
|
||||||
},
|
borderTopWidth: 1,
|
||||||
outerHighlighted: {
|
paddingLeft: 8,
|
||||||
paddingTop: 16,
|
},
|
||||||
paddingLeft: 8,
|
outerHighlighted: {
|
||||||
paddingRight: 8,
|
paddingTop: 16,
|
||||||
},
|
paddingLeft: 8,
|
||||||
noTopBorder: {
|
paddingRight: 8,
|
||||||
borderTopWidth: 0,
|
},
|
||||||
},
|
noTopBorder: {
|
||||||
layout: {
|
borderTopWidth: 0,
|
||||||
flexDirection: 'row',
|
},
|
||||||
gap: 10,
|
layout: {
|
||||||
paddingLeft: 8,
|
flexDirection: 'row',
|
||||||
},
|
gap: 10,
|
||||||
layoutAvi: {},
|
paddingLeft: 8,
|
||||||
layoutContent: {
|
},
|
||||||
flex: 1,
|
layoutAvi: {},
|
||||||
paddingRight: 10,
|
layoutContent: {
|
||||||
},
|
flex: 1,
|
||||||
meta: {
|
paddingRight: 10,
|
||||||
flexDirection: 'row',
|
},
|
||||||
paddingTop: 2,
|
meta: {
|
||||||
paddingBottom: 2,
|
flexDirection: 'row',
|
||||||
},
|
paddingTop: 2,
|
||||||
metaExpandedLine1: {
|
paddingBottom: 2,
|
||||||
paddingTop: 5,
|
},
|
||||||
paddingBottom: 0,
|
metaExpandedLine1: {
|
||||||
},
|
paddingTop: 5,
|
||||||
metaItem: {
|
paddingBottom: 0,
|
||||||
paddingRight: 5,
|
},
|
||||||
maxWidth: isDesktopWeb ? 380 : 220,
|
metaItem: {
|
||||||
},
|
paddingRight: 5,
|
||||||
alert: {
|
maxWidth: isDesktop ? 380 : 220,
|
||||||
marginBottom: 6,
|
},
|
||||||
},
|
alert: {
|
||||||
postTextContainer: {
|
marginBottom: 6,
|
||||||
flexDirection: 'row',
|
},
|
||||||
alignItems: 'center',
|
postTextContainer: {
|
||||||
flexWrap: 'wrap',
|
flexDirection: 'row',
|
||||||
paddingBottom: 4,
|
alignItems: 'center',
|
||||||
paddingRight: 10,
|
flexWrap: 'wrap',
|
||||||
},
|
paddingBottom: 4,
|
||||||
postTextLargeContainer: {
|
paddingRight: 10,
|
||||||
paddingHorizontal: 0,
|
},
|
||||||
paddingBottom: 10,
|
postTextLargeContainer: {
|
||||||
},
|
paddingHorizontal: 0,
|
||||||
translateLink: {
|
paddingBottom: 10,
|
||||||
marginBottom: 6,
|
},
|
||||||
},
|
translateLink: {
|
||||||
contentHider: {
|
marginBottom: 6,
|
||||||
marginBottom: 6,
|
},
|
||||||
},
|
contentHider: {
|
||||||
contentHiderChild: {
|
marginBottom: 6,
|
||||||
marginTop: 6,
|
},
|
||||||
},
|
contentHiderChild: {
|
||||||
expandedInfo: {
|
marginTop: 6,
|
||||||
flexDirection: 'row',
|
},
|
||||||
padding: 10,
|
expandedInfo: {
|
||||||
borderTopWidth: 1,
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
padding: 10,
|
||||||
marginTop: 5,
|
borderTopWidth: 1,
|
||||||
marginBottom: 15,
|
borderBottomWidth: 1,
|
||||||
},
|
marginTop: 5,
|
||||||
expandedInfoItem: {
|
marginBottom: 15,
|
||||||
marginRight: 10,
|
},
|
||||||
},
|
expandedInfoItem: {
|
||||||
loadMore: {
|
marginRight: 10,
|
||||||
flexDirection: 'row',
|
},
|
||||||
alignItems: 'center',
|
loadMore: {
|
||||||
justifyContent: 'flex-start',
|
flexDirection: 'row',
|
||||||
gap: 4,
|
alignItems: 'center',
|
||||||
paddingHorizontal: 20,
|
justifyContent: 'flex-start',
|
||||||
},
|
gap: 4,
|
||||||
replyLine: {
|
paddingHorizontal: 20,
|
||||||
width: 2,
|
},
|
||||||
marginLeft: 'auto',
|
replyLine: {
|
||||||
marginRight: 'auto',
|
width: 2,
|
||||||
},
|
marginLeft: 'auto',
|
||||||
cursor: {
|
marginRight: 'auto',
|
||||||
// @ts-ignore web only
|
},
|
||||||
cursor: 'pointer',
|
cursor: {
|
||||||
},
|
// @ts-ignore web only
|
||||||
tag: {
|
cursor: 'pointer',
|
||||||
paddingVertical: 4,
|
},
|
||||||
paddingHorizontal: 8,
|
tag: {
|
||||||
borderRadius: 4,
|
paddingVertical: 4,
|
||||||
},
|
paddingHorizontal: 8,
|
||||||
})
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const Feed = observer(function Feed({
|
|||||||
onScroll,
|
onScroll,
|
||||||
scrollEventThrottle,
|
scrollEventThrottle,
|
||||||
renderEmptyState,
|
renderEmptyState,
|
||||||
|
renderEndOfFeed,
|
||||||
testID,
|
testID,
|
||||||
headerOffset = 0,
|
headerOffset = 0,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
@@ -45,6 +46,7 @@ export const Feed = observer(function Feed({
|
|||||||
onScroll?: OnScrollCb
|
onScroll?: OnScrollCb
|
||||||
scrollEventThrottle?: number
|
scrollEventThrottle?: number
|
||||||
renderEmptyState?: () => JSX.Element
|
renderEmptyState?: () => JSX.Element
|
||||||
|
renderEndOfFeed?: () => JSX.Element
|
||||||
testID?: string
|
testID?: string
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
ListHeaderComponent?: () => JSX.Element
|
ListHeaderComponent?: () => JSX.Element
|
||||||
@@ -142,14 +144,16 @@ export const Feed = observer(function Feed({
|
|||||||
|
|
||||||
const FeedFooter = React.useCallback(
|
const FeedFooter = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
feed.isLoading ? (
|
feed.isLoadingMore ? (
|
||||||
<View style={styles.feedFooter}>
|
<View style={styles.feedFooter}>
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
</View>
|
</View>
|
||||||
|
) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
|
||||||
|
renderEndOfFeed()
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
),
|
),
|
||||||
[feed],
|
[feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,60 +28,73 @@ export function FollowingEmptyState() {
|
|||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
|
||||||
const onPressDiscoverFeeds = React.useCallback(() => {
|
const onPressDiscoverFeeds = React.useCallback(() => {
|
||||||
navigation.navigate('Feeds')
|
if (isWeb) {
|
||||||
|
navigation.navigate('Feeds')
|
||||||
|
} else {
|
||||||
|
navigation.navigate('FeedsTab')
|
||||||
|
navigation.popToTop()
|
||||||
|
}
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.container}>
|
||||||
<View style={styles.emptyIconContainer}>
|
<View style={styles.inner}>
|
||||||
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
|
<View style={styles.iconContainer}>
|
||||||
</View>
|
<MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
|
||||||
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
</View>
|
||||||
Your following feed is empty! Find some accounts to follow to fix this.
|
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||||
</Text>
|
Your following feed is empty! Follow more users to see what's
|
||||||
<Button
|
happening.
|
||||||
type="inverted"
|
|
||||||
style={styles.emptyBtn}
|
|
||||||
onPress={onPressFindAccounts}>
|
|
||||||
<Text type="lg-medium" style={palInverted.text}>
|
|
||||||
Find accounts to follow
|
|
||||||
</Text>
|
</Text>
|
||||||
<FontAwesomeIcon
|
<Button
|
||||||
icon="angle-right"
|
type="inverted"
|
||||||
style={palInverted.text as FontAwesomeIconStyle}
|
style={styles.emptyBtn}
|
||||||
size={14}
|
onPress={onPressFindAccounts}>
|
||||||
/>
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
</Button>
|
Find accounts to follow
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-right"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
|
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
|
||||||
You can also discover new Custom Feeds to follow.
|
You can also discover new Custom Feeds to follow.
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
type="inverted"
|
|
||||||
style={[styles.emptyBtn, s.mt10]}
|
|
||||||
onPress={onPressDiscoverFeeds}>
|
|
||||||
<Text type="lg-medium" style={palInverted.text}>
|
|
||||||
Discover new custom feeds
|
|
||||||
</Text>
|
</Text>
|
||||||
<FontAwesomeIcon
|
<Button
|
||||||
icon="angle-right"
|
type="inverted"
|
||||||
style={palInverted.text as FontAwesomeIconStyle}
|
style={[styles.emptyBtn, s.mt10]}
|
||||||
size={14}
|
onPress={onPressDiscoverFeeds}>
|
||||||
/>
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
</Button>
|
Discover new custom feeds
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-right"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
emptyContainer: {
|
container: {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
paddingVertical: 40,
|
paddingVertical: 40,
|
||||||
paddingHorizontal: 30,
|
paddingHorizontal: 30,
|
||||||
},
|
},
|
||||||
emptyIconContainer: {
|
inner: {
|
||||||
|
maxWidth: 460,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
emptyIcon: {
|
icon: {
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
},
|
},
|
||||||
@@ -94,13 +107,4 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
},
|
},
|
||||||
|
|
||||||
feedsTip: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 22,
|
|
||||||
},
|
|
||||||
feedsTipArrow: {
|
|
||||||
marginLeft: 32,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
export function FollowingEndOfFeed() {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const onPressFindAccounts = React.useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
navigation.navigate('Search', {})
|
||||||
|
} else {
|
||||||
|
navigation.navigate('SearchTab')
|
||||||
|
navigation.popToTop()
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
const onPressDiscoverFeeds = React.useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
navigation.navigate('Feeds')
|
||||||
|
} else {
|
||||||
|
navigation.navigate('FeedsTab')
|
||||||
|
navigation.popToTop()
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, pal.border]}>
|
||||||
|
<View style={styles.inner}>
|
||||||
|
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||||
|
You've reached the end of your feed! Find some more accounts to
|
||||||
|
follow.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
type="inverted"
|
||||||
|
style={styles.emptyBtn}
|
||||||
|
onPress={onPressFindAccounts}>
|
||||||
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
|
Find accounts to follow
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-right"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
|
||||||
|
You can also discover new Custom Feeds to follow.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
type="inverted"
|
||||||
|
style={[styles.emptyBtn, s.mt10]}
|
||||||
|
onPress={onPressDiscoverFeeds}>
|
||||||
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
|
Discover new custom feeds
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-right"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: 80,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
maxWidth: 460,
|
||||||
|
},
|
||||||
|
emptyBtn: {
|
||||||
|
marginVertical: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, TextStyle, View} from 'react-native'
|
import {StyleProp, TextStyle, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
import {Button, ButtonType} from '../util/forms/Button'
|
import {Button, ButtonType} from '../util/forms/Button'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
import {useFollowDid} from 'lib/hooks/useFollowDid'
|
import {useFollowProfile} from 'lib/hooks/useFollowProfile'
|
||||||
|
|
||||||
export const FollowButton = observer(function FollowButtonImpl({
|
export const FollowButton = observer(function FollowButtonImpl({
|
||||||
unfollowedType = 'inverted',
|
unfollowedType = 'inverted',
|
||||||
followedType = 'default',
|
followedType = 'default',
|
||||||
did,
|
profile,
|
||||||
onToggleFollow,
|
onToggleFollow,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
}: {
|
}: {
|
||||||
unfollowedType?: ButtonType
|
unfollowedType?: ButtonType
|
||||||
followedType?: ButtonType
|
followedType?: ButtonType
|
||||||
did: string
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
onToggleFollow?: (v: boolean) => void
|
onToggleFollow?: (v: boolean) => void
|
||||||
labelStyle?: StyleProp<TextStyle>
|
labelStyle?: StyleProp<TextStyle>
|
||||||
}) {
|
}) {
|
||||||
const {state, following, toggle} = useFollowDid({did})
|
const {state, following, toggle} = useFollowProfile(profile)
|
||||||
|
|
||||||
const onPress = React.useCallback(async () => {
|
const onPress = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export const ProfileCardWithFollowBtn = observer(
|
|||||||
noBorder={noBorder}
|
noBorder={noBorder}
|
||||||
followers={followers}
|
followers={followers}
|
||||||
renderButton={
|
renderButton={
|
||||||
isMe ? undefined : () => <FollowButton did={profile.did} />
|
isMe ? undefined : () => <FollowButton profile={profile} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -392,8 +392,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
|
|||||||
{
|
{
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
backgroundColor: showSuggestedFollows
|
backgroundColor: showSuggestedFollows
|
||||||
? colors.blue3
|
? pal.colors.text
|
||||||
: pal.viewLight.backgroundColor,
|
: pal.colors.backgroundLight,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {useStores} from 'state/index'
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {useFollowDid} from 'lib/hooks/useFollowDid'
|
import {useFollowProfile} from 'lib/hooks/useFollowProfile'
|
||||||
import {Button} from 'view/com/util/forms/Button'
|
import {Button} from 'view/com/util/forms/Button'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
@@ -83,7 +83,7 @@ export function ProfileHeaderSuggestedFollows({
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
store.me.follows.hydrateProfiles(suggestions)
|
store.me.follows.hydrateMany(suggestions)
|
||||||
|
|
||||||
return suggestions
|
return suggestions
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -218,7 +218,7 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
|
|||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {following, toggle} = useFollowDid({did: profile.did})
|
const {following, toggle} = useFollowProfile(profile)
|
||||||
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
|
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
|
||||||
|
|
||||||
const onPress = React.useCallback(async () => {
|
const onPress = React.useCallback(async () => {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function HeaderWithInput({
|
|||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onChangeText={onChangeQuery}
|
onChangeText={onChangeQuery}
|
||||||
onSubmitEditing={onSubmitQuery}
|
onSubmitEditing={onSubmitQuery}
|
||||||
autoFocus={isMobile}
|
autoFocus={false}
|
||||||
accessibilityRole="search"
|
accessibilityRole="search"
|
||||||
accessibilityLabel="Search"
|
accessibilityLabel="Search"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, {forwardRef, ForwardedRef} from 'react'
|
|||||||
import {RefreshControl, StyleSheet, View} from 'react-native'
|
import {RefreshControl, StyleSheet, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
import {CenteredView, FlatList} from '../util/Views'
|
import {FlatList} from '../util/Views'
|
||||||
import {FoafsModel} from 'state/models/discovery/foafs'
|
import {FoafsModel} from 'state/models/discovery/foafs'
|
||||||
import {
|
import {
|
||||||
SuggestedActorsModel,
|
SuggestedActorsModel,
|
||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
} from 'state/models/discovery/suggested-actors'
|
} from 'state/models/discovery/suggested-actors'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
|
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
|
||||||
interface Heading {
|
interface Heading {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
@@ -36,7 +37,16 @@ interface ProfileView {
|
|||||||
type: 'profile-view'
|
type: 'profile-view'
|
||||||
view: AppBskyActorDefs.ProfileViewBasic
|
view: AppBskyActorDefs.ProfileViewBasic
|
||||||
}
|
}
|
||||||
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView
|
interface LoadingPlaceholder {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'loading-placeholder'
|
||||||
|
}
|
||||||
|
type Item =
|
||||||
|
| Heading
|
||||||
|
| RefWrapper
|
||||||
|
| SuggestWrapper
|
||||||
|
| ProfileView
|
||||||
|
| LoadingPlaceholder
|
||||||
|
|
||||||
// FIXME(dan): Figure out why the false positives
|
// FIXME(dan): Figure out why the false positives
|
||||||
/* eslint-disable react/prop-types */
|
/* eslint-disable react/prop-types */
|
||||||
@@ -57,23 +67,6 @@ export const Suggestions = observer(
|
|||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
let items: Item[] = []
|
let items: Item[] = []
|
||||||
|
|
||||||
if (foafs.popular.length > 0) {
|
|
||||||
items = items
|
|
||||||
.concat([
|
|
||||||
{
|
|
||||||
_reactKey: '__popular_heading__',
|
|
||||||
type: 'heading',
|
|
||||||
title: 'In Your Network',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.concat(
|
|
||||||
foafs.popular.map(ref => ({
|
|
||||||
_reactKey: `popular-${ref.did}`,
|
|
||||||
type: 'ref',
|
|
||||||
ref,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (suggestedActors.hasContent) {
|
if (suggestedActors.hasContent) {
|
||||||
items = items
|
items = items
|
||||||
.concat([
|
.concat([
|
||||||
@@ -90,34 +83,73 @@ export const Suggestions = observer(
|
|||||||
suggested,
|
suggested,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
} else if (suggestedActors.isLoading) {
|
||||||
|
items = items.concat([
|
||||||
|
{
|
||||||
|
_reactKey: '__suggested_heading__',
|
||||||
|
type: 'heading',
|
||||||
|
title: 'Suggested Follows',
|
||||||
|
},
|
||||||
|
{_reactKey: '__suggested_loading__', type: 'loading-placeholder'},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
for (const source of foafs.sources) {
|
if (foafs.isLoading) {
|
||||||
const item = foafs.foafs.get(source)
|
items = items.concat([
|
||||||
if (!item || item.follows.length === 0) {
|
{
|
||||||
continue
|
_reactKey: '__popular_heading__',
|
||||||
|
type: 'heading',
|
||||||
|
title: 'In Your Network',
|
||||||
|
},
|
||||||
|
{_reactKey: '__foafs_loading__', type: 'loading-placeholder'},
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
if (foafs.popular.length > 0) {
|
||||||
|
items = items
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
_reactKey: '__popular_heading__',
|
||||||
|
type: 'heading',
|
||||||
|
title: 'In Your Network',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.concat(
|
||||||
|
foafs.popular.map(ref => ({
|
||||||
|
_reactKey: `popular-${ref.did}`,
|
||||||
|
type: 'ref',
|
||||||
|
ref,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const source of foafs.sources) {
|
||||||
|
const item = foafs.foafs.get(source)
|
||||||
|
if (!item || item.follows.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = items
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
_reactKey: `__${item.did}_heading__`,
|
||||||
|
type: 'heading',
|
||||||
|
title: `Followed by ${sanitizeDisplayName(
|
||||||
|
item.displayName || sanitizeHandle(item.handle),
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.concat(
|
||||||
|
item.follows.slice(0, 10).map(view => ({
|
||||||
|
_reactKey: `${item.did}-${view.did}`,
|
||||||
|
type: 'profile-view',
|
||||||
|
view,
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
items = items
|
|
||||||
.concat([
|
|
||||||
{
|
|
||||||
_reactKey: `__${item.did}_heading__`,
|
|
||||||
type: 'heading',
|
|
||||||
title: `Followed by ${sanitizeDisplayName(
|
|
||||||
item.displayName || sanitizeHandle(item.handle),
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.concat(
|
|
||||||
item.follows.slice(0, 10).map(view => ({
|
|
||||||
_reactKey: `${item.did}-${view.did}`,
|
|
||||||
type: 'profile-view',
|
|
||||||
view,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}, [
|
}, [
|
||||||
|
foafs.isLoading,
|
||||||
foafs.popular,
|
foafs.popular,
|
||||||
|
suggestedActors.isLoading,
|
||||||
suggestedActors.hasContent,
|
suggestedActors.hasContent,
|
||||||
suggestedActors.suggestions,
|
suggestedActors.suggestions,
|
||||||
foafs.sources,
|
foafs.sources,
|
||||||
@@ -183,18 +215,21 @@ export const Suggestions = observer(
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (item.type === 'loading-placeholder') {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ProfileCardLoadingPlaceholder />
|
||||||
|
<ProfileCardLoadingPlaceholder />
|
||||||
|
<ProfileCardLoadingPlaceholder />
|
||||||
|
<ProfileCardLoadingPlaceholder />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
[pal],
|
[pal],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (foafs.isLoading || suggestedActors.isLoading) {
|
|
||||||
return (
|
|
||||||
<CenteredView>
|
|
||||||
<ProfileCardFeedLoadingPlaceholder />
|
|
||||||
</CenteredView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
@@ -210,6 +245,7 @@ export const Suggestions = observer(
|
|||||||
}
|
}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
contentContainerStyle={s.contentContainer}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
|
||||||
|
import * as Toast from '../../com/util/Toast'
|
||||||
|
|
||||||
|
export function AccountDropdownBtn({handle}: {handle: string}) {
|
||||||
|
const store = useStores()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Remove account',
|
||||||
|
onPress: () => {
|
||||||
|
store.session.removeAccount(handle)
|
||||||
|
Toast.show('Account removed from quick access')
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'trash',
|
||||||
|
},
|
||||||
|
android: 'ic_delete',
|
||||||
|
web: 'trash',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<Pressable accessibilityRole="button" style={s.pl10}>
|
||||||
|
<NativeDropdown
|
||||||
|
testID="accountSettingsDropdownBtn"
|
||||||
|
items={items}
|
||||||
|
accessibilityLabel="Account options"
|
||||||
|
accessibilityHint="">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="ellipsis-h"
|
||||||
|
style={pal.textLight as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</NativeDropdown>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function EmptyState({
|
|||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={[styles.container, style]}>
|
<View testID={testID} style={[styles.container, pal.border, style]}>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
{icon === 'user-group' ? (
|
{icon === 'user-group' ? (
|
||||||
<UserGroupIcon size="64" style={styles.icon} />
|
<UserGroupIcon size="64" style={styles.icon} />
|
||||||
@@ -50,6 +50,7 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
paddingHorizontal: 36,
|
paddingHorizontal: 36,
|
||||||
|
borderTopWidth: 1,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
public render() {
|
public render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<CenteredView>
|
<CenteredView style={{height: '100%', flex: 1}}>
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title="Oh no!"
|
title="Oh no!"
|
||||||
message="There was an unexpected issue in the application. Please let us know if this happened to you!"
|
message="There was an unexpected issue in the application. Please let us know if this happened to you!"
|
||||||
|
|||||||
+69
-56
@@ -4,13 +4,13 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {Text} from './text/Text'
|
import {Text} from './text/Text'
|
||||||
import {TextLink} from './Link'
|
import {TextLink} from './Link'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
|
||||||
import {
|
import {
|
||||||
H1 as ExpoH1,
|
H1 as ExpoH1,
|
||||||
H2 as ExpoH2,
|
H2 as ExpoH2,
|
||||||
H3 as ExpoH3,
|
H3 as ExpoH3,
|
||||||
H4 as ExpoH4,
|
H4 as ExpoH4,
|
||||||
} from '@expo/html-elements'
|
} from '@expo/html-elements'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These utilities are used to define long documents in an html-like
|
* These utilities are used to define long documents in an html-like
|
||||||
@@ -27,30 +27,35 @@ interface IsChildProps {
|
|||||||
// | React.ReactNode
|
// | React.ReactNode
|
||||||
|
|
||||||
export function H1({children}: React.PropsWithChildren<{}>) {
|
export function H1({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const typography = useTheme().typography['title-xl']
|
const typography = useTheme().typography['title-xl']
|
||||||
return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
|
return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function H2({children}: React.PropsWithChildren<{}>) {
|
export function H2({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const typography = useTheme().typography['title-lg']
|
const typography = useTheme().typography['title-lg']
|
||||||
return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
|
return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function H3({children}: React.PropsWithChildren<{}>) {
|
export function H3({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const typography = useTheme().typography.title
|
const typography = useTheme().typography.title
|
||||||
return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
|
return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function H4({children}: React.PropsWithChildren<{}>) {
|
export function H4({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const typography = useTheme().typography['title-sm']
|
const typography = useTheme().typography['title-sm']
|
||||||
return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
|
return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function P({children}: React.PropsWithChildren<{}>) {
|
export function P({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<Text type="md" style={[pal.text, styles.p]}>
|
<Text type="md" style={[pal.text, styles.p]}>
|
||||||
@@ -60,6 +65,7 @@ export function P({children}: React.PropsWithChildren<{}>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
|
export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
|
||||||
|
const styles = useStyles()
|
||||||
return (
|
return (
|
||||||
<View style={[styles.ul, isChild && styles.ulChild]}>
|
<View style={[styles.ul, isChild && styles.ulChild]}>
|
||||||
{markChildProps(children)}
|
{markChildProps(children)}
|
||||||
@@ -68,6 +74,7 @@ export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
|
export function OL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
|
||||||
|
const styles = useStyles()
|
||||||
return (
|
return (
|
||||||
<View style={[styles.ol, isChild && styles.olChild]}>
|
<View style={[styles.ol, isChild && styles.olChild]}>
|
||||||
{markChildProps(children)}
|
{markChildProps(children)}
|
||||||
@@ -79,6 +86,7 @@ export function LI({
|
|||||||
children,
|
children,
|
||||||
value,
|
value,
|
||||||
}: React.PropsWithChildren<{value?: string}>) {
|
}: React.PropsWithChildren<{value?: string}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<View style={styles.li}>
|
<View style={styles.li}>
|
||||||
@@ -91,6 +99,7 @@ export function LI({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function A({children, href}: React.PropsWithChildren<{href: string}>) {
|
export function A({children, href}: React.PropsWithChildren<{href: string}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<TextLink
|
<TextLink
|
||||||
@@ -112,6 +121,7 @@ export function STRONG({children}: React.PropsWithChildren<{}>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EM({children}: React.PropsWithChildren<{}>) {
|
export function EM({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const styles = useStyles()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<Text type="md" style={[pal.text, styles.em]}>
|
<Text type="md" style={[pal.text, styles.em]}>
|
||||||
@@ -132,58 +142,61 @@ function markChildProps(children: React.ReactNode) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const useStyles = () => {
|
||||||
h1: {
|
const {isDesktop} = useWebMediaQueries()
|
||||||
marginTop: 20,
|
return StyleSheet.create({
|
||||||
marginBottom: 10,
|
h1: {
|
||||||
letterSpacing: 0.8,
|
marginTop: 20,
|
||||||
},
|
marginBottom: 10,
|
||||||
h2: {
|
letterSpacing: 0.8,
|
||||||
marginTop: 20,
|
},
|
||||||
marginBottom: 10,
|
h2: {
|
||||||
letterSpacing: 0.8,
|
marginTop: 20,
|
||||||
},
|
marginBottom: 10,
|
||||||
h3: {
|
letterSpacing: 0.8,
|
||||||
marginTop: 0,
|
},
|
||||||
marginBottom: 10,
|
h3: {
|
||||||
},
|
marginTop: 0,
|
||||||
h4: {
|
marginBottom: 10,
|
||||||
marginTop: 0,
|
},
|
||||||
marginBottom: 10,
|
h4: {
|
||||||
fontWeight: 'bold',
|
marginTop: 0,
|
||||||
},
|
marginBottom: 10,
|
||||||
p: {
|
fontWeight: 'bold',
|
||||||
marginBottom: 10,
|
},
|
||||||
},
|
p: {
|
||||||
ul: {
|
marginBottom: 10,
|
||||||
marginBottom: 10,
|
},
|
||||||
paddingLeft: isDesktopWeb ? 18 : 4,
|
ul: {
|
||||||
},
|
marginBottom: 10,
|
||||||
ulChild: {
|
paddingLeft: isDesktop ? 18 : 4,
|
||||||
paddingTop: 10,
|
},
|
||||||
marginBottom: 0,
|
ulChild: {
|
||||||
},
|
paddingTop: 10,
|
||||||
ol: {
|
marginBottom: 0,
|
||||||
marginBottom: 10,
|
},
|
||||||
paddingLeft: isDesktopWeb ? 18 : 4,
|
ol: {
|
||||||
},
|
marginBottom: 10,
|
||||||
olChild: {
|
paddingLeft: isDesktop ? 18 : 4,
|
||||||
paddingTop: 10,
|
},
|
||||||
marginBottom: 0,
|
olChild: {
|
||||||
},
|
paddingTop: 10,
|
||||||
li: {
|
marginBottom: 0,
|
||||||
flexDirection: 'row',
|
},
|
||||||
paddingRight: 20,
|
li: {
|
||||||
marginBottom: 10,
|
flexDirection: 'row',
|
||||||
},
|
paddingRight: 20,
|
||||||
liBullet: {
|
marginBottom: 10,
|
||||||
paddingRight: 10,
|
},
|
||||||
},
|
liBullet: {
|
||||||
liText: {},
|
paddingRight: 10,
|
||||||
a: {
|
},
|
||||||
marginBottom: 10,
|
liText: {},
|
||||||
},
|
a: {
|
||||||
em: {
|
marginBottom: 10,
|
||||||
fontStyle: 'italic',
|
},
|
||||||
},
|
em: {
|
||||||
})
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ import {TypographyVariant} from 'lib/ThemeContext'
|
|||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {router} from '../../../routes'
|
import {router} from '../../../routes'
|
||||||
import {useStores, RootStoreModel} from 'state/index'
|
import {useStores, RootStoreModel} from 'state/index'
|
||||||
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
|
import {
|
||||||
import {isAndroid, isDesktopWeb} from 'platform/detection'
|
convertBskyAppUrlIfNeeded,
|
||||||
|
isExternalUrl,
|
||||||
|
linkRequiresWarning,
|
||||||
|
} from 'lib/strings/url-helpers'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
import {PressableWithHover} from './PressableWithHover'
|
import {PressableWithHover} from './PressableWithHover'
|
||||||
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
|
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
type Event =
|
type Event =
|
||||||
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||||
@@ -142,6 +147,7 @@ export const TextLink = observer(function TextLink({
|
|||||||
dataSet,
|
dataSet,
|
||||||
title,
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
|
warnOnMismatchingLabel,
|
||||||
...orgProps
|
...orgProps
|
||||||
}: {
|
}: {
|
||||||
testID?: string
|
testID?: string
|
||||||
@@ -153,13 +159,29 @@ export const TextLink = observer(function TextLink({
|
|||||||
lineHeight?: number
|
lineHeight?: number
|
||||||
dataSet?: any
|
dataSet?: any
|
||||||
title?: string
|
title?: string
|
||||||
|
warnOnMismatchingLabel?: boolean
|
||||||
} & TextProps) {
|
} & TextProps) {
|
||||||
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
if (warnOnMismatchingLabel && typeof text !== 'string') {
|
||||||
|
console.error('Unable to detect mismatching label')
|
||||||
|
}
|
||||||
|
|
||||||
props.onPress = React.useCallback(
|
props.onPress = React.useCallback(
|
||||||
(e?: Event) => {
|
(e?: Event) => {
|
||||||
|
const requiresWarning =
|
||||||
|
warnOnMismatchingLabel &&
|
||||||
|
linkRequiresWarning(href, typeof text === 'string' ? text : '')
|
||||||
|
if (requiresWarning) {
|
||||||
|
e?.preventDefault?.()
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'link-warning',
|
||||||
|
text: typeof text === 'string' ? text : '',
|
||||||
|
href,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (onPress) {
|
if (onPress) {
|
||||||
e?.preventDefault?.()
|
e?.preventDefault?.()
|
||||||
// @ts-ignore function signature differs by platform -prf
|
// @ts-ignore function signature differs by platform -prf
|
||||||
@@ -167,7 +189,7 @@ export const TextLink = observer(function TextLink({
|
|||||||
}
|
}
|
||||||
return onPressInner(store, navigation, sanitizeUrl(href), e)
|
return onPressInner(store, navigation, sanitizeUrl(href), e)
|
||||||
},
|
},
|
||||||
[onPress, store, navigation, href],
|
[onPress, store, navigation, href, text, warnOnMismatchingLabel],
|
||||||
)
|
)
|
||||||
const hrefAttrs = useMemo(() => {
|
const hrefAttrs = useMemo(() => {
|
||||||
const isExternal = isExternalUrl(href)
|
const isExternal = isExternalUrl(href)
|
||||||
@@ -224,7 +246,9 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
|
|||||||
lineHeight,
|
lineHeight,
|
||||||
...props
|
...props
|
||||||
}: DesktopWebTextLinkProps) {
|
}: DesktopWebTextLinkProps) {
|
||||||
if (isDesktopWeb) {
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<TextLink
|
<TextLink
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ export function UserAvatar({
|
|||||||
aspect: [1, 1],
|
aspect: [1, 1],
|
||||||
})
|
})
|
||||||
const item = items[0]
|
const item = items[0]
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const croppedImage = await openCropper(store, {
|
const croppedImage = await openCropper(store, {
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ export function UserBanner({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items = await openPicker()
|
const items = await openPicker()
|
||||||
|
if (!items[0]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
await openCropper(store, {
|
await openCropper(store, {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function Button({
|
|||||||
type = 'primary',
|
type = 'primary',
|
||||||
label,
|
label,
|
||||||
style,
|
style,
|
||||||
|
labelContainerStyle,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
onPress,
|
onPress,
|
||||||
children,
|
children,
|
||||||
@@ -55,6 +56,7 @@ export function Button({
|
|||||||
type?: ButtonType
|
type?: ButtonType
|
||||||
label?: string
|
label?: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
labelContainerStyle?: StyleProp<ViewStyle>
|
||||||
labelStyle?: StyleProp<TextStyle>
|
labelStyle?: StyleProp<TextStyle>
|
||||||
onPress?: () => void | Promise<void>
|
onPress?: () => void | Promise<void>
|
||||||
testID?: string
|
testID?: string
|
||||||
@@ -173,7 +175,7 @@ export function Button({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.labelContainer}>
|
<View style={[styles.labelContainer, labelContainerStyle]}>
|
||||||
{label && withLoading && isLoading ? (
|
{label && withLoading && isLoading ? (
|
||||||
<ActivityIndicator size={12} color={typeLabelStyle.color} />
|
<ActivityIndicator size={12} color={typeLabelStyle.color} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -182,7 +184,15 @@ export function Button({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
|
}, [
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
withLoading,
|
||||||
|
isLoading,
|
||||||
|
labelContainerStyle,
|
||||||
|
typeLabelStyle,
|
||||||
|
labelStyle,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function RichText({
|
|||||||
href={link.uri}
|
href={link.uri}
|
||||||
style={[style, lineHeightStyle, pal.link]}
|
style={[style, lineHeightStyle, pal.link]}
|
||||||
dataSet={WORD_WRAP}
|
dataSet={WORD_WRAP}
|
||||||
|
warnOnMismatchingLabel
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
} else if (tag && AppBskyRichtextFacet.validateTag(tag).success) {
|
} else if (tag && AppBskyRichtextFacet.validateTag(tag).success) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
|||||||
import {TextLink} from 'view/com/util/Link'
|
import {TextLink} from 'view/com/util/Link'
|
||||||
import {Feed} from '../com/posts/Feed'
|
import {Feed} from '../com/posts/Feed'
|
||||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||||
|
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||||
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||||
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
|
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
|
||||||
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
|
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
|
||||||
@@ -110,6 +111,10 @@ export const HomeScreen = withAuthRequired(
|
|||||||
return <FollowingEmptyState />
|
return <FollowingEmptyState />
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const renderFollowingEndOfFeed = React.useCallback(() => {
|
||||||
|
return <FollowingEndOfFeed />
|
||||||
|
}, [])
|
||||||
|
|
||||||
const renderCustomFeedEmptyState = React.useCallback(() => {
|
const renderCustomFeedEmptyState = React.useCallback(() => {
|
||||||
return <CustomFeedEmptyState />
|
return <CustomFeedEmptyState />
|
||||||
}, [])
|
}, [])
|
||||||
@@ -127,6 +132,7 @@ export const HomeScreen = withAuthRequired(
|
|||||||
isPageFocused={selectedPage === 0}
|
isPageFocused={selectedPage === 0}
|
||||||
feed={store.me.mainFeed}
|
feed={store.me.mainFeed}
|
||||||
renderEmptyState={renderFollowingEmptyState}
|
renderEmptyState={renderFollowingEmptyState}
|
||||||
|
renderEndOfFeed={renderFollowingEndOfFeed}
|
||||||
/>
|
/>
|
||||||
{customFeeds.map((f, index) => {
|
{customFeeds.map((f, index) => {
|
||||||
return (
|
return (
|
||||||
@@ -149,11 +155,13 @@ const FeedPage = observer(function FeedPageImpl({
|
|||||||
isPageFocused,
|
isPageFocused,
|
||||||
feed,
|
feed,
|
||||||
renderEmptyState,
|
renderEmptyState,
|
||||||
|
renderEndOfFeed,
|
||||||
}: {
|
}: {
|
||||||
testID?: string
|
testID?: string
|
||||||
feed: PostsFeedModel
|
feed: PostsFeedModel
|
||||||
isPageFocused: boolean
|
isPageFocused: boolean
|
||||||
renderEmptyState?: () => JSX.Element
|
renderEmptyState?: () => JSX.Element
|
||||||
|
renderEndOfFeed?: () => JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
@@ -307,6 +315,7 @@ const FeedPage = observer(function FeedPageImpl({
|
|||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
scrollEventThrottle={100}
|
scrollEventThrottle={100}
|
||||||
renderEmptyState={renderEmptyState}
|
renderEmptyState={renderEmptyState}
|
||||||
|
renderEndOfFeed={renderEndOfFeed}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
headerOffset={headerOffset}
|
headerOffset={headerOffset}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const NotificationsScreen = withAuthRequired(
|
|||||||
}
|
}
|
||||||
}, [store, screen, onPressLoadLatest]),
|
}, [store, screen, onPressLoadLatest]),
|
||||||
)
|
)
|
||||||
|
|
||||||
useTabFocusEffect(
|
useTabFocusEffect(
|
||||||
'Notifications',
|
'Notifications',
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
|
|||||||
@@ -148,18 +148,18 @@ export const SearchScreen = withAuthRequired(
|
|||||||
style={pal.view}
|
style={pal.view}
|
||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
scrollEventThrottle={100}>
|
scrollEventThrottle={100}>
|
||||||
{query && autocompleteView.searchRes.length ? (
|
{query && autocompleteView.suggestions.length ? (
|
||||||
<>
|
<>
|
||||||
{autocompleteView.searchRes.map((profile, index) => (
|
{autocompleteView.suggestions.map((suggestion, index) => (
|
||||||
<ProfileCard
|
<ProfileCard
|
||||||
key={profile.did}
|
key={suggestion.did}
|
||||||
testID={`searchAutoCompleteResult-${profile.handle}`}
|
testID={`searchAutoCompleteResult-${suggestion.handle}`}
|
||||||
profile={profile}
|
profile={suggestion}
|
||||||
noBorder={index === 0}
|
noBorder={index === 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : query && !autocompleteView.searchRes.length ? (
|
) : query && !autocompleteView.suggestions.length ? (
|
||||||
<View>
|
<View>
|
||||||
<Text style={[pal.textLight, styles.searchPrompt]}>
|
<Text style={[pal.textLight, styles.searchPrompt]}>
|
||||||
No results found for {autocompleteView.prefix}
|
No results found for {autocompleteView.prefix}
|
||||||
|
|||||||
+123
-92
@@ -3,8 +3,8 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Linking,
|
Linking,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Pressable,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
@@ -36,22 +36,21 @@ import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
|
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {AccountData} from 'state/models/session'
|
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {pluralize} from 'lib/strings/helpers'
|
import {pluralize} from 'lib/strings/helpers'
|
||||||
import {HandIcon, HashtagIcon} from 'lib/icons'
|
import {HandIcon, HashtagIcon} from 'lib/icons'
|
||||||
import {formatCount} from 'view/com/util/numeric/format'
|
import {formatCount} from 'view/com/util/numeric/format'
|
||||||
import Clipboard from '@react-native-clipboard/clipboard'
|
import Clipboard from '@react-native-clipboard/clipboard'
|
||||||
import {reset as resetNavigation} from '../../Navigation'
|
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||||
|
|
||||||
// TEMPORARY (APP-700)
|
// TEMPORARY (APP-700)
|
||||||
// remove after backend testing finishes
|
// remove after backend testing finishes
|
||||||
// -prf
|
// -prf
|
||||||
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
|
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
|
||||||
import {STATUS_PAGE_URL} from 'lib/constants'
|
import {STATUS_PAGE_URL} from 'lib/constants'
|
||||||
import {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||||
export const SettingsScreen = withAuthRequired(
|
export const SettingsScreen = withAuthRequired(
|
||||||
@@ -61,7 +60,8 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const [isSwitching, setIsSwitching] = React.useState(false)
|
const [isSwitching, setIsSwitching, onPressSwitchAccount] =
|
||||||
|
useAccountSwitcher()
|
||||||
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
|
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
|
||||||
store.agent,
|
store.agent,
|
||||||
)
|
)
|
||||||
@@ -91,25 +91,6 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
}, [screen, store]),
|
}, [screen, store]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSwitchAccount = React.useCallback(
|
|
||||||
async (acct: AccountData) => {
|
|
||||||
track('Settings:SwitchAccountButtonClicked')
|
|
||||||
setIsSwitching(true)
|
|
||||||
if (await store.session.resumeSession(acct)) {
|
|
||||||
setIsSwitching(false)
|
|
||||||
resetNavigation()
|
|
||||||
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsSwitching(false)
|
|
||||||
Toast.show('Sorry! We need you to enter your password.')
|
|
||||||
navigation.navigate('HomeTab')
|
|
||||||
navigation.dispatch(StackActions.popToTop())
|
|
||||||
store.session.clear()
|
|
||||||
},
|
|
||||||
[track, setIsSwitching, navigation, store],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPressAddAccount = React.useCallback(() => {
|
const onPressAddAccount = React.useCallback(() => {
|
||||||
track('Settings:AddAccountButtonClicked')
|
track('Settings:AddAccountButtonClicked')
|
||||||
navigation.navigate('HomeTab')
|
navigation.navigate('HomeTab')
|
||||||
@@ -219,10 +200,25 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
<View style={[styles.infoLine]}>
|
<View style={[styles.infoLine]}>
|
||||||
<Text type="lg-medium" style={pal.text}>
|
<Text type="lg-medium" style={pal.text}>
|
||||||
Email:{' '}
|
Email:{' '}
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
{store.session.currentSession?.email}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{!store.session.emailNeedsConfirmation && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="check"
|
||||||
|
size={10}
|
||||||
|
style={{color: colors.green3, marginRight: 2}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
{store.session.currentSession?.email}{' '}
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
onPress={() => store.shell.openModal({name: 'change-email'})}>
|
||||||
|
<Text type="lg" style={pal.link}>
|
||||||
|
Change
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.infoLine]}>
|
<View style={[styles.infoLine]}>
|
||||||
<Text type="lg-medium" style={pal.text}>
|
<Text type="lg-medium" style={pal.text}>
|
||||||
@@ -238,6 +234,7 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
<EmailConfirmationNotice />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={[s.flexRow, styles.heading]}>
|
<View style={[s.flexRow, styles.heading]}>
|
||||||
@@ -325,37 +322,45 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
{store.me.invitesAvailable !== null && (
|
||||||
Invite a Friend
|
<>
|
||||||
</Text>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
<TouchableOpacity
|
Invite a Friend
|
||||||
testID="inviteFriendBtn"
|
</Text>
|
||||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
<TouchableOpacity
|
||||||
onPress={isSwitching ? undefined : onPressInviteCodes}
|
testID="inviteFriendBtn"
|
||||||
accessibilityRole="button"
|
style={[
|
||||||
accessibilityLabel="Invite"
|
styles.linkCard,
|
||||||
accessibilityHint="Opens invite code list">
|
pal.view,
|
||||||
<View
|
isSwitching && styles.dimmed,
|
||||||
style={[
|
]}
|
||||||
styles.iconContainer,
|
onPress={isSwitching ? undefined : onPressInviteCodes}
|
||||||
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
accessibilityRole="button"
|
||||||
]}>
|
accessibilityLabel="Invite"
|
||||||
<FontAwesomeIcon
|
accessibilityHint="Opens invite code list">
|
||||||
icon="ticket"
|
<View
|
||||||
style={
|
style={[
|
||||||
(store.me.invitesAvailable > 0
|
styles.iconContainer,
|
||||||
? primaryText
|
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||||
: pal.text) as FontAwesomeIconStyle
|
]}>
|
||||||
}
|
<FontAwesomeIcon
|
||||||
/>
|
icon="ticket"
|
||||||
</View>
|
style={
|
||||||
<Text
|
(store.me.invitesAvailable > 0
|
||||||
type="lg"
|
? primaryText
|
||||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
: pal.text) as FontAwesomeIconStyle
|
||||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
}
|
||||||
{pluralize(store.me.invitesAvailable, 'code')} available
|
/>
|
||||||
</Text>
|
</View>
|
||||||
</TouchableOpacity>
|
<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} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
@@ -630,40 +635,66 @@ export const SettingsScreen = withAuthRequired(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function AccountDropdownBtn({handle}: {handle: string}) {
|
const EmailConfirmationNotice = observer(
|
||||||
const store = useStores()
|
function EmailConfirmationNoticeImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const items: DropdownItem[] = [
|
const palInverted = usePalette('inverted')
|
||||||
{
|
const store = useStores()
|
||||||
label: 'Remove account',
|
const {isMobile} = useWebMediaQueries()
|
||||||
onPress: () => {
|
|
||||||
store.session.removeAccount(handle)
|
if (!store.session.emailNeedsConfirmation) {
|
||||||
Toast.show('Account removed from quick access')
|
return null
|
||||||
},
|
}
|
||||||
icon: {
|
|
||||||
ios: {
|
return (
|
||||||
name: 'trash',
|
<View style={{marginBottom: 20}}>
|
||||||
},
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
android: 'ic_delete',
|
Verify email
|
||||||
web: 'trash',
|
</Text>
|
||||||
},
|
<View
|
||||||
},
|
style={[
|
||||||
]
|
{
|
||||||
return (
|
paddingVertical: isMobile ? 12 : 0,
|
||||||
<Pressable accessibilityRole="button" style={s.pl10}>
|
paddingHorizontal: 18,
|
||||||
<NativeDropdown
|
},
|
||||||
testID="accountSettingsDropdownBtn"
|
pal.view,
|
||||||
items={items}
|
]}>
|
||||||
accessibilityLabel="Account options"
|
<View style={{flexDirection: 'row', marginBottom: 8}}>
|
||||||
accessibilityHint="">
|
<Pressable
|
||||||
<FontAwesomeIcon
|
style={[
|
||||||
icon="ellipsis-h"
|
palInverted.view,
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
{
|
||||||
/>
|
flexDirection: 'row',
|
||||||
</NativeDropdown>
|
gap: 6,
|
||||||
</Pressable>
|
borderRadius: 6,
|
||||||
)
|
paddingHorizontal: 12,
|
||||||
}
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
isMobile && {flex: 1},
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Verify my email"
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={() => store.shell.openModal({name: 'verify-email'})}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="envelope"
|
||||||
|
color={palInverted.colors.text}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<Text type="button" style={palInverted.text}>
|
||||||
|
Verify My Email
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Text style={pal.textLight}>
|
||||||
|
Protect your account by verifying your email.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
dimmed: {
|
dimmed: {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
winHeight,
|
winHeight,
|
||||||
replyTo,
|
replyTo,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
|
||||||
quote,
|
quote,
|
||||||
mention,
|
mention,
|
||||||
}: {
|
}: {
|
||||||
@@ -19,7 +18,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
winHeight: number
|
winHeight: number
|
||||||
replyTo?: ComposerOpts['replyTo']
|
replyTo?: ComposerOpts['replyTo']
|
||||||
onPost?: ComposerOpts['onPost']
|
onPost?: ComposerOpts['onPost']
|
||||||
onClose: () => void
|
|
||||||
quote?: ComposerOpts['quote']
|
quote?: ComposerOpts['quote']
|
||||||
mention?: ComposerOpts['mention']
|
mention?: ComposerOpts['mention']
|
||||||
}) {
|
}) {
|
||||||
@@ -64,7 +62,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
<ComposePost
|
<ComposePost
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
onPost={onPost}
|
onPost={onPost}
|
||||||
onClose={onClose}
|
|
||||||
quote={quote}
|
quote={quote}
|
||||||
mention={mention}
|
mention={mention}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
replyTo,
|
replyTo,
|
||||||
quote,
|
quote,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
|
||||||
mention,
|
mention,
|
||||||
}: {
|
}: {
|
||||||
active: boolean
|
active: boolean
|
||||||
@@ -21,7 +20,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
replyTo?: ComposerOpts['replyTo']
|
replyTo?: ComposerOpts['replyTo']
|
||||||
quote: ComposerOpts['quote']
|
quote: ComposerOpts['quote']
|
||||||
onPost?: ComposerOpts['onPost']
|
onPost?: ComposerOpts['onPost']
|
||||||
onClose: () => void
|
|
||||||
mention?: ComposerOpts['mention']
|
mention?: ComposerOpts['mention']
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
@@ -47,7 +45,6 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
quote={quote}
|
quote={quote}
|
||||||
onPost={onPost}
|
onPost={onPost}
|
||||||
onClose={onClose}
|
|
||||||
mention={mention}
|
mention={mention}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+29
-26
@@ -273,6 +273,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
|
|||||||
label="Feeds"
|
label="Feeds"
|
||||||
accessibilityLabel="Feeds"
|
accessibilityLabel="Feeds"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
bold={isAtFeeds}
|
||||||
onPress={onPressMyFeeds}
|
onPress={onPressMyFeeds}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -425,32 +426,34 @@ const InviteCodes = observer(function InviteCodesImpl({
|
|||||||
store.shell.openModal({name: 'invite-codes'})
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
}, [store, track])
|
}, [store, track])
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
store.me.invitesAvailable !== null && (
|
||||||
testID="menuItemInviteCodes"
|
<TouchableOpacity
|
||||||
style={[styles.inviteCodes, style]}
|
testID="menuItemInviteCodes"
|
||||||
onPress={onPress}
|
style={[styles.inviteCodes, style]}
|
||||||
accessibilityRole="button"
|
onPress={onPress}
|
||||||
accessibilityLabel={
|
accessibilityRole="button"
|
||||||
invitesAvailable === 1
|
accessibilityLabel={
|
||||||
? 'Invite codes: 1 available'
|
invitesAvailable === 1
|
||||||
: `Invite codes: ${invitesAvailable} available`
|
? 'Invite codes: 1 available'
|
||||||
}
|
: `Invite codes: ${invitesAvailable} available`
|
||||||
accessibilityHint="Opens list of invite codes">
|
}
|
||||||
<FontAwesomeIcon
|
accessibilityHint="Opens list of invite codes">
|
||||||
icon="ticket"
|
<FontAwesomeIcon
|
||||||
style={[
|
icon="ticket"
|
||||||
styles.inviteCodesIcon,
|
style={[
|
||||||
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
styles.inviteCodesIcon,
|
||||||
]}
|
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||||
size={18}
|
]}
|
||||||
/>
|
size={18}
|
||||||
<Text
|
/>
|
||||||
type="lg-medium"
|
<Text
|
||||||
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
type="lg-medium"
|
||||||
{formatCount(store.me.invitesAvailable)} invite{' '}
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||||
{pluralize(store.me.invitesAvailable, 'code')}
|
{formatCount(store.me.invitesAvailable)} invite{' '}
|
||||||
</Text>
|
{pluralize(store.me.invitesAvailable, 'code')}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export const BottomBar = observer(function BottomBarImpl({
|
|||||||
const onPressProfile = React.useCallback(() => {
|
const onPressProfile = React.useCallback(() => {
|
||||||
onPressTab('MyProfile')
|
onPressTab('MyProfile')
|
||||||
}, [onPressTab])
|
}, [onPressTab])
|
||||||
|
const onLongPressProfile = React.useCallback(() => {
|
||||||
|
store.shell.openModal({name: 'switch-account'})
|
||||||
|
}, [store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -202,6 +205,7 @@ export const BottomBar = observer(function BottomBarImpl({
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
onPress={onPressProfile}
|
onPress={onPressProfile}
|
||||||
|
onLongPress={onLongPressProfile}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityLabel="Profile"
|
accessibilityLabel="Profile"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user