Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e52a1cd7 | |||
| f9944b55e2 | |||
| 9fb20517ae | |||
| 4bf1719092 | |||
| 85c26fb5a8 | |||
| b42806a6e0 | |||
| adb5ce2961 | |||
| b5f36dc7a3 | |||
| b2d4771b5f | |||
| ddf0a1cced | |||
| 40752982da | |||
| 68c809b09a | |||
| 21f7a2a247 | |||
| 97ce9a73d2 | |||
| 33ea9ccef7 | |||
| 33a87ca22e | |||
| ef1d6ee490 | |||
| 1eb623ee2f | |||
| d5c7b8ceb8 | |||
| bae429202a | |||
| 0747193977 | |||
| 4a4106ba45 | |||
| 2577cbf252 | |||
| 9f33badfff | |||
| e18e7ce34a | |||
| 9826e7c7b9 | |||
| 515c9d5529 | |||
| 871ded193c | |||
| e1dcedd87e | |||
| 1acf4969c5 | |||
| dbbd71c6d0 | |||
| 84ee64025f | |||
| 6c11c0b81d | |||
| 7ebf1ed371 | |||
| 53afafb04f | |||
| 3e5a64b454 | |||
| 46f3265936 | |||
| a1a61ef2e5 | |||
| 927cee18d9 | |||
| 7fa0708e0e | |||
| c13b6946ba | |||
| ee80cae75f | |||
| 1781b3aab7 | |||
| 3426b24f67 | |||
| 4a4be1de1a | |||
| 32fbb9dba7 | |||
| c6e28f88e5 | |||
| db0de651ba | |||
| 6848ade046 | |||
| de94acb6c8 | |||
| 78b374b43d | |||
| 01e25c912c | |||
| 8e9cf182c2 | |||
| 9042f503c2 | |||
| abfd9a8c0b | |||
| f447eaa669 | |||
| d5ccbd76d5 | |||
| eba9f8a166 | |||
| 2a1edab6d4 | |||
| 4431cfe2d2 | |||
| 997918547c | |||
| c2a1cf4e56 | |||
| d98e3a8b45 | |||
| d40d931d86 | |||
| 5dd673d1ff | |||
| 10deddd0ad | |||
| a48c213af5 | |||
| 0b44af38ea | |||
| aad8d12ede | |||
| 209d8b683c | |||
| aa085b0b14 | |||
| 4d450da194 | |||
| e878da04a1 | |||
| fc28fc639f | |||
| d68b4ca856 | |||
| 60c0db03f7 | |||
| d47ff542da | |||
| bc2c44cb98 | |||
| 098f4b526e | |||
| f452ce74f4 | |||
| 832b05b64a | |||
| acb003d7e1 | |||
| ce0f796008 | |||
| b12cfbe90b | |||
| d85bbc1a28 | |||
| c43a0d8160 | |||
| c39352f859 | |||
| 0122cdb0b6 | |||
| 9aff2597cf | |||
| 77b21dfb1c | |||
| 9cff82448f | |||
| 34f9d12987 | |||
| d2f11f8e36 | |||
| 3b78d3227f | |||
| 64153067e3 | |||
| 8366fe2c4a | |||
| b74a0e0fe2 | |||
| bd7db8af26 | |||
| 19f8389fc7 | |||
| 0168aada37 | |||
| 4ec5fabdd1 | |||
| 260b03a05c | |||
| eb7306b165 | |||
| 9278822088 | |||
| 2ba0c6a711 | |||
| c6da98c818 | |||
| b1a1bae02e | |||
| 2ca673d7f5 | |||
| a76fb78d53 | |||
| 847d4d19b5 | |||
| 6598fca79d | |||
| 588659380a | |||
| 001b29a7e3 | |||
| fd5bbb2769 | |||
| 2f157c152a | |||
| 90b18cf004 | |||
| 64410efafe | |||
| 72e3763446 | |||
| 04fda0f142 | |||
| 1f60e1a748 | |||
| 2aae37d67b | |||
| 2e5f73ff61 | |||
| 3e340b336e | |||
| 65b83e0d6b | |||
| f5a78c2c86 | |||
| d4a234d5ce | |||
| cd3b0e54fb | |||
| 16763d1d41 | |||
| cc1c9ab305 | |||
| d57c8f7bca | |||
| 1aa1b69a94 | |||
| e181bae9a6 | |||
| 83bb5c1710 | |||
| a427010629 | |||
| b030d94a64 | |||
| 6d4ad59416 | |||
| d87c232660 | |||
| 6325eff938 | |||
| 5d59ab2ac0 | |||
| e941da646d | |||
| 1185ac4053 | |||
| 42723dfaf6 | |||
| 95736a5168 | |||
| b0873cae97 | |||
| 83b3b5e1c0 | |||
| 5f476851a3 | |||
| be2f319a34 | |||
| e9f30894b1 | |||
| 150fe421d6 | |||
| 41b53ca37b | |||
| b7697f08d6 | |||
| 88b95df200 | |||
| 1b3b4c6692 | |||
| 28f5a8e44d | |||
| 4fa576cb30 |
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
simulator: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 15',
|
||||
type: 'iPhone 15 Pro',
|
||||
},
|
||||
},
|
||||
attached: {
|
||||
|
||||
+4
-1
@@ -99,4 +99,7 @@ ios/
|
||||
.env.*
|
||||
|
||||
# Firebase (Android) Google services
|
||||
google-services.json
|
||||
google-services.json
|
||||
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
@@ -1,24 +1,33 @@
|
||||
# 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)**
|
||||
- **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)**
|
||||
|
||||
Links:
|
||||
## Development Resources
|
||||
|
||||
- [Build instructions](./docs/build.md)
|
||||
- [ATProto repo](https://github.com/bluesky-social/atproto)
|
||||
- [ATProto docs](https://atproto.com)
|
||||
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).
|
||||
|
||||
## Rules & guidelines
|
||||
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
|
||||
|
||||
---
|
||||
The [Build Instructions](./docs/build.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 encompasses 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:**
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# flow.yaml
|
||||
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- launchApp
|
||||
# Login
|
||||
# - runFlow:
|
||||
# when:
|
||||
# - tapOn: "Sign In"
|
||||
# - tapOn: "Username or email address"
|
||||
# - inputText: "ansh.bsky.team"
|
||||
# - tapOn: "Password"
|
||||
# - inputText: "PASSWORd"
|
||||
# - tapOn: "Next"
|
||||
# Allow notifications if popup is visible
|
||||
# - runFlow:
|
||||
# when:
|
||||
# visible: "Notifications"
|
||||
# commands:
|
||||
# - tapOn: "Allow"
|
||||
# Scroll in main feed
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
# Swipe between feeds
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
# Go to Notifications
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn: "Notifications"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- swipe:
|
||||
direction: "DOWN" # Make header visible
|
||||
# Go to Feeds tab
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn: "Feeds"
|
||||
- scrollUntilVisible:
|
||||
element: "Discover"
|
||||
direction: UP
|
||||
- tapOn: "Discover"
|
||||
- waitForAnimationToEnd
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
# Click on post
|
||||
- tapOn:
|
||||
id: "postText"
|
||||
index: 0
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
- "scroll"
|
||||
|
||||
@@ -502,6 +502,9 @@ async function main() {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('Create account', () => {
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,16 +18,12 @@ describe('invite-codes', () => {
|
||||
it('I can fetch invite codes', async () => {
|
||||
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||
await loginAsAlice()
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await expect(element(by.id('drawer'))).toBeVisible()
|
||||
await element(by.id('menuItemInviteCodes')).tap()
|
||||
await element(by.id('e2eOpenInviteCodesModal')).tap()
|
||||
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
|
||||
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
|
||||
inviteCode = attrs.text
|
||||
await element(by.id('closeBtn')).tap()
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
})
|
||||
|
||||
it('I can create a new account with the invite code', async () => {
|
||||
@@ -51,12 +47,18 @@ describe('invite-codes', () => {
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('I get a notification for the new user', async () => {
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
await loginAsAlice()
|
||||
await element(by.id('menuItemButton-Notifications')).tap()
|
||||
await waitFor(element(by.id('homeScreen')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
await expect(element(by.id('invitedUser'))).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('Profile screen', () => {
|
||||
it('Can see feeds', async () => {
|
||||
await element(by.id('selector')).swipe('left')
|
||||
await element(by.id('selector-4')).tap()
|
||||
await expect(element(by.id('feed-alices feed'))).toBeVisible()
|
||||
await expect(element(by.id('feed-alice-favs'))).toBeVisible()
|
||||
await element(by.id('selector')).swipe('right')
|
||||
await element(by.id('selector-0')).tap()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
Native: {
|
||||
ReactNativeTracing: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
})),
|
||||
ReactNavigationInstrumentation: jest.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,134 @@
|
||||
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],
|
||||
['http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
|
||||
['http://bsky.app/', 'bluesky', false],
|
||||
['https://bsky.app/', 'bluesky', false],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/',
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
false,
|
||||
],
|
||||
[
|
||||
'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
|
||||
'https://google.com',
|
||||
true,
|
||||
],
|
||||
['http://bsky.app/', 'https://google.com', true],
|
||||
['https://bsky.app/', 'https://google.com', true],
|
||||
|
||||
// bad uri inputs, default to true
|
||||
['', '', true],
|
||||
['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',
|
||||
scheme: 'bluesky',
|
||||
owner: 'blueskysocial',
|
||||
version: '1.51.0',
|
||||
version: '1.55.0',
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
@@ -19,7 +19,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '4',
|
||||
buildNumber: '1',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -43,7 +43,7 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: 38,
|
||||
versionCode: 44,
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#ffffff',
|
||||
|
||||
+22
-1
@@ -1,7 +1,23 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
const isTestEnv = process.env.NODE_ENV === 'test'
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
presets: [
|
||||
[
|
||||
'babel-preset-expo',
|
||||
{
|
||||
lazyImports: true,
|
||||
native: {
|
||||
// We should be able to remove this after upgrading Expo
|
||||
// to a version that includes https://github.com/expo/expo/pull/24672.
|
||||
unstable_transformProfile: 'hermes-stable',
|
||||
// Disable ESM -> CJS compilation because Metro takes care of it.
|
||||
// However, we need it in Jest tests since those run without Metro.
|
||||
disableImportExportTransform: !isTestEnv,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
@@ -30,5 +46,10 @@ module.exports = function (api) {
|
||||
],
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ func serve(cctx *cli.Context) error {
|
||||
}
|
||||
|
||||
e.HideBanner = true
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
|
||||
e.IPExtractor = echo.ExtractIPFromXFFHeader()
|
||||
|
||||
// SECURITY: Do not modify without due consideration.
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
ContentTypeNosniff: "nosniff",
|
||||
@@ -106,8 +111,23 @@ func serve(cctx *cli.Context) error {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
}))
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
|
||||
middleware.RateLimiterMemoryStoreConfig{
|
||||
Rate: 10, // requests per second
|
||||
Burst: 30, // allow bursts
|
||||
ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
|
||||
},
|
||||
),
|
||||
IdentifierExtractor: func(ctx echo.Context) (string, error) {
|
||||
id := ctx.RealIP()
|
||||
return id, nil
|
||||
},
|
||||
DenyHandler: func(c echo.Context, identifier string, err error) error {
|
||||
return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact security@bsky.app if you believe this was a mistake.\n")
|
||||
},
|
||||
}))
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
@@ -159,7 +179,6 @@ func serve(cctx *cli.Context) error {
|
||||
|
||||
// generic routes
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/search/feeds", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
@@ -167,9 +186,11 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings/language", server.WebGeneric)
|
||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||
e.GET("/settings/home-feed", server.WebGeneric)
|
||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
e.GET("/support", server.WebGeneric)
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
- brew tap wix/brew
|
||||
- brew install applesimutils
|
||||
- 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
|
||||
- Start the dev servers
|
||||
- `git clone git@github.com:bluesky-social/atproto.git`
|
||||
- `cd atproto`
|
||||
- `pnpm i`
|
||||
- `pnpm build`
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
- Run the dev app
|
||||
- iOS: `yarn ios`
|
||||
@@ -27,6 +29,7 @@
|
||||
- Each test run: `yarn e2e:run`
|
||||
- Tips
|
||||
- Make sure to copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- If the android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
@@ -118,6 +121,7 @@ upload-sourcemaps \
|
||||
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
|
||||
|
||||
### 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/)
|
||||
|
||||
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,14 @@
|
||||
# Testing instructions
|
||||
|
||||
### Using Maestro E2E tests
|
||||
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
|
||||
2. You can write Maestro tests in `__e2e__/maestro` directory by creating a new `.yaml` file or by modifying an existing one.
|
||||
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
|
||||
|
||||
|
||||
### Using Flashlight for Performance Testing
|
||||
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
|
||||
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
|
||||
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
|
||||
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
|
||||
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
get_container_id() {
|
||||
local compose_file=$1
|
||||
local service=$2
|
||||
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||
echo "usage: get_container_id <compose_file> <service>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -f $compose_file ps --format json --status running \
|
||||
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||
}
|
||||
|
||||
# Exports all environment variables
|
||||
export_env() {
|
||||
export_pg_env
|
||||
export_redis_env
|
||||
}
|
||||
|
||||
# Exports postgres environment variables
|
||||
export_pg_env() {
|
||||
# Based on creds in compose.yaml
|
||||
export PGPORT=5433
|
||||
export PGHOST=localhost
|
||||
export PGUSER=pg
|
||||
export PGPASSWORD=password
|
||||
export PGDATABASE=postgres
|
||||
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||
}
|
||||
|
||||
# Exports redis environment variables
|
||||
export_redis_env() {
|
||||
export REDIS_HOST="127.0.0.1:6380"
|
||||
}
|
||||
|
||||
# Main entry point
|
||||
main() {
|
||||
# Expect a SERVICES env var to be set with the docker service names
|
||||
local services=${SERVICES}
|
||||
|
||||
dir=$(dirname $0)
|
||||
compose_file="${dir}/docker-compose.yaml"
|
||||
|
||||
# whether this particular script started the container(s)
|
||||
started_container=false
|
||||
|
||||
# trap SIGINT and performs cleanup as necessary, i.e.
|
||||
# taking down containers if this script started them
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
local services=$@
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||
fi
|
||||
exit $?
|
||||
}
|
||||
|
||||
# check if all services are running already
|
||||
not_running=false
|
||||
for service in $services; do
|
||||
container_id=$(get_container_id $compose_file $service)
|
||||
if [ -z $container_id ]; then
|
||||
not_running=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# if any are missing, recreate all services
|
||||
if $not_running; then
|
||||
docker compose -f $compose_file up --wait --force-recreate ${services}
|
||||
started_container=true
|
||||
else
|
||||
echo "all services ${services} are already running"
|
||||
fi
|
||||
|
||||
# setup environment variables and run args
|
||||
export_env
|
||||
"$@"
|
||||
# save return code for later
|
||||
code=$?
|
||||
|
||||
# performs cleanup as necessary, i.e. taking down containers
|
||||
# if this script started them
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose -f $compose_file rm -f --stop --volumes ${services}
|
||||
fi
|
||||
|
||||
exit ${code}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
# An ephermerally-stored postgres database for single-use test runs
|
||||
db_test: &db_test
|
||||
image: postgres:14.4-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=pg
|
||||
- POSTGRES_PASSWORD=password
|
||||
ports:
|
||||
- '5433:5432'
|
||||
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: 'pg_isready -U pg'
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored postgres database
|
||||
db:
|
||||
<<: *db_test
|
||||
ports:
|
||||
- '5432:5432'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- atp_db:/var/lib/postgresql/data
|
||||
# An ephermerally-stored redis cache for single-use test runs
|
||||
redis_test: &redis_test
|
||||
image: redis:7.0-alpine
|
||||
ports:
|
||||
- '6380:6379'
|
||||
# Healthcheck ensures redis is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', '[ "$$(redis-cli ping)" = "PONG" ]']
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored redis cache
|
||||
redis:
|
||||
<<: *redis_test
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
ports:
|
||||
- '6379:6379'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- atp_redis:/data
|
||||
volumes:
|
||||
atp_db:
|
||||
atp_redis:
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test" main "$@"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-redis-and-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
# ./with-test-redis-and-db.sh redis-cli -h localhost -p 6380 ping
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test redis_test" main "$@"
|
||||
@@ -74,3 +74,14 @@ jest.mock('lande', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn().mockReturnValue([['eng']]),
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
init: () => jest.fn(),
|
||||
Native: {
|
||||
ReactNativeTracing: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
})),
|
||||
ReactNavigationInstrumentation: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
+94
-60
@@ -1,7 +1,7 @@
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestNetworkNoAppView} from '@atproto/dev-env'
|
||||
import {TestNetwork} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
@@ -18,14 +18,59 @@ export interface TestPDS {
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
class StringIdGenerator {
|
||||
_nextId = [0]
|
||||
constructor(
|
||||
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
) {}
|
||||
|
||||
next() {
|
||||
const r = []
|
||||
for (const char of this._nextId) {
|
||||
r.unshift(this._chars[char])
|
||||
}
|
||||
this._increment()
|
||||
return r.join('')
|
||||
}
|
||||
|
||||
_increment() {
|
||||
for (let i = 0; i < this._nextId.length; i++) {
|
||||
const val = ++this._nextId[i]
|
||||
if (val >= this._chars.length) {
|
||||
this._nextId[i] = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
this._nextId.push(0)
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
while (true) {
|
||||
yield this.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||
): Promise<TestPDS> {
|
||||
const port = await getPort()
|
||||
const port2 = await getPort(port + 1)
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const testNet = await TestNetworkNoAppView.create({
|
||||
pds: {port, publicUrl: pdsUrl, inviteRequired},
|
||||
const id = ids.next()
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
publicUrl: pdsUrl,
|
||||
inviteRequired,
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
},
|
||||
plc: {port: port2},
|
||||
})
|
||||
|
||||
@@ -48,7 +93,7 @@ class Mocker {
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(
|
||||
public testNet: TestNetworkNoAppView,
|
||||
public testNet: TestNetwork,
|
||||
public service: string,
|
||||
public pic: Uint8Array,
|
||||
) {
|
||||
@@ -59,6 +104,10 @@ class Mocker {
|
||||
return this.testNet.pds
|
||||
}
|
||||
|
||||
get bsky() {
|
||||
return this.testNet.bsky
|
||||
}
|
||||
|
||||
get plc() {
|
||||
return this.testNet.plc
|
||||
}
|
||||
@@ -81,11 +130,7 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -260,11 +305,7 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
`admin:${this.pds.ctx.cfg.adminPassword}`,
|
||||
)}`,
|
||||
},
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -275,24 +316,21 @@ class Mocker {
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -307,43 +345,39 @@ class Mocker {
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.pds.ctx
|
||||
const ctx = this.bsky.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const {getDefaultConfig} = require('expo/metro-config')
|
||||
const cfg = getDefaultConfig(__dirname)
|
||||
|
||||
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
||||
: cfg.resolver.sourceExts
|
||||
|
||||
cfg.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true,
|
||||
nonInlinedRequires: [
|
||||
// We can remove this option and rely on the default after
|
||||
// https://github.com/facebook/metro/pull/1126 is released.
|
||||
'React',
|
||||
'react',
|
||||
'react/jsx-dev-runtime',
|
||||
'react/jsx-runtime',
|
||||
'react-native',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = cfg
|
||||
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.51.0",
|
||||
"version": "1.55.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
@@ -11,6 +11,7 @@
|
||||
"web": "expo start --web",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
"test": "jest --forceExit --testTimeout=20000 --bail",
|
||||
"test-watch": "jest --watchAll",
|
||||
@@ -18,14 +19,19 @@
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
|
||||
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build": "detox build -c ios.sim.debug",
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
"perf:test": "maestro test",
|
||||
"perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "flashlight report .perf/results.json",
|
||||
"perf:measure": "flashlight measure",
|
||||
"build:apk": "eas build -p android --profile dev-android-apk"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.6.16",
|
||||
"@atproto/api": "^0.6.21",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -35,7 +41,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@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",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@react-native-async-storage/async-storage": "1.18.2",
|
||||
@@ -53,7 +59,7 @@
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "5.5.0",
|
||||
"@sentry/react-native": "5.10.0",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tiptap/core": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||
@@ -63,6 +69,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-placeholder": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-text": "^2.0.0-beta.220",
|
||||
"@tiptap/html": "^2.1.11",
|
||||
"@tiptap/pm": "^2.0.0-beta.220",
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
@@ -70,6 +77,7 @@
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
"await-lock": "^2.2.2",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"base64-js": "^1.5.1",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"email-validator": "^2.0.4",
|
||||
@@ -94,7 +102,6 @@
|
||||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"graphemer": "^1.4.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
@@ -114,11 +121,12 @@
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"psl": "^1.9.0",
|
||||
"react": "18.2.0",
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-circular-progressbar": "^2.1.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-draggable-flatlist": "^4.0.1",
|
||||
"react-native-drawer-layout": "^3.2.0",
|
||||
@@ -146,7 +154,7 @@
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"zeego": "^1.6.2",
|
||||
@@ -174,6 +182,7 @@
|
||||
"@types/lodash.samplesize": "^4.2.7",
|
||||
"@types/lodash.set": "^4.3.7",
|
||||
"@types/lodash.shuffle": "^4.2.7",
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react-avatar-editor": "^13.0.0",
|
||||
"@types/react-responsive": "^8.0.5",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
@@ -183,7 +192,7 @@
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"babel-plugin-react-native-web": "^0.18.12",
|
||||
"detox": "^20.11.3",
|
||||
"detox": "^20.13.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-detox": "^1.0.0",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
@@ -210,7 +219,8 @@
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18"
|
||||
"@types/react": "^18",
|
||||
"**/zeed-dom": "0.10.9"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
index 7e0b4cd..3fd7406 100644
|
||||
index 7e0b4cd..177454c 100644
|
||||
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
|
||||
@@ -12,3 +12,4 @@ index 7e0b4cd..3fd7406 100644
|
||||
+ } catch (e) {}
|
||||
}
|
||||
//# sourceMappingURL=ignorerequirecyclelogs.js.map
|
||||
\ No newline at end of file
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/node_modules/babel-preset-expo/index.js b/node_modules/babel-preset-expo/index.js
|
||||
index 2099ee3..2b9e092 100644
|
||||
--- a/node_modules/babel-preset-expo/index.js
|
||||
+++ b/node_modules/babel-preset-expo/index.js
|
||||
@@ -105,7 +105,8 @@ module.exports = function (api, options = {}) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
- getObjectRestSpreadPlugin(),
|
||||
+ // - dan: This will be disabled anyway when we upgrade Expo, but let's do it now.
|
||||
+ // getObjectRestSpreadPlugin(),
|
||||
...extraPlugins,
|
||||
getAliasPlugin(),
|
||||
[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }],
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/node_modules/babel-preset-fbjs/plugins/inline-requires.js b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
index b11fc83..e18661a 100644
|
||||
--- a/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
+++ b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
|
||||
@@ -256,6 +256,7 @@ function getInlineableModule(path, state) {
|
||||
|
||||
return moduleName == null ||
|
||||
state.ignoredRequires.has(moduleName) ||
|
||||
+ moduleName.startsWith('@babel/runtime/') ||
|
||||
isRequireInScope
|
||||
? null
|
||||
: { moduleName, requireFnName: fnName };
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
index 48a1409..ef185c9 100644
|
||||
--- a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
+++ b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
|
||||
@@ -70,14 +70,19 @@ function wrapModule(
|
||||
importDefaultName,
|
||||
importAllName,
|
||||
dependencyMapName,
|
||||
- globalPrefix
|
||||
+ globalPrefix,
|
||||
+ moduleFactoryName
|
||||
) {
|
||||
const params = buildParameters(
|
||||
importDefaultName,
|
||||
importAllName,
|
||||
dependencyMapName
|
||||
);
|
||||
- const factory = functionFromProgram(fileAst.program, params);
|
||||
+ const factory = functionFromProgram(
|
||||
+ fileAst.program,
|
||||
+ params,
|
||||
+ moduleFactoryName
|
||||
+ );
|
||||
const def = t.callExpression(t.identifier(`${globalPrefix}__d`), [factory]);
|
||||
const ast = t.file(t.program([t.expressionStatement(def)]));
|
||||
const requireName = renameRequires(ast);
|
||||
@@ -107,7 +112,16 @@ function wrapJson(source, globalPrefix) {
|
||||
"});",
|
||||
].join("\n");
|
||||
}
|
||||
-function functionFromProgram(program, parameters) {
|
||||
+const JS_INVALID_IDENT_RE = /[^a-zA-Z0-9$_]/g;
|
||||
+function functionFromProgram(program, parameters, moduleFactoryName) {
|
||||
+ let identifier;
|
||||
+ if (typeof moduleFactoryName === "string" && moduleFactoryName !== "") {
|
||||
+ // Keep the name readable so it shows up in profiler traces.
|
||||
+ // Add an unlikely suffix to avoid collisions with the module code.
|
||||
+ identifier = t.identifier(
|
||||
+ `${moduleFactoryName.replace(JS_INVALID_IDENT_RE, "_")}__module_factory__`
|
||||
+ );
|
||||
+ }
|
||||
return t.functionExpression(
|
||||
undefined,
|
||||
parameters.map(makeIdentifier),
|
||||
@@ -0,0 +1,50 @@
|
||||
diff --git a/node_modules/metro-runtime/src/polyfills/require.js b/node_modules/metro-runtime/src/polyfills/require.js
|
||||
index ce67cb4..eeeae84 100644
|
||||
--- a/node_modules/metro-runtime/src/polyfills/require.js
|
||||
+++ b/node_modules/metro-runtime/src/polyfills/require.js
|
||||
@@ -22,6 +22,13 @@ global.__c = clear;
|
||||
global.__registerSegment = registerSegment;
|
||||
var modules = clear();
|
||||
|
||||
+if (__DEV__) {
|
||||
+ // Added by Dan for module init logging.
|
||||
+ global.__INIT_LOGS__ = []
|
||||
+ var initModuleCounter = 0
|
||||
+ var initModuleStack = []
|
||||
+}
|
||||
+
|
||||
// Don't use a Symbol here, it would pull in an extra polyfill with all sorts of
|
||||
// additional stuff (e.g. Array.from).
|
||||
const EMPTY = {};
|
||||
@@ -303,7 +310,30 @@ function loadModuleImplementation(moduleId, module) {
|
||||
throw module.error;
|
||||
}
|
||||
if (__DEV__) {
|
||||
- var Systrace = requireSystrace();
|
||||
+ // Added by Dan for module init logging.
|
||||
+ var Systrace = {
|
||||
+ beginEvent(label) {
|
||||
+ let fullLabel = initModuleCounter++ + ' ' + label
|
||||
+ global.__INIT_LOGS__.push(
|
||||
+ ' '.repeat(initModuleStack.length) +
|
||||
+ ' ENTER ' + fullLabel
|
||||
+ )
|
||||
+ initModuleStack.push({
|
||||
+ fullLabel,
|
||||
+ startTime: nativePerformanceNow(),
|
||||
+ })
|
||||
+ },
|
||||
+ endEvent() {
|
||||
+ const res = initModuleStack.pop()
|
||||
+ const fullLabel = res.fullLabel
|
||||
+ const startTime = res.startTime
|
||||
+ const timeElapsed = Math.round(nativePerformanceNow() - startTime)
|
||||
+ global.__INIT_LOGS__.push(
|
||||
+ ' '.repeat(initModuleStack.length) +
|
||||
+ ' LEAVE ' + fullLabel + ' [' + timeElapsed + 'ms]',
|
||||
+ )
|
||||
+ }
|
||||
+ };
|
||||
var Refresh = requireRefresh();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
|
||||
index 27d4cb3..fd71f47 100644
|
||||
--- a/node_modules/metro-transform-worker/src/index.js
|
||||
+++ b/node_modules/metro-transform-worker/src/index.js
|
||||
@@ -190,6 +190,10 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
let dependencyMapName = "";
|
||||
let dependencies;
|
||||
let wrappedAst;
|
||||
+ const minify =
|
||||
+ options.minify &&
|
||||
+ options.unstable_transformProfile !== "hermes-canary" &&
|
||||
+ options.unstable_transformProfile !== "hermes-stable";
|
||||
|
||||
// If the module to transform is a script (meaning that is not part of the
|
||||
// dependency graph and it code will just be prepended to the bundle modules),
|
||||
@@ -229,19 +233,20 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
if (config.unstable_disableModuleWrapping === true) {
|
||||
wrappedAst = ast;
|
||||
} else {
|
||||
+ let moduleFactoryName;
|
||||
+ if (options.dev && !minify) {
|
||||
+ moduleFactoryName = file.filename;
|
||||
+ }
|
||||
({ ast: wrappedAst } = JsFileWrapping.wrapModule(
|
||||
ast,
|
||||
importDefault,
|
||||
importAll,
|
||||
dependencyMapName,
|
||||
- config.globalPrefix
|
||||
+ config.globalPrefix,
|
||||
+ moduleFactoryName
|
||||
));
|
||||
}
|
||||
}
|
||||
- const minify =
|
||||
- options.minify &&
|
||||
- options.unstable_transformProfile !== "hermes-canary" &&
|
||||
- options.unstable_transformProfile !== "hermes-stable";
|
||||
const reserved = [];
|
||||
if (config.unstable_dependencyMapReservedName != null) {
|
||||
reserved.push(config.unstable_dependencyMapReservedName);
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
index ab0fc7f..fbbf19f 100644
|
||||
index ab0fc7f..1ace752 100644
|
||||
--- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
+++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
|
||||
@@ -1,6 +1,6 @@
|
||||
@@ -19,7 +19,7 @@ index ab0fc7f..fbbf19f 100644
|
||||
|
||||
@property(nonatomic, strong) UIPageViewController *reactPageViewController;
|
||||
@property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
|
||||
@@ -80,6 +80,10 @@
|
||||
@@ -80,6 +80,10 @@ - (void)didMoveToWindow {
|
||||
[self setupInitialController];
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ index ab0fc7f..fbbf19f 100644
|
||||
if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
|
||||
[self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
|
||||
}
|
||||
@@ -463,4 +467,21 @@
|
||||
@@ -463,4 +467,21 @@ - (NSString *)determineScrollDirection:(UIScrollView *)scrollView {
|
||||
- (BOOL)isLtrLayout {
|
||||
return [_layoutDirection isEqualToString:@"ltr"];
|
||||
}
|
||||
+
|
||||
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||
+ if (otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ if (!_overdrag && otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [p velocityInView:self];
|
||||
+ if (self.currentIndex == 0 && velocity.x > 0) {
|
||||
|
||||
+9
-18
@@ -1,22 +1,23 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {Linking} from 'react-native'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import * as view from './view/index'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import * as Toast from './view/com/util/Toast'
|
||||
import {handleLink} from './Navigation'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
|
||||
@@ -29,20 +30,10 @@ const App = observer(function AppImpl() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
notifications.init(store)
|
||||
SplashScreen.hideAsync()
|
||||
Linking.getInitialURL().then((url: string | null) => {
|
||||
if (url) {
|
||||
handleLink(url)
|
||||
}
|
||||
})
|
||||
Linking.addEventListener('url', ({url}) => {
|
||||
handleLink(url)
|
||||
})
|
||||
store.onSessionDropped(() => {
|
||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||
})
|
||||
|
||||
+12
-10
@@ -1,15 +1,18 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as view from './view/index'
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from './view/shell/index'
|
||||
import {ToastContainer} from './view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import * as analytics from 'lib/analytics/analytics'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
|
||||
const App = observer(function AppImpl() {
|
||||
@@ -19,7 +22,6 @@ const App = observer(function AppImpl() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
analytics.init(store)
|
||||
|
||||
+80
-43
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
NavigationContainer,
|
||||
@@ -91,42 +92,42 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="NotFound"
|
||||
component={NotFoundScreen}
|
||||
getComponent={() => NotFoundScreen}
|
||||
options={{title: title('Not Found')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Moderation"
|
||||
component={ModerationScreen}
|
||||
getComponent={() => ModerationScreen}
|
||||
options={{title: title('Moderation')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMuteLists"
|
||||
component={ModerationMuteListsScreen}
|
||||
getComponent={() => ModerationMuteListsScreen}
|
||||
options={{title: title('Mute Lists')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationMutedAccounts"
|
||||
component={ModerationMutedAccounts}
|
||||
getComponent={() => ModerationMutedAccounts}
|
||||
options={{title: title('Muted Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationBlockedAccounts"
|
||||
component={ModerationBlockedAccounts}
|
||||
getComponent={() => ModerationBlockedAccounts}
|
||||
options={{title: title('Blocked Accounts')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
getComponent={() => SettingsScreen}
|
||||
options={{title: title('Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LanguageSettings"
|
||||
component={LanguageSettingsScreen}
|
||||
getComponent={() => LanguageSettingsScreen}
|
||||
options={{title: title('Language Settings')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
getComponent={() => ProfileScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`@${route.params.name}`),
|
||||
animation: 'none',
|
||||
@@ -134,101 +135,101 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollowers"
|
||||
component={ProfileFollowersScreen}
|
||||
getComponent={() => ProfileFollowersScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People following @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFollows"
|
||||
component={ProfileFollowsScreen}
|
||||
getComponent={() => ProfileFollowsScreen}
|
||||
options={({route}) => ({
|
||||
title: title(`People followed by @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
component={ProfileListScreen}
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title('Mute List')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
component={PostThreadScreen}
|
||||
getComponent={() => PostThreadScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostLikedBy"
|
||||
component={PostLikedByScreen}
|
||||
getComponent={() => PostLikedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostRepostedBy"
|
||||
component={PostRepostedByScreen}
|
||||
getComponent={() => PostRepostedByScreen}
|
||||
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CustomFeed"
|
||||
component={CustomFeedScreen}
|
||||
getComponent={() => CustomFeedScreen}
|
||||
options={{title: title('Feed')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CustomFeedLikedBy"
|
||||
component={CustomFeedLikedByScreen}
|
||||
getComponent={() => CustomFeedLikedByScreen}
|
||||
options={{title: title('Liked by')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
component={DebugScreen}
|
||||
getComponent={() => DebugScreen}
|
||||
options={{title: title('Debug')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Log"
|
||||
component={LogScreen}
|
||||
getComponent={() => LogScreen}
|
||||
options={{title: title('Log')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Support"
|
||||
component={SupportScreen}
|
||||
getComponent={() => SupportScreen}
|
||||
options={{title: title('Support')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PrivacyPolicy"
|
||||
component={PrivacyPolicyScreen}
|
||||
getComponent={() => PrivacyPolicyScreen}
|
||||
options={{title: title('Privacy Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="TermsOfService"
|
||||
component={TermsOfServiceScreen}
|
||||
getComponent={() => TermsOfServiceScreen}
|
||||
options={{title: title('Terms of Service')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CommunityGuidelines"
|
||||
component={CommunityGuidelinesScreen}
|
||||
getComponent={() => CommunityGuidelinesScreen}
|
||||
options={{title: title('Community Guidelines')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CopyrightPolicy"
|
||||
component={CopyrightPolicyScreen}
|
||||
getComponent={() => CopyrightPolicyScreen}
|
||||
options={{title: title('Copyright Policy')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AppPasswords"
|
||||
component={AppPasswords}
|
||||
getComponent={() => AppPasswords}
|
||||
options={{title: title('App Passwords')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SavedFeeds"
|
||||
component={SavedFeeds}
|
||||
getComponent={() => SavedFeeds}
|
||||
options={{title: title('Edit My Feeds')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesHomeFeed"
|
||||
component={PreferencesHomeFeed}
|
||||
getComponent={() => PreferencesHomeFeed}
|
||||
options={{title: title('Home Feed Preferences')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesThreads"
|
||||
component={PreferencesThreads}
|
||||
getComponent={() => PreferencesThreads}
|
||||
options={{title: title('Threads Preferences')}}
|
||||
/>
|
||||
</>
|
||||
@@ -246,20 +247,24 @@ function TabsNavigator() {
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
initialRouteName="HomeTab"
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
component={NotificationsTabNavigator}
|
||||
getComponent={() => NotificationsTabNavigator}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MyProfileTab"
|
||||
getComponent={() => MyProfileTabNavigator}
|
||||
/>
|
||||
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -276,7 +281,7 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" component={HomeScreen} />
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -293,7 +298,7 @@ function SearchTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<SearchTab.Screen name="Search" component={SearchScreen} />
|
||||
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
|
||||
{commonScreens(SearchTab as typeof HomeTab)}
|
||||
</SearchTab.Navigator>
|
||||
)
|
||||
@@ -310,7 +315,7 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -329,7 +334,7 @@ function NotificationsTabNavigator() {
|
||||
}}>
|
||||
<NotificationsTab.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
getComponent={() => NotificationsScreen}
|
||||
/>
|
||||
{commonScreens(NotificationsTab as typeof HomeTab)}
|
||||
</NotificationsTab.Navigator>
|
||||
@@ -351,7 +356,7 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||
<MyProfileTab.Screen
|
||||
name="MyProfile"
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
component={ProfileScreen}
|
||||
getComponent={() => ProfileScreen}
|
||||
initialParams={{
|
||||
name: store.me.did,
|
||||
}}
|
||||
@@ -382,22 +387,22 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||
}}>
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title('Home')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
getComponent={() => SearchScreen}
|
||||
options={{title: title('Search')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
component={FeedsScreen}
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title('Feeds')}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
getComponent={() => NotificationsScreen}
|
||||
options={{title: title('Notifications')}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
|
||||
@@ -461,6 +466,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onReady={() => {
|
||||
SplashScreen.hideAsync()
|
||||
const initMs = Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logModuleInitTrace()
|
||||
|
||||
// Register the navigation container with the Sentry instrumentation (only works on native)
|
||||
if (isNative) {
|
||||
const routingInstrumentation = getRoutingInstrumentation()
|
||||
@@ -482,9 +495,21 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
params?: AllNavigatorParams[K],
|
||||
) {
|
||||
if (navigationRef.isReady()) {
|
||||
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
const handler = () => {
|
||||
resolve()
|
||||
navigationRef.removeListener('state', handler)
|
||||
}
|
||||
navigationRef.addListener('state', handler)
|
||||
|
||||
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
}),
|
||||
timeout(1e3),
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
|
||||
@@ -562,6 +587,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
})
|
||||
|
||||
function logModuleInitTrace() {
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
const shouldLog = false
|
||||
// Relies on our patch to polyfill.js in metro-runtime
|
||||
const initLogs = (global as any).__INIT_LOGS__
|
||||
if (shouldLog && Array.isArray(initLogs)) {
|
||||
console.log(initLogs.join('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
|
||||
@@ -51,10 +51,10 @@ export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
store.log.debug('Ping w/hash')
|
||||
const email_hashed = sha256(sess.email)
|
||||
segmentClient.identify(email_hashed, {email_hashed})
|
||||
} else {
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
|
||||
@@ -46,10 +46,10 @@ export function init(store: RootStoreModel) {
|
||||
store.onSessionLoaded(() => {
|
||||
const sess = store.session.currentSession
|
||||
if (sess) {
|
||||
if (sess.email) {
|
||||
if (sess.did) {
|
||||
const did_hashed = sha256(sess.did)
|
||||
segmentClient.identify(did_hashed, {did_hashed})
|
||||
store.log.debug('Ping w/hash')
|
||||
const email_hashed = sha256(sess.email)
|
||||
segmentClient.identify(email_hashed, {email_hashed})
|
||||
} else {
|
||||
store.log.debug('Ping w/o hash')
|
||||
segmentClient.identify()
|
||||
|
||||
@@ -35,11 +35,57 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
feed: this._filter(res.data.feed),
|
||||
}
|
||||
}
|
||||
return {
|
||||
feed: [],
|
||||
}
|
||||
}
|
||||
|
||||
_filter(feed: AppBskyFeedDefs.FeedViewPost[]) {
|
||||
if (this.params.filter === 'posts_no_replies') {
|
||||
return feed.filter(post => {
|
||||
const isReply = post.reply
|
||||
const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason)
|
||||
if (!isReply) return true
|
||||
if (isRepost) return true
|
||||
return isReply && isAuthorReplyChain(this.params.actor, post, feed)
|
||||
})
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorReplyChain(
|
||||
actor: string,
|
||||
post: AppBskyFeedDefs.FeedViewPost,
|
||||
posts: AppBskyFeedDefs.FeedViewPost[],
|
||||
): boolean {
|
||||
// current post is by a different user (shouldn't happen)
|
||||
if (post.post.author.handle !== actor) return false
|
||||
|
||||
const replyParent = post.reply?.parent
|
||||
|
||||
if (AppBskyFeedDefs.isPostView(replyParent)) {
|
||||
// reply parent is by a different user
|
||||
if (replyParent.author.handle !== actor) return false
|
||||
|
||||
// A top-level post that matches the parent of the current post.
|
||||
const parentPost = posts.find(p => p.post.uri === replyParent.uri)
|
||||
|
||||
/*
|
||||
* Either we haven't fetched the parent at the top level, or the only
|
||||
* record we have is on feedItem.reply.parent, which we've already checked
|
||||
* above.
|
||||
*/
|
||||
if (!parentPost) return true
|
||||
|
||||
// Walk up to parent
|
||||
return isAuthorReplyChain(actor, parentPost, posts)
|
||||
}
|
||||
|
||||
// Just default to showing it
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trim()},
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
@@ -133,10 +133,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
aspectRatio: {width, height},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+4
-10
@@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
// TODO: remove this when the test suite no longer relies on it
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
@@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS(
|
||||
} else {
|
||||
// production
|
||||
return {
|
||||
pinned: [
|
||||
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'),
|
||||
],
|
||||
pinned: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
saved: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,3 +147,4 @@ export const HITSLOP_10 = createHitslop(10)
|
||||
export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
@@ -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 {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
|
||||
export function useFollowDid({did}: {did: string}) {
|
||||
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
|
||||
const store = useStores()
|
||||
const state = store.me.follows.getFollowState(did)
|
||||
const state = store.me.follows.getFollowState(profile.did)
|
||||
|
||||
return {
|
||||
state,
|
||||
@@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) {
|
||||
toggle: React.useCallback(async () => {
|
||||
if (state === FollowState.Following) {
|
||||
try {
|
||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
await store.agent.deleteFollow(
|
||||
store.me.follows.getFollowUri(profile.did),
|
||||
)
|
||||
store.me.follows.removeFollow(profile.did)
|
||||
return {
|
||||
state: FollowState.NotFollowing,
|
||||
following: false,
|
||||
@@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) {
|
||||
}
|
||||
} else if (state === FollowState.NotFollowing) {
|
||||
try {
|
||||
const res = await store.agent.follow(did)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
const res = await store.agent.follow(profile.did)
|
||||
store.me.follows.addFollow(profile.did, {
|
||||
followRecordUri: res.uri,
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
})
|
||||
return {
|
||||
state: FollowState.Following,
|
||||
following: true,
|
||||
@@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) {
|
||||
state: FollowState.Unknown,
|
||||
following: false,
|
||||
}
|
||||
}, [store, did, state]),
|
||||
}, [store, profile, state]),
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,60 @@
|
||||
import React from 'react'
|
||||
import {autorun} from 'mobx'
|
||||
import {useStores} from 'state/index'
|
||||
import {Animated} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function useMinimalShellMode() {
|
||||
const store = useStores()
|
||||
const minimalShellInterp = useAnimatedValue(0)
|
||||
const footerMinimalShellTransform = {
|
||||
transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
|
||||
}
|
||||
const minimalShellInterp = useSharedValue(0)
|
||||
const footerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const headerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
|
||||
],
|
||||
}
|
||||
})
|
||||
const fabMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
} else {
|
||||
Animated.timing(minimalShellInterp, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
}
|
||||
return autorun(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
minimalShellInterp.value = withTiming(1, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
} else {
|
||||
minimalShellInterp.value = withTiming(0, {
|
||||
duration: 125,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [minimalShellInterp, store.shell.minimalShellMode])
|
||||
|
||||
return {footerMinimalShellTransform}
|
||||
return {
|
||||
footerMinimalShellTransform,
|
||||
headerMinimalShellTransform,
|
||||
fabMinimalShellTransform,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@ import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
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 useDeviceLimits = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return {
|
||||
dyLimitUp: isDesktop ? 30 : 10,
|
||||
dyLimitDown: isDesktop ? 150 : 10,
|
||||
}
|
||||
}
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
@@ -18,6 +24,8 @@ export function useOnMainScroll(
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
|
||||
|
||||
return [
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -25,15 +33,11 @@ export function useOnMainScroll(
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (
|
||||
!store.shell.minimalShellMode &&
|
||||
dy > DY_LIMIT_DOWN &&
|
||||
y > Y_LIMIT
|
||||
) {
|
||||
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(dy < DY_LIMIT_UP * -1 || y <= Y_LIMIT)
|
||||
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
@@ -50,7 +54,7 @@ export function useOnMainScroll(
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store, isScrolledDown],
|
||||
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {BackHandler} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
})
|
||||
// only register back handler on android, otherwise it throws an error
|
||||
if (isAndroid) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
backHandler.remove()
|
||||
}
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function bskyTitle(page: string, unreadCountLabel?: string) {
|
||||
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
|
||||
return `${unreadPrefix}${page} - Bluesky`
|
||||
return `${unreadPrefix}${page} — Bluesky`
|
||||
}
|
||||
|
||||
@@ -15,3 +15,25 @@ export function enforceLen(str: string, len: number, ellipsis = false): string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
export function countLines(str: string | undefined): number {
|
||||
if (!str) return 0
|
||||
return str.match(/\n/g)?.length ?? 0
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {PROD_SERVICE} from 'state/index'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
export function isValidDomain(str: string): boolean {
|
||||
return !!TLDs.find(tld => {
|
||||
@@ -166,3 +167,70 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
||||
}
|
||||
return videoId
|
||||
}
|
||||
|
||||
export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
let urip
|
||||
try {
|
||||
urip = new URL(uri)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
|
||||
if (urip.hostname === 'bsky.app') {
|
||||
// if this is a link to internal content,
|
||||
// warn if it represents itself as a URL to another app
|
||||
if (
|
||||
labelDomain &&
|
||||
labelDomain !== 'bsky.app' &&
|
||||
isPossiblyAUrl(labelDomain)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// if this is a link to external content,
|
||||
// warn if the label doesnt match the target
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
return labelDomain !== urip.hostname
|
||||
}
|
||||
}
|
||||
|
||||
function labelToDomain(label: string): string | undefined {
|
||||
// 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',
|
||||
},
|
||||
'post-text-lg': {
|
||||
fontSize: 22,
|
||||
letterSpacing: 0.4,
|
||||
fontSize: 20,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
},
|
||||
'button-lg': {
|
||||
|
||||
@@ -12,7 +12,6 @@ export const isMobileWeb =
|
||||
isWeb &&
|
||||
// @ts-ignore we know window exists -prf
|
||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||
export const isDesktopWeb = isWeb && !isMobileWeb
|
||||
|
||||
export const deviceLocales = dedupArray(
|
||||
getLocales?.().map?.(locale => locale.languageCode),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'fast-text-encoding'
|
||||
import Graphemer from 'graphemer'
|
||||
// @ts-ignore no decl -prf
|
||||
import findLast from 'array.prototype.findlast'
|
||||
export {}
|
||||
@@ -54,18 +53,3 @@ globalThis.atob = (str: string): string => {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const splitter = new Graphemer()
|
||||
globalThis.Intl = globalThis.Intl || {}
|
||||
|
||||
// @ts-ignore we're polyfilling -prf
|
||||
globalThis.Intl.Segmenter =
|
||||
// @ts-ignore we're polyfilling -prf
|
||||
globalThis.Intl.Segmenter ||
|
||||
class Segmenter {
|
||||
constructor() {}
|
||||
// NOTE
|
||||
// this is not a precisely correct polyfill but it's sufficient for our needs
|
||||
// -prf
|
||||
segment = splitter.iterateGraphemes
|
||||
}
|
||||
|
||||
@@ -6,16 +6,3 @@ findLast.shim()
|
||||
|
||||
// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
|
||||
window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
|
||||
|
||||
// @ts-ignore not on the TS signature due to bad support -prf
|
||||
if (!globalThis.Intl?.Segmenter) {
|
||||
// NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf
|
||||
const script = document.createElement('script')
|
||||
script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js')
|
||||
document.head.appendChild(script)
|
||||
|
||||
// loading emoji mart data
|
||||
const emojiMartScript = document.createElement('script')
|
||||
emojiMartScript.setAttribute('src', '/static/js/emoji-mart-data.js')
|
||||
document.head.appendChild(emojiMartScript)
|
||||
}
|
||||
|
||||
Vendored
+72
-36
@@ -1,6 +1,14 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const MAX_SYNC_PAGES = 10
|
||||
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||
|
||||
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
||||
|
||||
@@ -10,6 +18,14 @@ export enum FollowState {
|
||||
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
|
||||
* follows. It should be periodically refreshed and updated any time
|
||||
@@ -17,9 +33,8 @@ export enum FollowState {
|
||||
*/
|
||||
export class MyFollowsCache {
|
||||
// data
|
||||
followDidToRecordMap: Record<string, string | boolean> = {}
|
||||
byDid: Record<string, FollowInfo> = {}
|
||||
lastSync = 0
|
||||
myDid?: string
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
@@ -35,16 +50,45 @@ export class MyFollowsCache {
|
||||
// =
|
||||
|
||||
clear() {
|
||||
this.followDidToRecordMap = {}
|
||||
this.lastSync = 0
|
||||
this.myDid = undefined
|
||||
this.byDid = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs a subset of the user's follows
|
||||
* for performance reasons, caps out at 1000 follows
|
||||
*/
|
||||
syncIfNeeded = bundleAsync(async () => {
|
||||
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 {
|
||||
if (typeof this.followDidToRecordMap[did] === 'undefined') {
|
||||
if (typeof this.byDid[did] === 'undefined') {
|
||||
return FollowState.Unknown
|
||||
}
|
||||
if (typeof this.followDidToRecordMap[did] === 'string') {
|
||||
if (typeof this.byDid[did].followRecordUri === 'string') {
|
||||
return FollowState.Following
|
||||
}
|
||||
return FollowState.NotFollowing
|
||||
@@ -53,49 +97,41 @@ export class MyFollowsCache {
|
||||
async fetchFollowState(did: string): Promise<FollowState> {
|
||||
// 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})
|
||||
if (res.data.viewer?.following) {
|
||||
this.addFollow(did, res.data.viewer.following)
|
||||
} else {
|
||||
this.removeFollow(did)
|
||||
}
|
||||
this.hydrate(did, res.data)
|
||||
return this.getFollowState(did)
|
||||
}
|
||||
|
||||
getFollowUri(did: string): string {
|
||||
const v = this.followDidToRecordMap[did]
|
||||
if (typeof v === 'string') {
|
||||
return v
|
||||
const v = this.byDid[did]
|
||||
if (v && typeof v.followRecordUri === 'string') {
|
||||
return v.followRecordUri
|
||||
}
|
||||
throw new Error('Not a followed user')
|
||||
}
|
||||
|
||||
addFollow(did: string, recordUri: string) {
|
||||
this.followDidToRecordMap[did] = recordUri
|
||||
addFollow(did: string, info: FollowInfo) {
|
||||
this.byDid[did] = info
|
||||
}
|
||||
|
||||
removeFollow(did: string) {
|
||||
this.followDidToRecordMap[did] = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (this.byDid[did]) {
|
||||
this.byDid[did].followRecordUri = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this to incrementally update the cache as views provide information
|
||||
*/
|
||||
hydrateProfiles(profiles: Profile[]) {
|
||||
hydrate(did: string, profile: Profile) {
|
||||
this.byDid[did] = {
|
||||
did,
|
||||
followRecordUri: profile.viewer?.following,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
hydrateMany(profiles: Profile[]) {
|
||||
for (const profile of profiles) {
|
||||
if (profile.viewer) {
|
||||
this.hydrate(profile.did, profile.viewer.following)
|
||||
}
|
||||
this.hydrate(profile.did, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedGetPostThread as GetPostThread,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
@@ -76,6 +77,13 @@ export class PostThreadModel {
|
||||
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
|
||||
// =
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export class ProfileModel {
|
||||
runInAction(() => {
|
||||
this.followersCount++
|
||||
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', {
|
||||
username: this.handle,
|
||||
@@ -290,8 +290,8 @@ export class ProfileModel {
|
||||
this.labels = res.data.labels
|
||||
if (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() {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import sampleSize from 'lodash.samplesize'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
@@ -43,35 +39,13 @@ export class FoafsModel {
|
||||
try {
|
||||
this.isLoading = true
|
||||
|
||||
// fetch & hydrate up to 1000 follows
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
// fetch some of the user's follows
|
||||
await this.rootStore.me.follows.syncIfNeeded()
|
||||
|
||||
// grab 10 of the users followed by the user
|
||||
runInAction(() => {
|
||||
this.sources = sampleSize(
|
||||
Object.keys(this.rootStore.me.follows.followDidToRecordMap),
|
||||
Object.keys(this.rootStore.me.follows.byDid),
|
||||
10,
|
||||
)
|
||||
})
|
||||
@@ -100,7 +74,7 @@ export class FoafsModel {
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
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 source = this.sources[i]
|
||||
|
||||
@@ -81,6 +81,7 @@ export class OnboardingModel {
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.rootStore.me.mainFeed.refresh() // load the selected content
|
||||
this.step = 'Home'
|
||||
track('Onboarding:Complete')
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class SuggestedActorsModel {
|
||||
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
|
||||
.account.filter,
|
||||
)
|
||||
this.rootStore.me.follows.hydrateProfiles(actors)
|
||||
this.rootStore.me.follows.hydrateMany(actors)
|
||||
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
@@ -118,7 +118,7 @@ export class SuggestedActorsModel {
|
||||
actor: actor,
|
||||
})
|
||||
const {suggestions: moreSuggestions} = res.data
|
||||
this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
|
||||
this.rootStore.me.follows.hydrateMany(moreSuggestions)
|
||||
// dedupe
|
||||
const toInsert = moreSuggestions.filter(
|
||||
s => !this.suggestions.find(s2 => s2.did === s.did),
|
||||
|
||||
@@ -4,6 +4,8 @@ import AwaitLock from 'await-lock'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
|
||||
type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
|
||||
|
||||
export class UserAutocompleteModel {
|
||||
// state
|
||||
isLoading = false
|
||||
@@ -12,9 +14,8 @@ export class UserAutocompleteModel {
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
knownHandles: Set<string> = new Set()
|
||||
_suggestions: ProfileViewBasic[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
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) {
|
||||
return []
|
||||
}
|
||||
if (this.prefix) {
|
||||
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,
|
||||
}))
|
||||
return this._suggestions
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
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) {
|
||||
@@ -57,7 +64,7 @@ export class UserAutocompleteModel {
|
||||
}
|
||||
|
||||
async setPrefix(prefix: string) {
|
||||
const origPrefix = prefix.trim()
|
||||
const origPrefix = prefix.trim().toLocaleLowerCase()
|
||||
this.prefix = origPrefix
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
@@ -65,9 +72,27 @@ export class UserAutocompleteModel {
|
||||
if (this.prefix !== origPrefix) {
|
||||
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 {
|
||||
this.searchRes = []
|
||||
runInAction(() => {
|
||||
this._computeSuggestions([])
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
@@ -77,28 +102,40 @@ export class UserAutocompleteModel {
|
||||
// internal
|
||||
// =
|
||||
|
||||
async _getFollows() {
|
||||
const res = await this.rootStore.agent.getFollows({
|
||||
actor: this.rootStore.me.did || '',
|
||||
})
|
||||
runInAction(() => {
|
||||
this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle))
|
||||
for (const f of this.follows) {
|
||||
this.knownHandles.add(f.handle)
|
||||
_computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
|
||||
if (this.prefix) {
|
||||
const items: ProfileViewBasic[] = []
|
||||
for (const item of this.follows) {
|
||||
if (prefixMatch(this.prefix, item)) {
|
||||
items.push(item)
|
||||
}
|
||||
if (items.length >= 8) {
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async _search() {
|
||||
const res = await this.rootStore.agent.searchActorsTypeahead({
|
||||
term: this.prefix,
|
||||
limit: 8,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.searchRes = res.data.actors
|
||||
for (const u of this.searchRes) {
|
||||
this.knownHandles.add(u.handle)
|
||||
for (const item of searchRes) {
|
||||
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||
items.push({
|
||||
did: item.did,
|
||||
handle: item.handle,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
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,7 @@ export class PostsFeedItemModel {
|
||||
},
|
||||
() => this.rootStore.agent.deleteLike(url),
|
||||
)
|
||||
track('Post:Unlike')
|
||||
} else {
|
||||
// like
|
||||
await updateDataOptimistically(
|
||||
@@ -129,11 +130,10 @@ export class PostsFeedItemModel {
|
||||
this.post.viewer!.like = res.uri
|
||||
},
|
||||
)
|
||||
track('Post:Like')
|
||||
}
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to toggle like', error)
|
||||
} finally {
|
||||
track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export class PostsFeedItemModel {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
try {
|
||||
if (this.post.viewer?.repost) {
|
||||
// unrepost
|
||||
const url = this.post.viewer.repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
@@ -150,7 +151,9 @@ export class PostsFeedItemModel {
|
||||
},
|
||||
() => this.rootStore.agent.deleteRepost(url),
|
||||
)
|
||||
track('Post:Unrepost')
|
||||
} else {
|
||||
// repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
@@ -162,11 +165,10 @@ export class PostsFeedItemModel {
|
||||
this.post.viewer!.repost = res.uri
|
||||
},
|
||||
)
|
||||
track('Post:Repost')
|
||||
}
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to toggle repost', error)
|
||||
} finally {
|
||||
track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +176,13 @@ export class PostsFeedItemModel {
|
||||
try {
|
||||
if (this.isThreadMuted) {
|
||||
this.rootStore.mutedThreads.uris.delete(this.rootUri)
|
||||
track('Post:ThreadUnmute')
|
||||
} else {
|
||||
this.rootStore.mutedThreads.uris.add(this.rootUri)
|
||||
track('Post:ThreadMute')
|
||||
}
|
||||
} catch (error) {
|
||||
this.rootStore.log.error('Failed to toggle thread mute', error)
|
||||
} finally {
|
||||
track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,10 @@ export class PostsFeedModel {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get isLoadingMore() {
|
||||
return this.isLoading && !this.isRefreshing
|
||||
}
|
||||
|
||||
setHasNewLatest(v: boolean) {
|
||||
this.hasNewLatest = v
|
||||
}
|
||||
@@ -307,12 +311,12 @@ export class PostsFeedModel {
|
||||
}
|
||||
|
||||
async _appendAll(res: FeedAPIResponse, replace = false) {
|
||||
this.hasMore = !!res.cursor
|
||||
this.hasMore = !!res.cursor && res.feed.length > 0
|
||||
if (replace) {
|
||||
this.emptyFetches = 0
|
||||
}
|
||||
|
||||
this.rootStore.me.follows.hydrateProfiles(
|
||||
this.rootStore.me.follows.hydrateMany(
|
||||
res.feed.map(item => item.post.author),
|
||||
)
|
||||
for (const item of res.feed) {
|
||||
|
||||
@@ -61,7 +61,7 @@ export class InvitedUsers {
|
||||
profile => !profile.viewer?.following,
|
||||
)
|
||||
})
|
||||
this.rootStore.me.follows.hydrateProfiles(this.profiles)
|
||||
this.rootStore.me.follows.hydrateMany(this.profiles)
|
||||
} catch (e) {
|
||||
this.rootStore.log.error(
|
||||
'Failed to fetch profiles for invited users',
|
||||
|
||||
@@ -126,7 +126,7 @@ export class LikesModel {
|
||||
_appendAll(res: GetLikes.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.rootStore.me.follows.hydrateProfiles(
|
||||
this.rootStore.me.follows.hydrateMany(
|
||||
res.data.likes.map(like => like.actor),
|
||||
)
|
||||
this.likes = this.likes.concat(res.data.likes)
|
||||
|
||||
@@ -130,6 +130,6 @@ export class RepostedByModel {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
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.hasMore = !!this.loadMoreCursor
|
||||
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.hasMore = !!this.loadMoreCursor
|
||||
this.follows = this.follows.concat(res.data.follows)
|
||||
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
|
||||
this.rootStore.me.follows.hydrateMany(res.data.follows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {openCropper} from 'lib/media/picker'
|
||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
@@ -164,8 +165,13 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
// Mobile
|
||||
async crop() {
|
||||
try {
|
||||
// openCropper requires an output width and height hence
|
||||
// getting upload dimensions before cropping is necessary.
|
||||
// NOTE
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
// providing width & height for ios only
|
||||
// -prf
|
||||
const {width, height} = this.getUploadDimensions({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
@@ -175,8 +181,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
mediaType: 'photo',
|
||||
path: this.path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
...(isIOS ? {width, height} : {}),
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {ImageSizesCache} from './cache/image-sizes'
|
||||
import {MutedThreads} from './muted-threads'
|
||||
import {Reminders} from './ui/reminders'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
|
||||
// TEMPORARY (APP-700)
|
||||
@@ -53,6 +54,7 @@ export class RootStoreModel {
|
||||
linkMetas = new LinkMetasCache(this)
|
||||
imageSizes = new ImageSizesCache()
|
||||
mutedThreads = new MutedThreads()
|
||||
reminders = new Reminders(this)
|
||||
|
||||
constructor(agent: BskyAgent) {
|
||||
this.agent = agent
|
||||
@@ -77,6 +79,7 @@ export class RootStoreModel {
|
||||
preferences: this.preferences.serialize(),
|
||||
invitedUsers: this.invitedUsers.serialize(),
|
||||
mutedThreads: this.mutedThreads.serialize(),
|
||||
reminders: this.reminders.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +112,9 @@ export class RootStoreModel {
|
||||
if (hasProp(v, 'mutedThreads')) {
|
||||
this.mutedThreads.hydrate(v.mutedThreads)
|
||||
}
|
||||
if (hasProp(v, 'reminders')) {
|
||||
this.reminders.hydrate(v.reminders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const accountData = z.object({
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
aviUrl: z.string().optional(),
|
||||
emailConfirmed: z.boolean().optional(),
|
||||
})
|
||||
export type AccountData = z.infer<typeof accountData>
|
||||
|
||||
@@ -106,6 +107,10 @@ export class SessionModel {
|
||||
return this.accounts.filter(acct => acct.did !== this.data?.did)
|
||||
}
|
||||
|
||||
get emailNeedsConfirmation() {
|
||||
return !this.currentSession?.emailConfirmed
|
||||
}
|
||||
|
||||
get isSandbox() {
|
||||
if (!this.data) {
|
||||
return false
|
||||
@@ -217,6 +222,7 @@ export class SessionModel {
|
||||
? addedInfo.displayName
|
||||
: existingAccount?.displayName || '',
|
||||
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
|
||||
emailConfirmed: session?.emailConfirmed,
|
||||
}
|
||||
if (!existingAccount) {
|
||||
this.accounts.push(newAccount)
|
||||
@@ -246,6 +252,8 @@ export class SessionModel {
|
||||
did: acct.did,
|
||||
displayName: acct.displayName,
|
||||
aviUrl: acct.aviUrl,
|
||||
email: acct.email,
|
||||
emailConfirmed: acct.emailConfirmed,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -297,6 +305,8 @@ export class SessionModel {
|
||||
refreshJwt: account.refreshJwt || '',
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
email: account.email,
|
||||
emailConfirmed: account.emailConfirmed,
|
||||
}),
|
||||
)
|
||||
const addedInfo = await this.loadAccountInfo(agent, account.did)
|
||||
@@ -452,4 +462,10 @@ export class SessionModel {
|
||||
await this.rootStore.me.load()
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalAccountData(changes: Partial<AccountData>) {
|
||||
this.accounts = this.accounts.map(acct =>
|
||||
acct.did === this.data?.did ? {...acct, ...changes} : acct,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export class CreateAccountModel {
|
||||
email: this.email,
|
||||
handle: createFullHandle(this.handle, this.userDomain),
|
||||
password: this.password,
|
||||
inviteCode: this.inviteCode,
|
||||
inviteCode: this.inviteCode.trim(),
|
||||
})
|
||||
/* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
|
||||
track('Create Account')
|
||||
|
||||
@@ -418,6 +418,7 @@ export class PreferencesModel {
|
||||
const oldPinned = this.pinnedFeeds
|
||||
this.savedFeeds = saved
|
||||
this.pinnedFeeds = pinned
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
const res = await cb()
|
||||
runInAction(() => {
|
||||
@@ -430,6 +431,8 @@ export class PreferencesModel {
|
||||
this.pinnedFeeds = oldPinned
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +444,7 @@ export class PreferencesModel {
|
||||
|
||||
async addSavedFeed(v: string) {
|
||||
return this._optimisticUpdateSavedFeeds(
|
||||
[...this.savedFeeds, v],
|
||||
[...this.savedFeeds.filter(uri => uri !== v), v],
|
||||
this.pinnedFeeds,
|
||||
() => this.rootStore.agent.addSavedFeed(v),
|
||||
)
|
||||
@@ -457,8 +460,8 @@ export class PreferencesModel {
|
||||
|
||||
async addPinnedFeed(v: string) {
|
||||
return this._optimisticUpdateSavedFeeds(
|
||||
this.savedFeeds,
|
||||
[...this.pinnedFeeds, v],
|
||||
[...this.savedFeeds.filter(uri => uri !== v), v],
|
||||
[...this.pinnedFeeds.filter(uri => uri !== v), v],
|
||||
() => this.rootStore.agent.addPinnedFeed(v),
|
||||
)
|
||||
}
|
||||
@@ -473,71 +476,121 @@ export class PreferencesModel {
|
||||
|
||||
async setBirthDate(birthDate: Date) {
|
||||
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() {
|
||||
this.homeFeed.hideReplies = !this.homeFeed.hideReplies
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideReplies: this.homeFeed.hideReplies,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideReplies: this.homeFeed.hideReplies,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async toggleHomeFeedHideRepliesByUnfollowed() {
|
||||
this.homeFeed.hideRepliesByUnfollowed =
|
||||
!this.homeFeed.hideRepliesByUnfollowed
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async setHomeFeedHideRepliesByLikeCount(threshold: number) {
|
||||
this.homeFeed.hideRepliesByLikeCount = threshold
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async toggleHomeFeedHideReposts() {
|
||||
this.homeFeed.hideReposts = !this.homeFeed.hideReposts
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideReposts: this.homeFeed.hideReposts,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideReposts: this.homeFeed.hideReposts,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async toggleHomeFeedHideQuotePosts() {
|
||||
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideQuotePosts: this.homeFeed.hideQuotePosts,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
hideQuotePosts: this.homeFeed.hideQuotePosts,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async toggleHomeFeedMergeFeedEnabled() {
|
||||
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setFeedViewPrefs('home', {
|
||||
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async setThreadSort(v: string) {
|
||||
if (THREAD_SORT_VALUES.includes(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() {
|
||||
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
|
||||
await this.rootStore.agent.setThreadViewPrefs({
|
||||
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setThreadViewPrefs({
|
||||
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async toggleThreadTreeViewEnabled() {
|
||||
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
|
||||
await this.rootStore.agent.setThreadViewPrefs({
|
||||
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
|
||||
})
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
await this.rootStore.agent.setThreadViewPrefs({
|
||||
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
|
||||
})
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
toggleRequireAltTextEnabled() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
export class Reminders {
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{serialize: false, hydrate: false},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {}
|
||||
}
|
||||
|
||||
hydrate(_v: unknown) {}
|
||||
|
||||
get shouldRequestEmailConfirmation() {
|
||||
return false
|
||||
}
|
||||
|
||||
setEmailConfirmationRequested() {}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
export class Reminders {
|
||||
lastEmailConfirm: Date | null = null
|
||||
|
||||
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
|
||||
}
|
||||
// only prompt once
|
||||
if (this.lastEmailConfirm) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
// shard the users into 2 day of the week buckets
|
||||
// (this is to avoid a sudden influx of email updates when
|
||||
// this feature rolls out)
|
||||
const code = toHashCode(sess.did) % 7
|
||||
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setEmailConfirmationRequested() {
|
||||
this.lastEmailConfirm = new Date()
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class SearchUIModel {
|
||||
} while (profilesSearch.length)
|
||||
}
|
||||
|
||||
this.rootStore.me.follows.hydrateProfiles(profiles)
|
||||
this.rootStore.me.follows.hydrateMany(profiles)
|
||||
|
||||
runInAction(() => {
|
||||
this.profiles = profiles
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ConfirmModal {
|
||||
onPressCancel?: () => void | Promise<void>
|
||||
confirmBtnText?: string
|
||||
confirmBtnStyle?: StyleProp<ViewStyle>
|
||||
cancelBtnText?: string
|
||||
}
|
||||
|
||||
export interface EditProfileModal {
|
||||
@@ -140,6 +141,25 @@ export interface BirthDateSettingsModal {
|
||||
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 =
|
||||
// Account
|
||||
| AddAppPasswordModal
|
||||
@@ -148,6 +168,9 @@ export type Modal =
|
||||
| EditProfileModal
|
||||
| ProfilePreviewModal
|
||||
| BirthDateSettingsModal
|
||||
| VerifyEmailModal
|
||||
| ChangeEmailModal
|
||||
| SwitchAccountModal
|
||||
|
||||
// Curation
|
||||
| ContentFilteringSettingsModal
|
||||
@@ -174,6 +197,7 @@ export type Modal =
|
||||
|
||||
// Generic
|
||||
| ConfirmModal
|
||||
| LinkWarningModal
|
||||
|
||||
interface LightboxModel {}
|
||||
|
||||
@@ -250,6 +274,7 @@ export class ShellUiModel {
|
||||
})
|
||||
|
||||
this.setupClock()
|
||||
this.setupLoginModals()
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
@@ -375,4 +400,13 @@ export class ShellUiModel {
|
||||
})
|
||||
}, 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 {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {isMobileWeb} from 'platform/detection'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
|
||||
export const SplashScreen = ({
|
||||
onPressSignin,
|
||||
@@ -16,6 +17,9 @@ export const SplashScreen = ({
|
||||
onPressCreateAccount: () => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
const styles = useStyles()
|
||||
const isMobileWeb = isWeb && isTabletOrMobile
|
||||
|
||||
return (
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
@@ -55,13 +59,14 @@ export const SplashScreen = ({
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
<Footer />
|
||||
<Footer styles={styles} />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
@@ -82,78 +87,81 @@ function Footer() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
},
|
||||
containerInner: {
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
// @ts-ignore web only
|
||||
paddingBottom: '20vh',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
containerInnerMobile: {
|
||||
paddingBottom: 50,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
color: colors.blue3,
|
||||
fontSize: 68,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 10,
|
||||
},
|
||||
titleMobile: {
|
||||
textAlign: 'center',
|
||||
color: colors.blue3,
|
||||
fontSize: 58,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
color: colors.gray5,
|
||||
fontSize: 52,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 30,
|
||||
},
|
||||
subtitleMobile: {
|
||||
textAlign: 'center',
|
||||
color: colors.gray5,
|
||||
fontSize: 42,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 30,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: isMobileWeb ? 'column' : 'row',
|
||||
gap: 20,
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 30,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
minWidth: 220,
|
||||
},
|
||||
btnLabel: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
},
|
||||
notice: {
|
||||
paddingHorizontal: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
footerLink: {
|
||||
marginRight: 20,
|
||||
},
|
||||
})
|
||||
const useStyles = () => {
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
const isMobileWeb = isWeb && isTabletOrMobile
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
},
|
||||
containerInner: {
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
// @ts-ignore web only
|
||||
paddingBottom: '20vh',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
containerInnerMobile: {
|
||||
paddingBottom: 50,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
color: colors.blue3,
|
||||
fontSize: 68,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 10,
|
||||
},
|
||||
titleMobile: {
|
||||
textAlign: 'center',
|
||||
color: colors.blue3,
|
||||
fontSize: 58,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
color: colors.gray5,
|
||||
fontSize: 52,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 30,
|
||||
},
|
||||
subtitleMobile: {
|
||||
textAlign: 'center',
|
||||
color: colors.gray5,
|
||||
fontSize: 42,
|
||||
fontWeight: 'bold',
|
||||
paddingBottom: 30,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: isMobileWeb ? 'column' : 'row',
|
||||
gap: 20,
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 30,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
minWidth: 220,
|
||||
},
|
||||
btnLabel: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
},
|
||||
notice: {
|
||||
paddingHorizontal: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
footerLink: {
|
||||
marginRight: 20,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {TextInput} from '../util/TextInput'
|
||||
import {Policies} from './Policies'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {useStores} from 'state/index'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
/** STEP 2: Your account
|
||||
* @field Invite code or waitlist
|
||||
@@ -60,10 +61,11 @@ export const Step2 = observer(function Step2Impl({
|
||||
Don't have an invite code?{' '}
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onPressWaitlist}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Waitlist"
|
||||
accessibilityHint="Opens Bluesky waitlist form">
|
||||
<Text style={pal.link}>Join the waitlist.</Text>
|
||||
accessibilityLabel="Join the waitlist."
|
||||
accessibilityHint="">
|
||||
<View style={styles.touchable}>
|
||||
<Text style={pal.link}>Join the waitlist.</Text>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Text>
|
||||
) : (
|
||||
@@ -151,4 +153,8 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 6,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
|
||||
touchable: {
|
||||
...(isWeb && {cursor: 'pointer'}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
||||
tdStyles.title2,
|
||||
isTabletOrMobile && tdStyles.title2Small,
|
||||
]}>
|
||||
Recomended
|
||||
Recommended
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await item.save()
|
||||
await item.pin()
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
|
||||
@@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
|
||||
</View>
|
||||
|
||||
<FollowButton
|
||||
did={profile.did}
|
||||
profile={profile}
|
||||
labelStyle={styles.followButton}
|
||||
onToggleFollow={async isFollow => {
|
||||
if (isFollow) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -49,14 +50,10 @@ import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||
import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
|
||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||
|
||||
type Props = ComposerOpts & {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
replyTo,
|
||||
onPost,
|
||||
onClose,
|
||||
quote: initQuote,
|
||||
mention: initMention,
|
||||
}: Props) {
|
||||
@@ -90,6 +87,9 @@ export const ComposePost = observer(function ComposePost({
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||
const gallery = useMemo(() => new GalleryModel(store), [store])
|
||||
const onClose = useCallback(() => {
|
||||
store.shell.closeComposer()
|
||||
}, [store])
|
||||
|
||||
const autocompleteView = useMemo<UserAutocompleteModel>(
|
||||
() => new UserAutocompleteModel(store),
|
||||
@@ -129,6 +129,23 @@ export const ComposePost = observer(function ComposePost({
|
||||
onClose()
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {ImageStyle, Keyboard} from 'react-native'
|
||||
import React, {useState} from 'react'
|
||||
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
@@ -8,15 +8,45 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {openAltTextModal} from 'lib/media/alt-text'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
|
||||
interface Props {
|
||||
const IMAGE_GAP = 8
|
||||
|
||||
interface GalleryProps {
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
export const Gallery = observer(function GalleryImpl({gallery}: Props) {
|
||||
export const Gallery = (props: GalleryProps) => {
|
||||
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
|
||||
|
||||
const onLayout = (evt: LayoutChangeEvent) => {
|
||||
const {width, height} = evt.nativeEvent.layout
|
||||
setContainerInfo({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View onLayout={onLayout}>
|
||||
{containerInfo ? (
|
||||
<GalleryInner {...props} containerInfo={containerInfo} />
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
interface GalleryInnerProps extends GalleryProps {
|
||||
containerInfo: Dimensions
|
||||
}
|
||||
|
||||
const GalleryInner = observer(function GalleryImpl({
|
||||
gallery,
|
||||
containerInfo,
|
||||
}: GalleryInnerProps) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -26,7 +56,7 @@ export const Gallery = observer(function GalleryImpl({gallery}: Props) {
|
||||
if (gallery.size === 1) {
|
||||
side = 250
|
||||
} else {
|
||||
side = (isMobile ? 350 : 560) / gallery.size
|
||||
side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
|
||||
}
|
||||
|
||||
const imageStyle = {
|
||||
@@ -169,7 +199,7 @@ const styles = StyleSheet.create({
|
||||
gallery: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
gap: IMAGE_GAP,
|
||||
marginTop: 16,
|
||||
},
|
||||
image: {
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useStores} from 'state/index'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {openCamera} from 'lib/media/picker'
|
||||
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
||||
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {isMobileWeb, isNative} from 'platform/detection'
|
||||
|
||||
type Props = {
|
||||
gallery: GalleryModel
|
||||
@@ -43,7 +43,8 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||
}
|
||||
}, [gallery, track, store, requestCameraAccessIfNeeded])
|
||||
|
||||
if (isDesktopWeb) {
|
||||
const shouldShowCameraButton = isNative || isMobileWeb
|
||||
if (!shouldShowCameraButton) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
type Props = {
|
||||
gallery: GalleryModel
|
||||
@@ -23,12 +23,12 @@ export function SelectPhotoBtn({gallery}: Props) {
|
||||
const onPressSelectPhotos = useCallback(async () => {
|
||||
track('Composer:GalleryOpened')
|
||||
|
||||
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
|
||||
if (isNative && !(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
gallery.pick()
|
||||
}, [track, gallery, requestPhotoAccessIfNeeded])
|
||||
}, [track, requestPhotoAccessIfNeeded, gallery])
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -187,16 +187,19 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
!segment.facet ? pal.text : pal.link,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||
return (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
segment.facet && !isTag ? pal.link : pal.text,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
}, [richtext, pal.link, pal.text])
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {isUriImage, blobToDataUri} from 'lib/media/util'
|
||||
import {Emoji} from './web/EmojiPicker.web'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -52,6 +53,26 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
ref,
|
||||
) {
|
||||
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
||||
const extensions = React.useMemo(
|
||||
() => [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: createSuggestion({autocompleteView}),
|
||||
}),
|
||||
Paragraph,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Text,
|
||||
History,
|
||||
Hardbreak,
|
||||
],
|
||||
[autocompleteView, placeholder],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('publish', onPressPublish)
|
||||
@@ -68,23 +89,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: createSuggestion({autocompleteView}),
|
||||
}),
|
||||
Paragraph,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Text,
|
||||
History,
|
||||
Hardbreak,
|
||||
],
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: modeClass,
|
||||
@@ -107,14 +112,14 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
}
|
||||
},
|
||||
},
|
||||
content: textToEditorJson(richtext.text.toString()),
|
||||
content: generateJSON(richtext.text.toString(), extensions),
|
||||
autofocus: 'end',
|
||||
editable: true,
|
||||
injectCSS: true,
|
||||
onUpdate({editor: editorProp}) {
|
||||
const json = editorProp.getJSON()
|
||||
|
||||
const newRt = new RichText({text: editorJsonToText(json).trim()})
|
||||
const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
@@ -182,61 +187,6 @@ function editorJsonToText(json: JSONContent): string {
|
||||
return text
|
||||
}
|
||||
|
||||
function textToEditorJson(text: string): JSONContent {
|
||||
if (text === '' || text.length === 0) {
|
||||
return {
|
||||
text: '',
|
||||
}
|
||||
}
|
||||
|
||||
const lines = text.split('\n')
|
||||
const docContent: JSONContent[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') {
|
||||
continue // skip empty lines
|
||||
}
|
||||
|
||||
const paragraphContent: JSONContent[] = []
|
||||
let position = 0
|
||||
|
||||
while (position < line.length) {
|
||||
if (line[position] === '@') {
|
||||
// Handle mentions
|
||||
let endPosition = position + 1
|
||||
while (endPosition < line.length && /\S/.test(line[endPosition])) {
|
||||
endPosition++
|
||||
}
|
||||
const mentionId = line.substring(position + 1, endPosition)
|
||||
paragraphContent.push({
|
||||
type: 'mention',
|
||||
attrs: {id: mentionId},
|
||||
})
|
||||
position = endPosition
|
||||
} else {
|
||||
// Handle regular text
|
||||
let endPosition = line.indexOf('@', position)
|
||||
if (endPosition === -1) endPosition = line.length
|
||||
paragraphContent.push({
|
||||
type: 'text',
|
||||
text: line.substring(position, endPosition),
|
||||
})
|
||||
position = endPosition
|
||||
}
|
||||
}
|
||||
|
||||
docContent.push({
|
||||
type: 'paragraph',
|
||||
content: paragraphContent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'doc',
|
||||
content: docContent,
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -72,8 +72,9 @@ export function EmojiPicker({close}: {close: () => void}) {
|
||||
},
|
||||
]}>
|
||||
<Picker
|
||||
// @ts-ignore we set emojiMartData in `emoji-mart-data.js` file
|
||||
data={window.emojiMartData}
|
||||
data={async () => {
|
||||
return (await import('./EmojiPickerData.json')).default
|
||||
}}
|
||||
onEmojiSelect={onInsert}
|
||||
autoFocus={false}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import {Mark} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {findChildren} from '@tiptap/core'
|
||||
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
import {isValidDomain} from 'lib/strings/url-helpers'
|
||||
@@ -36,20 +35,20 @@ export const LinkDecorator = Mark.create({
|
||||
function getDecorations(doc: ProsemirrorNode) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
findChildren(doc, node => node.type.name === 'paragraph').forEach(
|
||||
paragraphNode => {
|
||||
const textContent = paragraphNode.node.textContent
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
const textContent = node.textContent
|
||||
|
||||
// links
|
||||
iterateUris(textContent, (from, to) => {
|
||||
decorations.push(
|
||||
Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, {
|
||||
Decoration.inline(pos + from, pos + to, {
|
||||
class: 'autolink',
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import {useAnalytics} from '@segment/analytics-react-native'
|
||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React from 'react'
|
||||
import {FlatList, View} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedModel} from 'state/models/feeds/posts'
|
||||
import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
|
||||
import {Feed} from '../posts/Feed'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
|
||||
import useAppState from 'react-native-appstate-hook'
|
||||
|
||||
export const FeedPage = observer(function FeedPageImpl({
|
||||
testID,
|
||||
isPageFocused,
|
||||
feed,
|
||||
renderEmptyState,
|
||||
renderEndOfFeed,
|
||||
}: {
|
||||
testID?: string
|
||||
feed: PostsFeedModel
|
||||
isPageFocused: boolean
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
|
||||
const {screen, track} = useAnalytics()
|
||||
const headerOffset = useHeaderOffset()
|
||||
const scrollElRef = React.useRef<FlatList>(null)
|
||||
const {appState} = useAppState({
|
||||
onForeground: () => doPoll(true),
|
||||
})
|
||||
const isScreenFocused = useIsFocused()
|
||||
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
||||
|
||||
React.useEffect(() => {
|
||||
// called on first load
|
||||
if (!feed.hasLoaded && isPageFocused) {
|
||||
feed.setup()
|
||||
}
|
||||
}, [isPageFocused, feed])
|
||||
|
||||
const doPoll = React.useCallback(
|
||||
(knownActive = false) => {
|
||||
if (
|
||||
(!knownActive && appState !== 'active') ||
|
||||
!isScreenFocused ||
|
||||
!isPageFocused
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (feed.isLoading) {
|
||||
return
|
||||
}
|
||||
store.log.debug('HomeScreen: Polling for new posts')
|
||||
feed.checkForLatest()
|
||||
},
|
||||
[appState, isScreenFocused, isPageFocused, store, feed],
|
||||
)
|
||||
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
|
||||
resetMainScroll()
|
||||
}, [headerOffset, resetMainScroll])
|
||||
|
||||
const onSoftReset = React.useCallback(() => {
|
||||
if (isPageFocused) {
|
||||
scrollToTop()
|
||||
feed.refresh()
|
||||
}
|
||||
}, [isPageFocused, scrollToTop, feed])
|
||||
|
||||
// fires when page within screen is activated/deactivated
|
||||
// - check for latest
|
||||
React.useEffect(() => {
|
||||
if (!isPageFocused || !isScreenFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
||||
const feedCleanup = feed.registerListeners()
|
||||
const pollInterval = setInterval(doPoll, POLL_FREQ)
|
||||
|
||||
screen('Feed')
|
||||
store.log.debug('HomeScreen: Updating feed')
|
||||
feed.checkForLatest()
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval)
|
||||
softResetSub.remove()
|
||||
feedCleanup()
|
||||
}
|
||||
}, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
|
||||
|
||||
const onPressCompose = React.useCallback(() => {
|
||||
track('HomeScreen:PressCompose')
|
||||
store.shell.openComposer({})
|
||||
}, [store, track])
|
||||
|
||||
const onPressTryAgain = React.useCallback(() => {
|
||||
feed.refresh()
|
||||
}, [feed])
|
||||
|
||||
const onPressLoadLatest = React.useCallback(() => {
|
||||
scrollToTop()
|
||||
feed.refresh()
|
||||
}, [feed, scrollToTop])
|
||||
|
||||
const ListHeaderComponent = React.useCallback(() => {
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
]}>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/"
|
||||
style={[pal.text, {fontWeight: 'bold'}]}
|
||||
text={
|
||||
<>
|
||||
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
|
||||
{hasNew && (
|
||||
<View
|
||||
style={{
|
||||
top: -8,
|
||||
backgroundColor: colors.blue3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={() => store.emitScreenSoftReset()}
|
||||
/>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/home-feed"
|
||||
style={{fontWeight: 'bold'}}
|
||||
accessibilityLabel="Feed Preferences"
|
||||
accessibilityHint=""
|
||||
text={
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}, [isDesktop, pal, store, hasNew])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
<Feed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
key="default"
|
||||
feed={feed}
|
||||
scrollElRef={scrollElRef}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
onScroll={onMainScroll}
|
||||
scrollEventThrottle={100}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
onPress={onPressLoadLatest}
|
||||
label="Load new posts"
|
||||
showIndicator={hasNew}
|
||||
/>
|
||||
)}
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={onPressCompose}
|
||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New post"
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
@@ -1,157 +1,386 @@
|
||||
/**
|
||||
* 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, {useState} from 'react'
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
|
||||
import {
|
||||
Animated,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
NativeMethodsMixin,
|
||||
} from 'react-native'
|
||||
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
withDecay,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {GestureDetector, Gesture} from 'react-native-gesture-handler'
|
||||
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_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
const MIN_DOUBLE_TAP_SCALE = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
const initialTransform = createTransform()
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
onLongPress: (image: ImageSource) => void
|
||||
delayLongPress: number
|
||||
swipeToCloseEnabled?: boolean
|
||||
doubleTapToZoomEnabled?: boolean
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
onLongPress,
|
||||
delayLongPress,
|
||||
swipeToCloseEnabled = true,
|
||||
doubleTapToZoomEnabled = true,
|
||||
isScrollViewBeingDragged,
|
||||
}: Props) => {
|
||||
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const imageDimensions = useImageDimensions(imageSrc)
|
||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||
const scrollValueY = new Animated.Value(0)
|
||||
const [isLoaded, setLoadEnd] = useState(false)
|
||||
const committedTransform = useSharedValue(initialTransform)
|
||||
const panTranslation = useSharedValue({x: 0, y: 0})
|
||||
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), [])
|
||||
const onZoomPerformed = useCallback(
|
||||
(isZoomed: boolean) => {
|
||||
onZoom(isZoomed)
|
||||
if (imageContainer?.current) {
|
||||
imageContainer.current.setNativeProps({
|
||||
scrollEnabled: !isZoomed,
|
||||
})
|
||||
// Keep track of when we're entering or leaving scaled rendering.
|
||||
// Note: DO NOT move any logic reading animated values outside this function.
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
if (pinchScale.value !== 1) {
|
||||
// We're currently pinching.
|
||||
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(() => {
|
||||
onLongPress(imageSrc)
|
||||
}, [imageSrc, onLongPress])
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
setIsScaled(nextIsScaled)
|
||||
onZoom(nextIsScaled)
|
||||
}
|
||||
|
||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
||||
initialScale: scale || 1,
|
||||
initialTranslate: translate || {x: 0, y: 0},
|
||||
onZoom: onZoomPerformed,
|
||||
doubleTapToZoomEnabled,
|
||||
onLongPress: onLongPressHandler,
|
||||
delayLongPress,
|
||||
})
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
// Apply the active adjustments on top of the committed transform before the gestures.
|
||||
// This is matrix multiplication, so operations are applied in the reverse order.
|
||||
let t = createTransform()
|
||||
prependPan(t, panTranslation.value)
|
||||
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||
prependTransform(t, committedTransform.value)
|
||||
const [translateX, translateY, scale] = readTransform(t)
|
||||
|
||||
const imagesStyles = getImageStyles(
|
||||
imageDimensions,
|
||||
translateValue,
|
||||
scaleValue,
|
||||
)
|
||||
const imageOpacity = scrollValueY.interpolate({
|
||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
outputRange: [0.7, 1, 0.7],
|
||||
})
|
||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
||||
|
||||
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()
|
||||
const dismissDistance = dismissSwipeTranslateY.value
|
||||
const dismissProgress = Math.min(
|
||||
Math.abs(dismissDistance) / (SCREEN.height / 2),
|
||||
1,
|
||||
)
|
||||
return {
|
||||
opacity: 1 - dismissProgress,
|
||||
transform: [
|
||||
{translateX},
|
||||
{translateY: translateY + dismissDistance},
|
||||
{scale},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 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>) => {
|
||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||
const pinch = Gesture.Pinch()
|
||||
.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
|
||||
|
||||
scrollValueY.setValue(offsetY)
|
||||
}
|
||||
// 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 singleTap = Gesture.Tap().onEnd(() => {
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
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 composedGesture = isScrollViewBeingDragged
|
||||
? // If the parent is not at rest, provide a no-op gesture.
|
||||
Gesture.Manual()
|
||||
: Gesture.Exclusive(
|
||||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
singleTap,
|
||||
)
|
||||
|
||||
const isLoading = !isLoaded || !imageDimensions
|
||||
return (
|
||||
<ScrollView
|
||||
ref={imageContainer}
|
||||
style={styles.listItem}
|
||||
pagingEnabled
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={swipeToCloseEnabled}
|
||||
{...(swipeToCloseEnabled && {
|
||||
onScroll,
|
||||
onScrollEndDrag,
|
||||
})}>
|
||||
<AnimatedImage
|
||||
{...panHandlers}
|
||||
source={imageSrc}
|
||||
style={imageStylesWithOpacity}
|
||||
onLoad={onLoaded}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
||||
</ScrollView>
|
||||
<Animated.View ref={containerRef} style={styles.container}>
|
||||
{isLoading && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
container: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT * 2,
|
||||
image: {
|
||||
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)
|
||||
|
||||
@@ -6,159 +6,251 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {Dimensions, StyleSheet} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
|
||||
import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
|
||||
import {getImageStyles, getImageTransform} from '../../utils'
|
||||
import {ImageSource} from '../../@types'
|
||||
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||
import {ImageLoading} from './ImageLoading'
|
||||
|
||||
const SWIPE_CLOSE_OFFSET = 75
|
||||
const SWIPE_CLOSE_VELOCITY = 1
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
const MAX_SCALE = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
const MIN_DOUBLE_TAP_SCALE = 2
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLongPress: (image: ImageSource) => void
|
||||
delayLongPress: number
|
||||
swipeToCloseEnabled?: boolean
|
||||
doubleTapToZoomEnabled?: boolean
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
onLongPress,
|
||||
delayLongPress,
|
||||
swipeToCloseEnabled = true,
|
||||
doubleTapToZoomEnabled = true,
|
||||
}: Props) => {
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const translationY = useSharedValue(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [scaled, setScaled] = useState(false)
|
||||
const imageDimensions = useImageDimensions(imageSrc)
|
||||
const handleDoubleTap = useDoubleTapToZoom(
|
||||
scrollViewRef,
|
||||
scaled,
|
||||
SCREEN,
|
||||
imageDimensions,
|
||||
)
|
||||
const maxZoomScale = imageDimensions
|
||||
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||
: 1
|
||||
|
||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||
const scrollValueY = new Animated.Value(0)
|
||||
const scaleValue = new Animated.Value(scale || 1)
|
||||
const translateValue = new Animated.ValueXY(translate)
|
||||
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
||||
|
||||
const imageOpacity = scrollValueY.interpolate({
|
||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
outputRange: [0.5, 1, 0.5],
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
translationY.value,
|
||||
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
[0.5, 1, 0.5],
|
||||
),
|
||||
}
|
||||
})
|
||||
const imagesStyles = getImageStyles(
|
||||
imageDimensions,
|
||||
translateValue,
|
||||
scaleValue,
|
||||
)
|
||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
||||
|
||||
const onScrollEndDrag = useCallback(
|
||||
({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
||||
const currentScaled = nativeEvent?.zoomScale > 1
|
||||
|
||||
onZoom(currentScaled)
|
||||
setScaled(currentScaled)
|
||||
|
||||
if (
|
||||
!currentScaled &&
|
||||
swipeToCloseEnabled &&
|
||||
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
|
||||
) {
|
||||
onRequestClose()
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
},
|
||||
[onRequestClose, onZoom, swipeToCloseEnabled],
|
||||
)
|
||||
onEndDrag(e) {
|
||||
const velocityY = e.velocity?.y ?? 0
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||
runOnJS(onRequestClose)()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||
|
||||
if (nativeEvent?.zoomScale > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollValueY.setValue(offsetY)
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
onZoom(nextIsScaled)
|
||||
setScaled(nextIsScaled)
|
||||
}
|
||||
|
||||
const onLongPressHandler = useCallback(() => {
|
||||
onLongPress(imageSrc)
|
||||
}, [imageSrc, onLongPress])
|
||||
function handleDoubleTap(absoluteX: number, absoluteY: number) {
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
let nextZoomRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
}
|
||||
|
||||
const willZoom = !scaled
|
||||
if (willZoom) {
|
||||
nextZoomRect = getZoomRectAfterDoubleTap(
|
||||
imageDimensions,
|
||||
absoluteX,
|
||||
absoluteY,
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
...nextZoomRect, // This rect is in screen coordinates
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
const {absoluteX, absoluteY} = e
|
||||
runOnJS(handleDoubleTap)(absoluteX, absoluteY)
|
||||
})
|
||||
|
||||
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.ScrollView
|
||||
// @ts-ignore Something's up with the types here
|
||||
ref={scrollViewRef}
|
||||
style={styles.listItem}
|
||||
pinchGestureEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
maximumZoomScale={maxScrollViewZoom}
|
||||
maximumZoomScale={maxZoomScale}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={swipeToCloseEnabled}
|
||||
onScrollEndDrag={onScrollEndDrag}
|
||||
scrollEventThrottle={1}
|
||||
{...(swipeToCloseEnabled && {
|
||||
onScroll,
|
||||
})}>
|
||||
onScroll={scrollHandler}>
|
||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||
<TouchableWithoutFeedback
|
||||
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
|
||||
onLongPress={onLongPressHandler}
|
||||
delayLongPress={delayLongPress}
|
||||
accessibilityRole="image"
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint="">
|
||||
<AnimatedImage
|
||||
source={imageSrc}
|
||||
style={imageStylesWithOpacity}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
</ScrollView>
|
||||
</View>
|
||||
accessibilityHint=""
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
listItem: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
image: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
},
|
||||
})
|
||||
|
||||
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_DOUBLE_TAP_SCALE,
|
||||
)
|
||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(ImageItem)
|
||||
|
||||
@@ -7,11 +7,9 @@ import {ImageSource} from '../../@types'
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLongPress: (image: ImageSource) => void
|
||||
delayLongPress: number
|
||||
swipeToCloseEnabled?: boolean
|
||||
doubleTapToZoomEnabled?: boolean
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
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 {Image, ImageURISource} from 'react-native'
|
||||
|
||||
import {createCache} from '../utils'
|
||||
import {Dimensions, ImageSource} from '../@types'
|
||||
|
||||
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 useImageDimensions = (image: ImageSource): Dimensions | null => {
|
||||
@@ -21,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
|
||||
return new Promise(resolve => {
|
||||
if (typeof image === 'number') {
|
||||
const cacheKey = `${image}`
|
||||
let imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||
|
||||
if (!imageDimensions) {
|
||||
const {width, height} = Image.resolveAssetSource(image)
|
||||
imageDimensions = {width, height}
|
||||
imageDimensionsCache.set(cacheKey, imageDimensions)
|
||||
}
|
||||
|
||||
resolve(imageDimensions)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (image.uri) {
|
||||
const source = image as ImageURISource
|
||||
|
||||
const cacheKey = source.uri as string
|
||||
|
||||
const imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||
|
||||
if (imageDimensions) {
|
||||
resolve(imageDimensions)
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user