Merge remote-tracking branch 'origin/main' into eric/app-864-integrate-post-tags-into-app

* origin/main: (40 commits)
  1.52
  README: tweaks to high-level context (#1625)
  Fix stuck lightbox header after double tap (#1627)
  Fix: add padding to the spinner bottom while loading threads (#1626)
  Rewrite Android lightbox (#1624)
  Dont trim before posting (close #1621) (#1622)
  Only listen to back button on android (#1623)
  Improve typeahead search with inclusion of followed users (temporary solution) (#1612)
  Slightly smaller highlighted post text (#1608)
  Pull upstream bugfixes to bottom-sheet (#1606)
  Fix animations and gestures getting reset on state updates in the lightbox (#1618)
  Remove unused lightbox options (#1616)
  Profile UI tweaks (#1607)
  Fix invite codes flash on desktop, use loading placeholder (#1591)
  Update to react-native@0.72.5 (#1599)
  Fixed a typo on the onboarding recommended screen (#1604)
  Onboarding & feed fixes (#1602)
  Improve time to content in the search page (#1603)
  Fix a potential reference error in bottombarweb (#1600)
  Fix: only use scroll-positioning control on thread when looking at replies (#1587)
  ...
This commit is contained in:
Eric Bailey
2023-10-09 11:02:43 -05:00
108 changed files with 3705 additions and 2167 deletions
+18 -9
View File
@@ -1,24 +1,33 @@
# Bluesky Social App # Bluesky Social App
Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com). Welcome friends! This is the codebase for the Bluesky Social app.
Get the app itself:
- **Web: [bsky.app](https://bsky.app)** - **Web: [bsky.app](https://bsky.app)**
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)** - **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)** - **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
Links: ## Development Resources
- [Build instructions](./docs/build.md) This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
- [ATProto repo](https://github.com/bluesky-social/atproto)
- [ATProto docs](https://atproto.com)
## Rules & guidelines There is a small about of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
--- The [Build Instructions](./docs/builds.md) are a good place to get started with the app itself.
️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at:
--- - [Overview and Guides](https://atproto.com/guides/overview)
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [Protocol Specifications](https://atproto.com/specs/atp)
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
## Contributions
> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
**Rules:** **Rules:**
+98
View File
@@ -0,0 +1,98 @@
import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
['http://example.com', 'example.com/page', false],
['http://example.com', '', true],
['http://example.com', 'other.com', true],
['http://example.com', 'http://other.com', true],
['http://example.com', 'some label', true],
['http://example.com', 'example.com more', true],
['http://example.com', 'http://example.co', true],
['http://example.co', 'http://example.com', true],
['http://example.com', 'example.co', true],
['http://example.co', 'example.com', true],
['http://site.pages.dev', 'http://site.page', true],
['http://site.page', 'http://site.pages.dev', true],
['http://site.pages.dev', 'site.page', true],
['http://site.page', 'site.pages.dev', true],
['http://site.pages.dev', 'http://site.pages', true],
['http://site.pages', 'http://site.pages.dev', true],
['http://site.pages.dev', 'site.pages', true],
['http://site.pages', 'site.pages.dev', true],
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
]
it.each(cases)(
'given input uri %p and text %p, returns %p',
(uri, text, expected) => {
const output = linkRequiresWarning(uri, text)
expect(output).toEqual(expected)
},
)
})
describe('isPossiblyAUrl', () => {
type Case = [string, boolean]
const cases: Case[] = [
['', false],
['text', false],
['some text', false],
['some text', false],
['some domain.com', false],
['domain.com', true],
[' domain.com', true],
['domain.com ', true],
[' domain.com ', true],
['http://domain.com', true],
[' http://domain.com', true],
['http://domain.com ', true],
[' http://domain.com ', true],
['https://domain.com', true],
[' https://domain.com', true],
['https://domain.com ', true],
[' https://domain.com ', true],
['http://domain.com/foo', true],
['http://domain.com stuff', true],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
const output = isPossiblyAUrl(str)
expect(output).toEqual(expected)
})
})
describe('splitApexDomain', () => {
type Case = [string, string, string]
const cases: Case[] = [
['', '', ''],
['example.com', '', 'example.com'],
['foo.example.com', 'foo.', 'example.com'],
['foo.bar.example.com', 'foo.bar.', 'example.com'],
['example.co.uk', '', 'example.co.uk'],
['foo.example.co.uk', 'foo.', 'example.co.uk'],
['example.nonsense', '', 'example.nonsense'],
['foo.example.nonsense', '', 'foo.example.nonsense'],
['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'],
['example.com.example.com', 'example.com.', 'example.com'],
]
it.each(cases)(
'given input uri %p, returns %p,%p',
(str, expected1, expected2) => {
const output = splitApexDomain(str)
expect(output[0]).toEqual(expected1)
expect(output[1]).toEqual(expected2)
},
)
})
+3 -3
View File
@@ -6,7 +6,7 @@ module.exports = function () {
slug: 'bluesky', slug: 'bluesky',
scheme: 'bluesky', scheme: 'bluesky',
owner: 'blueskysocial', owner: 'blueskysocial',
version: '1.51.0', version: '1.52.0',
runtimeVersion: { runtimeVersion: {
policy: 'appVersion', policy: 'appVersion',
}, },
@@ -19,7 +19,7 @@ module.exports = function () {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
ios: { ios: {
buildNumber: '5', buildNumber: '1',
supportsTablet: false, supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app', bundleIdentifier: 'xyz.blueskyweb.app',
config: { config: {
@@ -43,7 +43,7 @@ module.exports = function () {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
android: { android: {
versionCode: 39, versionCode: 40,
adaptiveIcon: { adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png', foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
+3
View File
@@ -8,11 +8,13 @@
- brew tap wix/brew - brew tap wix/brew
- brew install applesimutils - brew install applesimutils
- After initial setup: - After initial setup:
- Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required)
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change - `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
- Start the dev servers - Start the dev servers
- `git clone git@github.com:bluesky-social/atproto.git` - `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto` - `cd atproto`
- `pnpm i` - `pnpm i`
- `pnpm build`
- `cd packages/dev-env && pnpm start` - `cd packages/dev-env && pnpm start`
- Run the dev app - Run the dev app
- iOS: `yarn ios` - iOS: `yarn ios`
@@ -119,6 +121,7 @@ upload-sourcemaps \
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map` dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
### OTA updates ### OTA updates
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/) To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/) The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
+41
View File
@@ -0,0 +1,41 @@
{
"project_info": {
"project_id": "blueskyweb-example",
"project_number": "100000000000",
"firebase_url": "https://blueskyweb-example.firebaseio.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063",
"android_client_info": {
"package_name": "xyz.blueskyweb.app"
}
},
"oauth_client": [
{
"client_id": "123456789000.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "123456789000"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
}
],
"configuration_version": "1"
}
+6 -4
View File
@@ -25,7 +25,7 @@
"build:apk": "eas build -p android --profile dev-android-apk" "build:apk": "eas build -p android --profile dev-android-apk"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.6.19", "@atproto/api": "^0.6.20",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -35,7 +35,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0", "@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4.4.7", "@gorhom/bottom-sheet": "^4.5.1",
"@mattermost/react-native-paste-input": "^0.6.4", "@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1", "@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2", "@react-native-async-storage/async-storage": "1.18.2",
@@ -45,7 +45,7 @@
"@react-native-community/datetimepicker": "7.2.0", "@react-native-community/datetimepicker": "7.2.0",
"@react-native-menu/menu": "^0.8.0", "@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.4.10", "@react-native-picker/picker": "2.4.10",
"@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/bottom-tabs": "^6.5.9",
"@react-navigation/drawer": "^6.6.2", "@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6", "@react-navigation/native": "^6.1.6",
"@react-navigation/native-stack": "^6.9.12", "@react-navigation/native-stack": "^6.9.12",
@@ -116,11 +116,12 @@
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"patch-package": "^6.5.1", "patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0", "react": "18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-native": "0.72.4", "react-native": "0.72.5",
"react-native-appstate-hook": "^1.0.6", "react-native-appstate-hook": "^1.0.6",
"react-native-draggable-flatlist": "^4.0.1", "react-native-draggable-flatlist": "^4.0.1",
"react-native-drawer-layout": "^3.2.0", "react-native-drawer-layout": "^3.2.0",
@@ -176,6 +177,7 @@
"@types/lodash.samplesize": "^4.2.7", "@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7", "@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7", "@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0", "@types/react-avatar-editor": "^13.0.0",
"@types/react-responsive": "^8.0.5", "@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1", "@types/react-test-renderer": "^17.0.1",
+1
View File
@@ -246,6 +246,7 @@ function TabsNavigator() {
), ),
[], [],
) )
return ( return (
<Tab.Navigator <Tab.Navigator
initialRouteName="HomeTab" initialRouteName="HomeTab"
+1 -1
View File
@@ -95,7 +95,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
| undefined | undefined
let reply let reply
let rt = new RichText( let rt = new RichText(
{text: opts.rawText.trim()}, {text: opts.rawText.trimEnd()},
{ {
cleanNewlines: true, cleanNewlines: true,
}, },
+3 -10
View File
@@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS(
serviceUrl: string, serviceUrl: string,
resolveHandle: (name: string) => Promise<string>, resolveHandle: (name: string) => Promise<string>,
) { ) {
// TODO: remove this when the test suite no longer relies on it
if (IS_LOCAL_DEV(serviceUrl)) { if (IS_LOCAL_DEV(serviceUrl)) {
// local dev // local dev
const aliceDid = await resolveHandle('alice.test') const aliceDid = await resolveHandle('alice.test')
@@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS(
} else { } else {
// production // production
return { return {
pinned: [ pinned: [PROD_DEFAULT_FEED('whats-hot')],
PROD_DEFAULT_FEED('whats-hot'), saved: [PROD_DEFAULT_FEED('whats-hot')],
PROD_DEFAULT_FEED('with-friends'),
],
saved: [
PROD_DEFAULT_FEED('bsky-team'),
PROD_DEFAULT_FEED('with-friends'),
PROD_DEFAULT_FEED('whats-hot'),
PROD_DEFAULT_FEED('hot-classic'),
],
} }
} }
} }
+41
View File
@@ -0,0 +1,41 @@
import {useCallback, useState} from 'react'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {AccountData} from 'state/models/session'
import {reset as resetNavigation} from '../../Navigation'
import * as Toast from 'view/com/util/Toast'
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, setIsSwitching] = useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressSwitchAccount = useCallback(
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
},
[track, setIsSwitching, navigation, store],
)
return [isSwitching, setIsSwitching, onPressSwitchAccount]
}
@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
export function useFollowDid({did}: {did: string}) { export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
const store = useStores() const store = useStores()
const state = store.me.follows.getFollowState(did) const state = store.me.follows.getFollowState(profile.did)
return { return {
state, state,
@@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) {
toggle: React.useCallback(async () => { toggle: React.useCallback(async () => {
if (state === FollowState.Following) { if (state === FollowState.Following) {
try { try {
await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) await store.agent.deleteFollow(
store.me.follows.removeFollow(did) store.me.follows.getFollowUri(profile.did),
)
store.me.follows.removeFollow(profile.did)
return { return {
state: FollowState.NotFollowing, state: FollowState.NotFollowing,
following: false, following: false,
@@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) {
} }
} else if (state === FollowState.NotFollowing) { } else if (state === FollowState.NotFollowing) {
try { try {
const res = await store.agent.follow(did) const res = await store.agent.follow(profile.did)
store.me.follows.addFollow(did, res.uri) store.me.follows.addFollow(profile.did, {
followRecordUri: res.uri,
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
})
return { return {
state: FollowState.Following, state: FollowState.Following,
following: true, following: true,
@@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) {
state: FollowState.Unknown, state: FollowState.Unknown,
following: false, following: false,
} }
}, [store, did, state]), }, [store, profile, state]),
} }
} }
+14 -10
View File
@@ -2,12 +2,18 @@ import {useState, useCallback, useRef} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection' import {useWebMediaQueries} from './useWebMediaQueries'
const DY_LIMIT_UP = isDesktopWeb ? 30 : 10
const DY_LIMIT_DOWN = isDesktopWeb ? 150 : 10
const Y_LIMIT = 10 const Y_LIMIT = 10
const useDeviceLimits = () => {
const {isDesktop} = useWebMediaQueries()
return {
dyLimitUp: isDesktop ? 30 : 10,
dyLimitDown: isDesktop ? 150 : 10,
}
}
export type OnScrollCb = ( export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>, event: NativeSyntheticEvent<NativeScrollEvent>,
) => void ) => void
@@ -18,6 +24,8 @@ export function useOnMainScroll(
): [OnScrollCb, boolean, ResetCb] { ): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0) let lastY = useRef(0)
let [isScrolledDown, setIsScrolledDown] = useState(false) let [isScrolledDown, setIsScrolledDown] = useState(false)
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
return [ return [
useCallback( useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -25,15 +33,11 @@ export function useOnMainScroll(
const dy = y - (lastY.current || 0) const dy = y - (lastY.current || 0)
lastY.current = y lastY.current = y
if ( if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
!store.shell.minimalShellMode &&
dy > DY_LIMIT_DOWN &&
y > Y_LIMIT
) {
store.shell.setMinimalShellMode(true) store.shell.setMinimalShellMode(true)
} else if ( } else if (
store.shell.minimalShellMode && store.shell.minimalShellMode &&
(dy < DY_LIMIT_UP * -1 || y <= Y_LIMIT) (dy < dyLimitUp * -1 || y <= Y_LIMIT)
) { ) {
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
} }
@@ -50,7 +54,7 @@ export function useOnMainScroll(
setIsScrolledDown(false) setIsScrolledDown(false)
} }
}, },
[store, isScrolledDown], [store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
), ),
isScrolledDown, isScrolledDown,
useCallback(() => { useCallback(() => {
+14 -3
View File
@@ -1,8 +1,19 @@
import {isAndroid} from 'platform/detection'
import {BackHandler} from 'react-native' import {BackHandler} from 'react-native'
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
export function init(store: RootStoreModel) { export function init(store: RootStoreModel) {
BackHandler.addEventListener('hardwareBackPress', () => { // only register back handler on android, otherwise it throws an error
return store.shell.closeAnyActiveElement() if (isAndroid) {
}) const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
return store.shell.closeAnyActiveElement()
},
)
return () => {
backHandler.remove()
}
}
return () => {}
} }
+17
View File
@@ -15,3 +15,20 @@ export function enforceLen(str: string, len: number, ellipsis = false): string {
} }
return str return str
} }
// https://stackoverflow.com/a/52171480
export function toHashCode(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
}
+51
View File
@@ -1,6 +1,7 @@
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index' import {PROD_SERVICE} from 'state/index'
import TLDs from 'tlds' import TLDs from 'tlds'
import psl from 'psl'
export function isValidDomain(str: string): boolean { export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => { return !!TLDs.find(tld => {
@@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined {
} }
return videoId return videoId
} }
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
if (!labelDomain) {
return true
}
try {
const urip = new URL(uri)
return labelDomain !== urip.hostname
} catch {
return true
}
}
function labelToDomain(label: string): string | undefined {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname
} catch {}
try {
return new URL('https://' + label).hostname
} catch {}
return undefined
}
export function isPossiblyAUrl(str: string): boolean {
str = str.trim()
if (str.startsWith('http://')) {
return true
}
if (str.startsWith('https://')) {
return true
}
const [firstWord] = str.split(/[\s\/]/)
return isValidDomain(firstWord)
}
export function splitApexDomain(hostname: string): [string, string] {
const hostnamep = psl.parse(hostname)
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
return ['', hostname]
}
return [
hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
hostnamep.domain,
]
}
+2 -2
View File
@@ -264,8 +264,8 @@ export const defaultTheme: Theme = {
fontWeight: '400', fontWeight: '400',
}, },
'post-text-lg': { 'post-text-lg': {
fontSize: 22, fontSize: 20,
letterSpacing: 0.4, letterSpacing: 0.2,
fontWeight: '400', fontWeight: '400',
}, },
'button-lg': { 'button-lg': {
-1
View File
@@ -12,7 +12,6 @@ export const isMobileWeb =
isWeb && isWeb &&
// @ts-ignore we know window exists -prf // @ts-ignore we know window exists -prf
global.window.matchMedia(isMobileWebMediaQuery)?.matches global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const isDesktopWeb = isWeb && !isMobileWeb
export const deviceLocales = dedupArray( export const deviceLocales = dedupArray(
getLocales?.().map?.(locale => locale.languageCode), getLocales?.().map?.(locale => locale.languageCode),
+69 -34
View File
@@ -1,7 +1,14 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {AppBskyActorDefs} from '@atproto/api' import {
AppBskyActorDefs,
AppBskyGraphGetFollows as GetFollows,
moderateProfile,
} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
const MAX_SYNC_PAGES = 10
const SYNC_TTL = 60e3 * 10 // 10 minutes
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
export enum FollowState { export enum FollowState {
@@ -10,6 +17,14 @@ export enum FollowState {
Unknown, Unknown,
} }
export interface FollowInfo {
did: string
followRecordUri: string | undefined
handle: string
displayName: string | undefined
avatar: string | undefined
}
/** /**
* This model is used to maintain a synced local cache of the user's * This model is used to maintain a synced local cache of the user's
* follows. It should be periodically refreshed and updated any time * follows. It should be periodically refreshed and updated any time
@@ -17,9 +32,8 @@ export enum FollowState {
*/ */
export class MyFollowsCache { export class MyFollowsCache {
// data // data
followDidToRecordMap: Record<string, string | boolean> = {} byDid: Record<string, FollowInfo> = {}
lastSync = 0 lastSync = 0
myDid?: string
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@@ -35,16 +49,45 @@ export class MyFollowsCache {
// = // =
clear() { clear() {
this.followDidToRecordMap = {} this.byDid = {}
this.lastSync = 0 }
this.myDid = undefined
/**
* Syncs a subset of the user's follows
* for performance reasons, caps out at 1000 follows
*/
async syncIfNeeded() {
if (this.lastSync > Date.now() - SYNC_TTL) {
return
}
let cursor
for (let i = 0; i < MAX_SYNC_PAGES; i++) {
const res: GetFollows.Response = await this.rootStore.agent.getFollows({
actor: this.rootStore.me.did,
cursor,
limit: 100,
})
res.data.follows = res.data.follows.filter(
profile =>
!moderateProfile(profile, this.rootStore.preferences.moderationOpts)
.account.filter,
)
this.hydrateMany(res.data.follows)
if (!res.data.cursor) {
break
}
cursor = res.data.cursor
}
this.lastSync = Date.now()
} }
getFollowState(did: string): FollowState { getFollowState(did: string): FollowState {
if (typeof this.followDidToRecordMap[did] === 'undefined') { if (typeof this.byDid[did] === 'undefined') {
return FollowState.Unknown return FollowState.Unknown
} }
if (typeof this.followDidToRecordMap[did] === 'string') { if (typeof this.byDid[did].followRecordUri === 'string') {
return FollowState.Following return FollowState.Following
} }
return FollowState.NotFollowing return FollowState.NotFollowing
@@ -53,49 +96,41 @@ export class MyFollowsCache {
async fetchFollowState(did: string): Promise<FollowState> { async fetchFollowState(did: string): Promise<FollowState> {
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
const res = await this.rootStore.agent.getProfile({actor: did}) const res = await this.rootStore.agent.getProfile({actor: did})
if (res.data.viewer?.following) { this.hydrate(did, res.data)
this.addFollow(did, res.data.viewer.following)
} else {
this.removeFollow(did)
}
return this.getFollowState(did) return this.getFollowState(did)
} }
getFollowUri(did: string): string { getFollowUri(did: string): string {
const v = this.followDidToRecordMap[did] const v = this.byDid[did]
if (typeof v === 'string') { if (typeof v === 'string') {
return v return v
} }
throw new Error('Not a followed user') throw new Error('Not a followed user')
} }
addFollow(did: string, recordUri: string) { addFollow(did: string, info: FollowInfo) {
this.followDidToRecordMap[did] = recordUri this.byDid[did] = info
} }
removeFollow(did: string) { removeFollow(did: string) {
this.followDidToRecordMap[did] = false if (this.byDid[did]) {
} this.byDid[did].followRecordUri = undefined
/**
* Use this to incrementally update the cache as views provide information
*/
hydrate(did: string, recordUri: string | undefined) {
if (recordUri) {
this.followDidToRecordMap[did] = recordUri
} else {
this.followDidToRecordMap[did] = false
} }
} }
/** hydrate(did: string, profile: Profile) {
* Use this to incrementally update the cache as views provide information this.byDid[did] = {
*/ did,
hydrateProfiles(profiles: Profile[]) { followRecordUri: profile.viewer?.following,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
}
}
hydrateMany(profiles: Profile[]) {
for (const profile of profiles) { for (const profile of profiles) {
if (profile.viewer) { this.hydrate(profile.did, profile)
this.hydrate(profile.did, profile.viewer.following)
}
} }
} }
} }
+8
View File
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost,
PostModeration, PostModeration,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
@@ -76,6 +77,13 @@ export class PostThreadModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri) return this.rootStore.mutedThreads.uris.has(this.rootUri)
} }
get isCachedPostAReply() {
if (AppBskyFeedPost.isRecord(this.thread?.post.record)) {
return !!this.thread?.post.record.reply
}
return false
}
// public api // public api
// = // =
+2 -2
View File
@@ -137,7 +137,7 @@ export class ProfileModel {
runInAction(() => { runInAction(() => {
this.followersCount++ this.followersCount++
this.viewer.following = res.uri this.viewer.following = res.uri
this.rootStore.me.follows.addFollow(this.did, res.uri) this.rootStore.me.follows.hydrate(this.did, this)
}) })
track('Profile:Follow', { track('Profile:Follow', {
username: this.handle, username: this.handle,
@@ -290,8 +290,8 @@ export class ProfileModel {
this.labels = res.data.labels this.labels = res.data.labels
if (res.data.viewer) { if (res.data.viewer) {
Object.assign(this.viewer, res.data.viewer) Object.assign(this.viewer, res.data.viewer)
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
} }
this.rootStore.me.follows.hydrate(this.did, res.data)
} }
async _createRichText() { async _createRichText() {
+5 -31
View File
@@ -1,8 +1,4 @@
import { import {AppBskyActorDefs} from '@atproto/api'
AppBskyActorDefs,
AppBskyGraphGetFollows as GetFollows,
moderateProfile,
} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import sampleSize from 'lodash.samplesize' import sampleSize from 'lodash.samplesize'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
@@ -43,35 +39,13 @@ export class FoafsModel {
try { try {
this.isLoading = true this.isLoading = true
// fetch & hydrate up to 1000 follows // fetch some of the user's follows
{ await this.rootStore.me.follows.syncIfNeeded()
let cursor
for (let i = 0; i < 10; i++) {
const res: GetFollows.Response =
await this.rootStore.agent.getFollows({
actor: this.rootStore.me.did,
cursor,
limit: 100,
})
res.data.follows = res.data.follows.filter(
profile =>
!moderateProfile(
profile,
this.rootStore.preferences.moderationOpts,
).account.filter,
)
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
if (!res.data.cursor) {
break
}
cursor = res.data.cursor
}
}
// grab 10 of the users followed by the user // grab 10 of the users followed by the user
runInAction(() => { runInAction(() => {
this.sources = sampleSize( this.sources = sampleSize(
Object.keys(this.rootStore.me.follows.followDidToRecordMap), Object.keys(this.rootStore.me.follows.byDid),
10, 10,
) )
}) })
@@ -100,7 +74,7 @@ export class FoafsModel {
for (let i = 0; i < results.length; i++) { for (let i = 0; i < results.length; i++) {
const res = results[i] const res = results[i]
if (res.status === 'fulfilled') { if (res.status === 'fulfilled') {
this.rootStore.me.follows.hydrateProfiles(res.value.data.follows) this.rootStore.me.follows.hydrateMany(res.value.data.follows)
} }
const profile = profiles.data.profiles[i] const profile = profiles.data.profiles[i]
const source = this.sources[i] const source = this.sources[i]
+1
View File
@@ -81,6 +81,7 @@ export class OnboardingModel {
} }
finish() { finish() {
this.rootStore.me.mainFeed.refresh() // load the selected content
this.step = 'Home' this.step = 'Home'
track('Onboarding:Complete') track('Onboarding:Complete')
} }
@@ -76,7 +76,7 @@ export class SuggestedActorsModel {
!moderateProfile(actor, this.rootStore.preferences.moderationOpts) !moderateProfile(actor, this.rootStore.preferences.moderationOpts)
.account.filter, .account.filter,
) )
this.rootStore.me.follows.hydrateProfiles(actors) this.rootStore.me.follows.hydrateMany(actors)
runInAction(() => { runInAction(() => {
if (replace) { if (replace) {
@@ -118,7 +118,7 @@ export class SuggestedActorsModel {
actor: actor, actor: actor,
}) })
const {suggestions: moreSuggestions} = res.data const {suggestions: moreSuggestions} = res.data
this.rootStore.me.follows.hydrateProfiles(moreSuggestions) this.rootStore.me.follows.hydrateMany(moreSuggestions)
// dedupe // dedupe
const toInsert = moreSuggestions.filter( const toInsert = moreSuggestions.filter(
s => !this.suggestions.find(s2 => s2.did === s.did), s => !this.suggestions.find(s2 => s2.did === s.did),
+77 -40
View File
@@ -4,6 +4,8 @@ import AwaitLock from 'await-lock'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {isInvalidHandle} from 'lib/strings/handles' import {isInvalidHandle} from 'lib/strings/handles'
type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
export class UserAutocompleteModel { export class UserAutocompleteModel {
// state // state
isLoading = false isLoading = false
@@ -12,9 +14,8 @@ export class UserAutocompleteModel {
lock = new AwaitLock() lock = new AwaitLock()
// data // data
follows: AppBskyActorDefs.ProfileViewBasic[] = []
searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
knownHandles: Set<string> = new Set() knownHandles: Set<string> = new Set()
_suggestions: ProfileViewBasic[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@@ -27,29 +28,35 @@ export class UserAutocompleteModel {
) )
} }
get suggestions() { get follows(): ProfileViewBasic[] {
return Object.values(this.rootStore.me.follows.byDid).map(item => ({
did: item.did,
handle: item.handle,
displayName: item.displayName,
avatar: item.avatar,
}))
}
get suggestions(): ProfileViewBasic[] {
if (!this.isActive) { if (!this.isActive) {
return [] return []
} }
if (this.prefix) { return this._suggestions
return this.searchRes.map(user => ({
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar,
}))
}
return this.follows.map(follow => ({
handle: follow.handle,
displayName: follow.displayName,
avatar: follow.avatar,
}))
} }
// public api // public api
// = // =
async setup() { async setup() {
await this._getFollows() await this.rootStore.me.follows.syncIfNeeded()
runInAction(() => {
for (const did in this.rootStore.me.follows.byDid) {
const info = this.rootStore.me.follows.byDid[did]
if (!isInvalidHandle(info.handle)) {
this.knownHandles.add(info.handle)
}
}
})
} }
setActive(v: boolean) { setActive(v: boolean) {
@@ -57,7 +64,7 @@ export class UserAutocompleteModel {
} }
async setPrefix(prefix: string) { async setPrefix(prefix: string) {
const origPrefix = prefix.trim() const origPrefix = prefix.trim().toLocaleLowerCase()
this.prefix = origPrefix this.prefix = origPrefix
await this.lock.acquireAsync() await this.lock.acquireAsync()
try { try {
@@ -65,9 +72,27 @@ export class UserAutocompleteModel {
if (this.prefix !== origPrefix) { if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance return // another prefix was set before we got our chance
} }
await this._search()
// reset to follow results
this._computeSuggestions([])
// ask backend
const res = await this.rootStore.agent.searchActorsTypeahead({
term: this.prefix,
limit: 8,
})
this._computeSuggestions(res.data.actors)
// update known handles
runInAction(() => {
for (const u of res.data.actors) {
this.knownHandles.add(u.handle)
}
})
} else { } else {
this.searchRes = [] runInAction(() => {
this._computeSuggestions([])
})
} }
} finally { } finally {
this.lock.release() this.lock.release()
@@ -77,28 +102,40 @@ export class UserAutocompleteModel {
// internal // internal
// = // =
async _getFollows() { _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
const res = await this.rootStore.agent.getFollows({ if (this.prefix) {
actor: this.rootStore.me.did || '', const items: ProfileViewBasic[] = []
}) for (const item of this.follows) {
runInAction(() => { if (prefixMatch(this.prefix, item)) {
this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle)) items.push(item)
for (const f of this.follows) { }
this.knownHandles.add(f.handle) if (items.length >= 8) {
break
}
} }
}) for (const item of searchRes) {
} if (!items.find(item2 => item2.handle === item.handle)) {
items.push({
async _search() { did: item.did,
const res = await this.rootStore.agent.searchActorsTypeahead({ handle: item.handle,
term: this.prefix, displayName: item.displayName,
limit: 8, avatar: item.avatar,
}) })
runInAction(() => { }
this.searchRes = res.data.actors
for (const u of this.searchRes) {
this.knownHandles.add(u.handle)
} }
}) this._suggestions = items
} else {
this._suggestions = this.follows
}
} }
} }
function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
if (info.handle.includes(prefix)) {
return true
}
if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
return true
}
return false
}
+6 -2
View File
@@ -116,6 +116,10 @@ export class PostsFeedModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get isLoadingMore() {
return this.isLoading && !this.isRefreshing
}
setHasNewLatest(v: boolean) { setHasNewLatest(v: boolean) {
this.hasNewLatest = v this.hasNewLatest = v
} }
@@ -307,12 +311,12 @@ export class PostsFeedModel {
} }
async _appendAll(res: FeedAPIResponse, replace = false) { async _appendAll(res: FeedAPIResponse, replace = false) {
this.hasMore = !!res.cursor this.hasMore = !!res.cursor && res.feed.length > 0
if (replace) { if (replace) {
this.emptyFetches = 0 this.emptyFetches = 0
} }
this.rootStore.me.follows.hydrateProfiles( this.rootStore.me.follows.hydrateMany(
res.feed.map(item => item.post.author), res.feed.map(item => item.post.author),
) )
for (const item of res.feed) { for (const item of res.feed) {
+1 -1
View File
@@ -61,7 +61,7 @@ export class InvitedUsers {
profile => !profile.viewer?.following, profile => !profile.viewer?.following,
) )
}) })
this.rootStore.me.follows.hydrateProfiles(this.profiles) this.rootStore.me.follows.hydrateMany(this.profiles)
} catch (e) { } catch (e) {
this.rootStore.log.error( this.rootStore.log.error(
'Failed to fetch profiles for invited users', 'Failed to fetch profiles for invited users',
+1 -1
View File
@@ -126,7 +126,7 @@ export class LikesModel {
_appendAll(res: GetLikes.Response) { _appendAll(res: GetLikes.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.rootStore.me.follows.hydrateProfiles( this.rootStore.me.follows.hydrateMany(
res.data.likes.map(like => like.actor), res.data.likes.map(like => like.actor),
) )
this.likes = this.likes.concat(res.data.likes) this.likes = this.likes.concat(res.data.likes)
+1 -1
View File
@@ -130,6 +130,6 @@ export class RepostedByModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy) this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) this.rootStore.me.follows.hydrateMany(res.data.repostedBy)
} }
} }
+1 -1
View File
@@ -115,6 +115,6 @@ export class UserFollowersModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers) this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateProfiles(res.data.followers) this.rootStore.me.follows.hydrateMany(res.data.followers)
} }
} }
+1 -1
View File
@@ -115,6 +115,6 @@ export class UserFollowsModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows) this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateProfiles(res.data.follows) this.rootStore.me.follows.hydrateMany(res.data.follows)
} }
} }
+5 -3
View File
@@ -25,13 +25,13 @@ export class MeModel {
savedFeeds: SavedFeedsModel savedFeeds: SavedFeedsModel
notifications: NotificationsFeedModel notifications: NotificationsFeedModel
follows: MyFollowsCache follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = [] invites: ComAtprotoServerDefs.InviteCode[] | null = []
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
lastProfileStateUpdate = Date.now() lastProfileStateUpdate = Date.now()
lastNotifsUpdate = Date.now() lastNotifsUpdate = Date.now()
get invitesAvailable() { get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length return this.invites?.filter(isInviteAvailable).length || null
} }
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
@@ -180,7 +180,9 @@ export class MeModel {
} catch (e) { } catch (e) {
this.rootStore.log.error('Failed to fetch user invite codes', e) this.rootStore.log.error('Failed to fetch user invite codes', e)
} }
await this.rootStore.invitedUsers.fetch(this.invites) if (this.invites) {
await this.rootStore.invitedUsers.fetch(this.invites)
}
} }
} }
+6
View File
@@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes' import {ImageSizesCache} from './cache/image-sizes'
import {MutedThreads} from './muted-threads' import {MutedThreads} from './muted-threads'
import {Reminders} from './ui/reminders'
import {reset as resetNavigation} from '../../Navigation' import {reset as resetNavigation} from '../../Navigation'
import {RecentTagsModel} from './ui/tags-autocomplete' import {RecentTagsModel} from './ui/tags-autocomplete'
@@ -54,6 +55,7 @@ export class RootStoreModel {
linkMetas = new LinkMetasCache(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache() imageSizes = new ImageSizesCache()
mutedThreads = new MutedThreads() mutedThreads = new MutedThreads()
reminders = new Reminders(this)
recentTags = new RecentTagsModel() recentTags = new RecentTagsModel()
constructor(agent: BskyAgent) { constructor(agent: BskyAgent) {
@@ -79,6 +81,7 @@ export class RootStoreModel {
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(), invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(), mutedThreads: this.mutedThreads.serialize(),
reminders: this.reminders.serialize(),
recentTags: this.recentTags.serialize(), recentTags: this.recentTags.serialize(),
} }
} }
@@ -115,6 +118,9 @@ export class RootStoreModel {
if (hasProp(v, 'recentTags')) { if (hasProp(v, 'recentTags')) {
this.recentTags.hydrate(v.recentTags) this.recentTags.hydrate(v.recentTags)
} }
if (hasProp(v, 'reminders')) {
this.reminders.hydrate(v.reminders)
}
} }
} }
+16
View File
@@ -30,6 +30,7 @@ export const accountData = z.object({
email: z.string().optional(), email: z.string().optional(),
displayName: z.string().optional(), displayName: z.string().optional(),
aviUrl: z.string().optional(), aviUrl: z.string().optional(),
emailConfirmed: z.boolean().optional(),
}) })
export type AccountData = z.infer<typeof accountData> export type AccountData = z.infer<typeof accountData>
@@ -106,6 +107,10 @@ export class SessionModel {
return this.accounts.filter(acct => acct.did !== this.data?.did) return this.accounts.filter(acct => acct.did !== this.data?.did)
} }
get emailNeedsConfirmation() {
return !this.currentSession?.emailConfirmed
}
get isSandbox() { get isSandbox() {
if (!this.data) { if (!this.data) {
return false return false
@@ -217,6 +222,7 @@ export class SessionModel {
? addedInfo.displayName ? addedInfo.displayName
: existingAccount?.displayName || '', : existingAccount?.displayName || '',
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
emailConfirmed: session?.emailConfirmed,
} }
if (!existingAccount) { if (!existingAccount) {
this.accounts.push(newAccount) this.accounts.push(newAccount)
@@ -246,6 +252,8 @@ export class SessionModel {
did: acct.did, did: acct.did,
displayName: acct.displayName, displayName: acct.displayName,
aviUrl: acct.aviUrl, aviUrl: acct.aviUrl,
email: acct.email,
emailConfirmed: acct.emailConfirmed,
})) }))
} }
@@ -297,6 +305,8 @@ export class SessionModel {
refreshJwt: account.refreshJwt || '', refreshJwt: account.refreshJwt || '',
did: account.did, did: account.did,
handle: account.handle, handle: account.handle,
email: account.email,
emailConfirmed: account.emailConfirmed,
}), }),
) )
const addedInfo = await this.loadAccountInfo(agent, account.did) const addedInfo = await this.loadAccountInfo(agent, account.did)
@@ -452,4 +462,10 @@ export class SessionModel {
await this.rootStore.me.load() await this.rootStore.me.load()
} }
} }
updateLocalAccountData(changes: Partial<AccountData>) {
this.accounts = this.accounts.map(acct =>
acct.did === this.data?.did ? {...acct, ...changes} : acct,
)
}
} }
+82 -29
View File
@@ -418,6 +418,7 @@ export class PreferencesModel {
const oldPinned = this.pinnedFeeds const oldPinned = this.pinnedFeeds
this.savedFeeds = saved this.savedFeeds = saved
this.pinnedFeeds = pinned this.pinnedFeeds = pinned
await this.lock.acquireAsync()
try { try {
const res = await cb() const res = await cb()
runInAction(() => { runInAction(() => {
@@ -430,6 +431,8 @@ export class PreferencesModel {
this.pinnedFeeds = oldPinned this.pinnedFeeds = oldPinned
}) })
throw e throw e
} finally {
this.lock.release()
} }
} }
@@ -441,7 +444,7 @@ export class PreferencesModel {
async addSavedFeed(v: string) { async addSavedFeed(v: string) {
return this._optimisticUpdateSavedFeeds( return this._optimisticUpdateSavedFeeds(
[...this.savedFeeds, v], [...this.savedFeeds.filter(uri => uri !== v), v],
this.pinnedFeeds, this.pinnedFeeds,
() => this.rootStore.agent.addSavedFeed(v), () => this.rootStore.agent.addSavedFeed(v),
) )
@@ -457,8 +460,8 @@ export class PreferencesModel {
async addPinnedFeed(v: string) { async addPinnedFeed(v: string) {
return this._optimisticUpdateSavedFeeds( return this._optimisticUpdateSavedFeeds(
this.savedFeeds, [...this.savedFeeds.filter(uri => uri !== v), v],
[...this.pinnedFeeds, v], [...this.pinnedFeeds.filter(uri => uri !== v), v],
() => this.rootStore.agent.addPinnedFeed(v), () => this.rootStore.agent.addPinnedFeed(v),
) )
} }
@@ -473,71 +476,121 @@ export class PreferencesModel {
async setBirthDate(birthDate: Date) { async setBirthDate(birthDate: Date) {
this.birthDate = birthDate this.birthDate = birthDate
await this.rootStore.agent.setPersonalDetails({birthDate}) await this.lock.acquireAsync()
try {
await this.rootStore.agent.setPersonalDetails({birthDate})
} finally {
this.lock.release()
}
} }
async toggleHomeFeedHideReplies() { async toggleHomeFeedHideReplies() {
this.homeFeed.hideReplies = !this.homeFeed.hideReplies this.homeFeed.hideReplies = !this.homeFeed.hideReplies
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
hideReplies: this.homeFeed.hideReplies, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
hideReplies: this.homeFeed.hideReplies,
})
} finally {
this.lock.release()
}
} }
async toggleHomeFeedHideRepliesByUnfollowed() { async toggleHomeFeedHideRepliesByUnfollowed() {
this.homeFeed.hideRepliesByUnfollowed = this.homeFeed.hideRepliesByUnfollowed =
!this.homeFeed.hideRepliesByUnfollowed !this.homeFeed.hideRepliesByUnfollowed
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
})
} finally {
this.lock.release()
}
} }
async setHomeFeedHideRepliesByLikeCount(threshold: number) { async setHomeFeedHideRepliesByLikeCount(threshold: number) {
this.homeFeed.hideRepliesByLikeCount = threshold this.homeFeed.hideRepliesByLikeCount = threshold
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
})
} finally {
this.lock.release()
}
} }
async toggleHomeFeedHideReposts() { async toggleHomeFeedHideReposts() {
this.homeFeed.hideReposts = !this.homeFeed.hideReposts this.homeFeed.hideReposts = !this.homeFeed.hideReposts
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
hideReposts: this.homeFeed.hideReposts, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
hideReposts: this.homeFeed.hideReposts,
})
} finally {
this.lock.release()
}
} }
async toggleHomeFeedHideQuotePosts() { async toggleHomeFeedHideQuotePosts() {
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
hideQuotePosts: this.homeFeed.hideQuotePosts, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
hideQuotePosts: this.homeFeed.hideQuotePosts,
})
} finally {
this.lock.release()
}
} }
async toggleHomeFeedMergeFeedEnabled() { async toggleHomeFeedMergeFeedEnabled() {
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
await this.rootStore.agent.setFeedViewPrefs('home', { await this.lock.acquireAsync()
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, try {
}) await this.rootStore.agent.setFeedViewPrefs('home', {
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
})
} finally {
this.lock.release()
}
} }
async setThreadSort(v: string) { async setThreadSort(v: string) {
if (THREAD_SORT_VALUES.includes(v)) { if (THREAD_SORT_VALUES.includes(v)) {
this.thread.sort = v this.thread.sort = v
await this.rootStore.agent.setThreadViewPrefs({sort: v}) await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({sort: v})
} finally {
this.lock.release()
}
} }
} }
async togglePrioritizedFollowedUsers() { async togglePrioritizedFollowedUsers() {
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
await this.rootStore.agent.setThreadViewPrefs({ await this.lock.acquireAsync()
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, try {
}) await this.rootStore.agent.setThreadViewPrefs({
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
})
} finally {
this.lock.release()
}
} }
async toggleThreadTreeViewEnabled() { async toggleThreadTreeViewEnabled() {
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
await this.rootStore.agent.setThreadViewPrefs({ await this.lock.acquireAsync()
lab_treeViewEnabled: this.thread.lab_treeViewEnabled, try {
}) await this.rootStore.agent.setThreadViewPrefs({
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
})
} finally {
this.lock.release()
}
} }
toggleRequireAltTextEnabled() { toggleRequireAltTextEnabled() {
+64
View File
@@ -0,0 +1,64 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from 'lib/type-guards'
import {RootStoreModel} from '../root-store'
import {toHashCode} from 'lib/strings/helpers'
const DAY = 60e3 * 24 * 1 // 1 day (ms)
export class Reminders {
lastEmailConfirm: Date = new Date()
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{serialize: false, hydrate: false},
{autoBind: true},
)
}
serialize() {
return {
lastEmailConfirm: this.lastEmailConfirm
? this.lastEmailConfirm.toISOString()
: undefined,
}
}
hydrate(v: unknown) {
if (
isObj(v) &&
hasProp(v, 'lastEmailConfirm') &&
typeof v.lastEmailConfirm === 'string'
) {
this.lastEmailConfirm = new Date(v.lastEmailConfirm)
}
}
get shouldRequestEmailConfirmation() {
const sess = this.rootStore.session.currentSession
if (!sess) {
return false
}
if (sess.emailConfirmed) {
return false
}
if (this.rootStore.onboarding.isActive) {
return false
}
const today = new Date()
// shard the users into 2 day of the week buckets
// (this is to avoid a sudden influx of email updates when
// this feature rolls out)
const code = toHashCode(sess.did) % 7
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
return false
}
// only ask once a day at most, but because of the bucketing
// this will be more like weekly
return Number(today) - Number(this.lastEmailConfirm) > DAY
}
setEmailConfirmationRequested() {
this.lastEmailConfirm = new Date()
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ export class SearchUIModel {
} while (profilesSearch.length) } while (profilesSearch.length)
} }
this.rootStore.me.follows.hydrateProfiles(profiles) this.rootStore.me.follows.hydrateMany(profiles)
runInAction(() => { runInAction(() => {
this.profiles = profiles this.profiles = profiles
+34
View File
@@ -24,6 +24,7 @@ export interface ConfirmModal {
onPressCancel?: () => void | Promise<void> onPressCancel?: () => void | Promise<void>
confirmBtnText?: string confirmBtnText?: string
confirmBtnStyle?: StyleProp<ViewStyle> confirmBtnStyle?: StyleProp<ViewStyle>
cancelBtnText?: string
} }
export interface EditProfileModal { export interface EditProfileModal {
@@ -140,6 +141,25 @@ export interface BirthDateSettingsModal {
name: 'birth-date-settings' name: 'birth-date-settings'
} }
export interface VerifyEmailModal {
name: 'verify-email'
showReminder?: boolean
}
export interface ChangeEmailModal {
name: 'change-email'
}
export interface SwitchAccountModal {
name: 'switch-account'
}
export interface LinkWarningModal {
name: 'link-warning'
text: string
href: string
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@@ -148,6 +168,9 @@ export type Modal =
| EditProfileModal | EditProfileModal
| ProfilePreviewModal | ProfilePreviewModal
| BirthDateSettingsModal | BirthDateSettingsModal
| VerifyEmailModal
| ChangeEmailModal
| SwitchAccountModal
// Curation // Curation
| ContentFilteringSettingsModal | ContentFilteringSettingsModal
@@ -174,6 +197,7 @@ export type Modal =
// Generic // Generic
| ConfirmModal | ConfirmModal
| LinkWarningModal
interface LightboxModel {} interface LightboxModel {}
@@ -250,6 +274,7 @@ export class ShellUiModel {
}) })
this.setupClock() this.setupClock()
this.setupLoginModals()
} }
serialize(): unknown { serialize(): unknown {
@@ -375,4 +400,13 @@ export class ShellUiModel {
}) })
}, 60_000) }, 60_000)
} }
setupLoginModals() {
this.rootStore.onSessionReady(() => {
if (this.rootStore.reminders.shouldRequestEmailConfirmation) {
this.openModal({name: 'verify-email', showReminder: true})
this.rootStore.reminders.setEmailConfirmationRequested()
}
})
}
} }
+86 -78
View File
@@ -6,7 +6,8 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views' import {CenteredView} from '../util/Views'
import {isMobileWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
export const SplashScreen = ({ export const SplashScreen = ({
onPressSignin, onPressSignin,
@@ -16,6 +17,9 @@ export const SplashScreen = ({
onPressCreateAccount: () => void onPressCreateAccount: () => void
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries()
const styles = useStyles()
const isMobileWeb = isWeb && isTabletOrMobile
return ( return (
<CenteredView style={[styles.container, pal.view]}> <CenteredView style={[styles.container, pal.view]}>
@@ -55,13 +59,14 @@ export const SplashScreen = ({
</View> </View>
</ErrorBoundary> </ErrorBoundary>
</View> </View>
<Footer /> <Footer styles={styles} />
</CenteredView> </CenteredView>
) )
} }
function Footer() { function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[styles.footer, pal.view, pal.border]}> <View style={[styles.footer, pal.view, pal.border]}>
<TextLink <TextLink
@@ -82,78 +87,81 @@ function Footer() {
</View> </View>
) )
} }
const useStyles = () => {
const styles = StyleSheet.create({ const {isTabletOrMobile} = useWebMediaQueries()
container: { const isMobileWeb = isWeb && isTabletOrMobile
height: '100%', return StyleSheet.create({
}, container: {
containerInner: { height: '100%',
height: '100%', },
justifyContent: 'center', containerInner: {
// @ts-ignore web only height: '100%',
paddingBottom: '20vh', justifyContent: 'center',
paddingHorizontal: 20, // @ts-ignore web only
}, paddingBottom: '20vh',
containerInnerMobile: { paddingHorizontal: 20,
paddingBottom: 50, },
}, containerInnerMobile: {
title: { paddingBottom: 50,
textAlign: 'center', },
color: colors.blue3, title: {
fontSize: 68, textAlign: 'center',
fontWeight: 'bold', color: colors.blue3,
paddingBottom: 10, fontSize: 68,
}, fontWeight: 'bold',
titleMobile: { paddingBottom: 10,
textAlign: 'center', },
color: colors.blue3, titleMobile: {
fontSize: 58, textAlign: 'center',
fontWeight: 'bold', color: colors.blue3,
}, fontSize: 58,
subtitle: { fontWeight: 'bold',
textAlign: 'center', },
color: colors.gray5, subtitle: {
fontSize: 52, textAlign: 'center',
fontWeight: 'bold', color: colors.gray5,
paddingBottom: 30, fontSize: 52,
}, fontWeight: 'bold',
subtitleMobile: { paddingBottom: 30,
textAlign: 'center', },
color: colors.gray5, subtitleMobile: {
fontSize: 42, textAlign: 'center',
fontWeight: 'bold', color: colors.gray5,
paddingBottom: 30, fontSize: 42,
}, fontWeight: 'bold',
btns: { paddingBottom: 30,
flexDirection: isMobileWeb ? 'column' : 'row', },
gap: 20, btns: {
justifyContent: 'center', flexDirection: isMobileWeb ? 'column' : 'row',
paddingBottom: 40, gap: 20,
}, justifyContent: 'center',
btn: { paddingBottom: 40,
borderRadius: 30, },
paddingHorizontal: 24, btn: {
paddingVertical: 12, borderRadius: 30,
minWidth: 220, paddingHorizontal: 24,
}, paddingVertical: 12,
btnLabel: { minWidth: 220,
textAlign: 'center', },
fontSize: 18, btnLabel: {
}, textAlign: 'center',
notice: { fontSize: 18,
paddingHorizontal: 40, },
textAlign: 'center', notice: {
}, paddingHorizontal: 40,
footer: { textAlign: 'center',
position: 'absolute', },
left: 0, footer: {
right: 0, position: 'absolute',
bottom: 0, left: 0,
padding: 20, right: 0,
borderTopWidth: 1, bottom: 0,
flexDirection: 'row', padding: 20,
}, borderTopWidth: 1,
footerLink: { flexDirection: 'row',
marginRight: 20, },
}, footerLink: {
}) marginRight: 20,
},
})
}
@@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
tdStyles.title2, tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small, isTabletOrMobile && tdStyles.title2Small,
]}> ]}>
Recomended Recommended
</Text> </Text>
<Text <Text
style={[ style={[
@@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
} }
} else { } else {
try { try {
await item.save()
await item.pin() await item.pin()
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') Toast.show('There was an issue contacting your server')
@@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
</View> </View>
<FollowButton <FollowButton
did={profile.did} profile={profile}
labelStyle={styles.followButton} labelStyle={styles.followButton}
onToggleFollow={async isFollow => { onToggleFollow={async isFollow => {
if (isFollow) { if (isFollow) {
+22 -5
View File
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
BackHandler,
Keyboard, Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@@ -51,14 +52,10 @@ import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {TagInput} from './TagInput' import {TagInput} from './TagInput'
type Props = ComposerOpts & { type Props = ComposerOpts
onClose: () => void
}
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
replyTo, replyTo,
onPost, onPost,
onClose,
quote: initQuote, quote: initQuote,
mention: initMention, mention: initMention,
}: Props) { }: Props) {
@@ -93,6 +90,9 @@ export const ComposePost = observer(function ComposePost({
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(store), [store]) const gallery = useMemo(() => new GalleryModel(store), [store])
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const onClose = useCallback(() => {
store.shell.closeComposer()
}, [store])
const autocompleteView = useMemo<UserAutocompleteModel>( const autocompleteView = useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store), () => new UserAutocompleteModel(store),
@@ -136,6 +136,23 @@ export const ComposePost = observer(function ComposePost({
onClose() onClose()
} }
}, [store, onClose, graphemeLength, gallery]) }, [store, onClose, graphemeLength, gallery])
// android back button
useEffect(() => {
if (!isAndroid) {
return
}
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
onPressCancel()
return true
},
)
return () => {
backHandler.remove()
}
}, [onPressCancel])
// initial setup // initial setup
useEffect(() => { useEffect(() => {
@@ -7,11 +7,11 @@ import {
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker' import {openCamera} from 'lib/media/picker'
import {useCameraPermission} from 'lib/hooks/usePermissions' import {useCameraPermission} from 'lib/hooks/usePermissions'
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants' import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
import {isMobileWeb, isNative} from 'platform/detection'
type Props = { type Props = {
gallery: GalleryModel gallery: GalleryModel
@@ -43,7 +43,8 @@ export function OpenCameraBtn({gallery}: Props) {
} }
}, [gallery, track, store, requestCameraAccessIfNeeded]) }, [gallery, track, store, requestCameraAccessIfNeeded])
if (isDesktopWeb) { const shouldShowCameraButton = isNative || isMobileWeb
if (!shouldShowCameraButton) {
return null return null
} }
@@ -6,10 +6,10 @@ import {
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {isDesktopWeb} from 'platform/detection'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
import {HITSLOP_10} from 'lib/constants' import {HITSLOP_10} from 'lib/constants'
import {isNative} from 'platform/detection'
type Props = { type Props = {
gallery: GalleryModel gallery: GalleryModel
@@ -23,12 +23,12 @@ export function SelectPhotoBtn({gallery}: Props) {
const onPressSelectPhotos = useCallback(async () => { const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened') track('Composer:GalleryOpened')
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) { if (isNative && !(await requestPhotoAccessIfNeeded())) {
return return
} }
gallery.pick() gallery.pick()
}, [track, gallery, requestPhotoAccessIfNeeded]) }, [track, requestPhotoAccessIfNeeded, gallery])
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -132,7 +132,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
onUpdate({editor: editorProp}) { onUpdate({editor: editorProp}) {
const json = editorProp.getJSON() const json = editorProp.getJSON()
const newRt = new RichText({text: editorJsonToText(json).trim()}) const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
newRt.detectFacetsWithoutResolution() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
@@ -1,157 +1,403 @@
/** import React, {MutableRefObject, useState} from 'react'
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback, useRef, useState} from 'react' import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {
Animated,
ScrollView,
Dimensions,
StyleSheet,
NativeScrollEvent,
NativeSyntheticEvent,
NativeMethodsMixin,
} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import Animated, {
measure,
runOnJS,
useAnimatedRef,
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {
GestureDetector,
Gesture,
GestureType,
} from 'react-native-gesture-handler'
import useImageDimensions from '../../hooks/useImageDimensions' import useImageDimensions from '../../hooks/useImageDimensions'
import usePanResponder from '../../hooks/usePanResponder' import {
createTransform,
readTransform,
applyRounding,
prependPan,
prependPinch,
prependTransform,
TransformMatrix,
} from '../../transforms'
import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
import {getImageStyles, getImageTransform} from '../../utils'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1.75
const SCREEN = Dimensions.get('window') const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width const MIN_DOUBLE_TAP_SCALE = 2
const SCREEN_HEIGHT = SCREEN.height const MAX_ORIGINAL_IMAGE_ZOOM = 2
const AnimatedImage = Animated.createAnimatedComponent(Image)
const initialTransform = createTransform()
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onZoom: (isZoomed: boolean) => void onZoom: (isZoomed: boolean) => void
onLongPress: (image: ImageSource) => void pinchGestureRef: MutableRefObject<GestureType | undefined>
delayLongPress: number isScrollViewBeingDragged: boolean
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
} }
const AnimatedImage = Animated.createAnimatedComponent(Image)
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
onZoom, onZoom,
onRequestClose, onRequestClose,
onLongPress, isScrollViewBeingDragged,
delayLongPress, pinchGestureRef,
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => { }: Props) => {
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) const [isScaled, setIsScaled] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN) const committedTransform = useSharedValue(initialTransform)
const scrollValueY = new Animated.Value(0) const panTranslation = useSharedValue({x: 0, y: 0})
const [isLoaded, setLoadEnd] = useState(false) const pinchOrigin = useSharedValue({x: 0, y: 0})
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({x: 0, y: 0})
const dismissSwipeTranslateY = useSharedValue(0)
const containerRef = useAnimatedRef()
const onLoaded = useCallback(() => setLoadEnd(true), []) // Keep track of when we're entering or leaving scaled rendering.
const onZoomPerformed = useCallback( // Note: DO NOT move any logic reading animated values outside this function.
(isZoomed: boolean) => { useAnimatedReaction(
onZoom(isZoomed) () => {
if (imageContainer?.current) { if (pinchScale.value !== 1) {
imageContainer.current.setNativeProps({ // We're currently pinching.
scrollEnabled: !isZoomed, return true
}) }
const [, , committedScale] = readTransform(committedTransform.value)
if (committedScale !== 1) {
// We started from a pinched in state.
return true
}
// We're at rest.
return false
},
(nextIsScaled, prevIsScaled) => {
if (nextIsScaled !== prevIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
} }
}, },
[onZoom],
) )
const onLongPressHandler = useCallback(() => { function handleZoom(nextIsScaled: boolean) {
onLongPress(imageSrc) setIsScaled(nextIsScaled)
}, [imageSrc, onLongPress]) onZoom(nextIsScaled)
}
const [panHandlers, scaleValue, translateValue] = usePanResponder({ const animatedStyle = useAnimatedStyle(() => {
initialScale: scale || 1, // Apply the active adjustments on top of the committed transform before the gestures.
initialTranslate: translate || {x: 0, y: 0}, // This is matrix multiplication, so operations are applied in the reverse order.
onZoom: onZoomPerformed, let t = createTransform()
doubleTapToZoomEnabled, prependPan(t, panTranslation.value)
onLongPress: onLongPressHandler, prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
delayLongPress, prependTransform(t, committedTransform.value)
}) const [translateX, translateY, scale] = readTransform(t)
const imagesStyles = getImageStyles( const dismissDistance = dismissSwipeTranslateY.value
imageDimensions, const dismissProgress = Math.min(
translateValue, Math.abs(dismissDistance) / (SCREEN.height / 2),
scaleValue, 1,
) )
const imageOpacity = scrollValueY.interpolate({ return {
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], opacity: 1 - dismissProgress,
outputRange: [0.7, 1, 0.7], transform: [
}) {translateX},
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} {translateY: translateY + dismissDistance},
{scale},
const onScrollEndDrag = ({ ],
nativeEvent,
}: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
offsetY > SWIPE_CLOSE_OFFSET) ||
offsetY > SCREEN_HEIGHT / 2
) {
onRequestClose()
} }
})
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
candidateTransform: TransformMatrix,
) {
'worklet'
if (!imageDimensions) {
return [0, 0]
}
const [nextTranslateX, nextTranslateY, nextScale] =
readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
const clampedTranslateX = clampTranslation(
nextTranslateX,
scaledDimensions.width,
SCREEN.width,
)
const clampedTranslateY = clampTranslation(
nextTranslateY,
scaledDimensions.height,
SCREEN.height,
)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
return [dx, dy]
} }
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { // This is a hack.
const offsetY = nativeEvent?.contentOffset?.y ?? 0 // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
// However, there is no great reliable way to coordinate this yet in RGNH.
// This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
if (isScrollViewBeingDragged) {
// Steal the gesture (and do nothing, so native ScrollView does its thing).
manager.activate()
return
}
const measurement = measure(containerRef)
if (!measurement || measurement.pageX !== 0) {
// Steal the gesture (and do nothing, so native ScrollView does its thing).
manager.activate()
return
}
// Fail this "fake" gesture so that the gestures after it can proceed.
manager.fail()
})
scrollValueY.setValue(offsetY) const pinch = Gesture.Pinch()
} .withRef(pinchGestureRef)
.onStart(e => {
pinchOrigin.value = {
x: e.focalX - SCREEN.width / 2,
y: e.focalY - SCREEN.height / 2,
}
})
.onChange(e => {
if (!imageDimensions) {
return
}
// Don't let the picture zoom in so close that it gets blurry.
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
const [, , committedScale] = readTransform(committedTransform.value)
const maxCommittedScale =
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
const minPinchScale = 1 / committedScale
const maxPinchScale = maxCommittedScale / committedScale
const nextPinchScale = Math.min(
Math.max(minPinchScale, e.scale),
maxPinchScale,
)
pinchScale.value = nextPinchScale
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
const t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [dx, dy] = getExtraTranslationToStayInBounds(t)
if (dx !== 0 || dy !== 0) {
pinchTranslation.value = {
x: pinchTranslation.value.x + dx,
y: pinchTranslation.value.y + dy,
}
}
})
.onEnd(() => {
// Commit just the pinch.
let t = createTransform()
prependPinch(
t,
pinchScale.value,
pinchOrigin.value,
pinchTranslation.value,
)
prependTransform(t, committedTransform.value)
applyRounding(t)
committedTransform.value = t
// Reset just the pinch.
pinchScale.value = 1
pinchOrigin.value = {x: 0, y: 0}
pinchTranslation.value = {x: 0, y: 0}
})
const pan = Gesture.Pan()
.averageTouches(true)
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
.minPointers(isScaled ? 1 : 2)
.onChange(e => {
if (!imageDimensions) {
return
}
const nextPanTranslation = {x: e.translationX, y: e.translationY}
let t = createTransform()
prependPan(t, nextPanTranslation)
prependPinch(
t,
pinchScale.value,
pinchOrigin.value,
pinchTranslation.value,
)
prependTransform(t, committedTransform.value)
// Prevent panning from going out of bounds.
const [dx, dy] = getExtraTranslationToStayInBounds(t)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.value = nextPanTranslation
})
.onEnd(() => {
// Commit just the pan.
let t = createTransform()
prependPan(t, panTranslation.value)
prependTransform(t, committedTransform.value)
applyRounding(t)
committedTransform.value = t
// Reset just the pan.
panTranslation.value = {x: 0, y: 0}
})
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(e => {
if (!imageDimensions) {
return
}
const [, , committedScale] = readTransform(committedTransform.value)
if (committedScale !== 1) {
// Go back to 1:1 using the identity vector.
let t = createTransform()
committedTransform.value = withClampedSpring(t)
return
}
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const candidateScale = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_DOUBLE_TAP_SCALE,
)
// But don't zoom in so close that the picture gets blurry.
const maxScale =
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
const scale = Math.min(candidateScale, maxScale)
// Calculate where we would be if the user pinched into the double tapped point.
// We won't use this transform directly because it may go out of bounds.
const candidateTransform = createTransform()
const origin = {
x: e.absoluteX - SCREEN.width / 2,
y: e.absoluteY - SCREEN.height / 2,
}
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
// Now we know how much we went out of bounds, so we can shoot correctly.
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
const finalTransform = createTransform()
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
committedTransform.value = withClampedSpring(finalTransform)
})
const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
if (Math.abs(e.velocityY) > 1000) {
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
runOnJS(onRequestClose)()
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
const isLoading = !isLoaded || !imageDimensions
return ( return (
<ScrollView <Animated.View ref={containerRef} style={styles.container}>
ref={imageContainer} {isLoading && (
style={styles.listItem} <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
pagingEnabled )}
nestedScrollEnabled <GestureDetector
showsHorizontalScrollIndicator={false} gesture={Gesture.Exclusive(
showsVerticalScrollIndicator={false} consumeHScroll,
contentContainerStyle={styles.imageScrollContainer} dismissSwipePan,
scrollEnabled={swipeToCloseEnabled} Gesture.Simultaneous(pinch, pan),
{...(swipeToCloseEnabled && { doubleTap,
onScroll, )}>
onScrollEndDrag, <AnimatedImage
})}> source={imageSrc}
<AnimatedImage contentFit="contain"
{...panHandlers} style={[styles.image, animatedStyle]}
source={imageSrc} accessibilityLabel={imageSrc.alt}
style={imageStylesWithOpacity} accessibilityHint=""
onLoad={onLoaded} onLoad={() => setIsLoaded(true)}
accessibilityLabel={imageSrc.alt} />
accessibilityHint="" </GestureDetector>
/> </Animated.View>
{(!isLoaded || !imageDimensions) && <ImageLoading />}
</ScrollView>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listItem: { container: {
width: SCREEN_WIDTH, width: SCREEN.width,
height: SCREEN_HEIGHT, height: SCREEN.height,
overflow: 'hidden',
}, },
imageScrollContainer: { image: {
height: SCREEN_HEIGHT * 2, flex: 1,
},
loading: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}, },
}) })
function getScaledDimensions(
imageDimensions: ImageDimensions,
scale: number,
): ImageDimensions {
'worklet'
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * SCREEN.width,
height: (scale * SCREEN.width) / imageAspect,
}
} else {
return {
width: scale * SCREEN.height * imageAspect,
height: scale * SCREEN.height,
}
}
}
function clampTranslation(
value: number,
scaledSize: number,
screenSize: number,
): number {
'worklet'
// Figure out how much the user should be allowed to pan, and constrain the translation.
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
return clampedValue
}
function withClampedSpring(value: any) {
'worklet'
return withSpring(value, {overshootClamping: true})
}
export default React.memo(ImageItem) export default React.memo(ImageItem)
@@ -6,7 +6,7 @@
* *
*/ */
import React, {useCallback, useRef, useState} from 'react' import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
import { import {
Animated, Animated,
@@ -16,71 +16,52 @@ import {
View, View,
NativeScrollEvent, NativeScrollEvent,
NativeSyntheticEvent, NativeSyntheticEvent,
NativeTouchEvent,
TouchableWithoutFeedback, TouchableWithoutFeedback,
} from 'react-native' } from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {GestureType} from 'react-native-gesture-handler'
import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
import useImageDimensions from '../../hooks/useImageDimensions' import useImageDimensions from '../../hooks/useImageDimensions'
import {getImageStyles, getImageTransform} from '../../utils' import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading' import {ImageLoading} from './ImageLoading'
const DOUBLE_TAP_DELAY = 300
const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1 const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height const SCREEN_HEIGHT = SCREEN.height
const MIN_ZOOM = 2
const MAX_SCALE = 2 const MAX_SCALE = 2
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
onLongPress: (image: ImageSource) => void pinchGestureRef: MutableRefObject<GestureType>
delayLongPress: number isScrollViewBeingDragged: boolean
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
} }
const AnimatedImage = Animated.createAnimatedComponent(Image) const AnimatedImage = Animated.createAnimatedComponent(Image)
const ImageItem = ({ let lastTapTS: number | null = null
imageSrc,
onZoom, const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
onRequestClose,
onLongPress,
delayLongPress,
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const scrollViewRef = useRef<ScrollView>(null) const scrollViewRef = useRef<ScrollView>(null)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const handleDoubleTap = useDoubleTapToZoom(
scrollViewRef,
scaled,
SCREEN,
imageDimensions,
)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN) const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0) const [scrollValueY] = useState(() => new Animated.Value(0))
const scaleValue = new Animated.Value(scale || 1)
const translateValue = new Animated.ValueXY(translate)
const maxScrollViewZoom = MAX_SCALE / (scale || 1) const maxScrollViewZoom = MAX_SCALE / (scale || 1)
const imageOpacity = scrollValueY.interpolate({ const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.5, 1, 0.5], outputRange: [0.5, 1, 0.5],
}) })
const imagesStyles = getImageStyles( const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1)
imageDimensions,
translateValue,
scaleValue,
)
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
const onScrollEndDrag = useCallback( const onScrollEndDrag = useCallback(
@@ -91,15 +72,11 @@ const ImageItem = ({
onZoom(currentScaled) onZoom(currentScaled)
setScaled(currentScaled) setScaled(currentScaled)
if ( if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
!currentScaled &&
swipeToCloseEnabled &&
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
) {
onRequestClose() onRequestClose()
} }
}, },
[onRequestClose, onZoom, swipeToCloseEnabled], [onRequestClose, onZoom],
) )
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -112,9 +89,40 @@ const ImageItem = ({
scrollValueY.setValue(offsetY) scrollValueY.setValue(offsetY)
} }
const onLongPressHandler = useCallback(() => { const handleDoubleTap = useCallback(
onLongPress(imageSrc) (event: NativeSyntheticEvent<NativeTouchEvent>) => {
}, [imageSrc, onLongPress]) const nowTS = new Date().getTime()
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
let nextZoomRect = {
x: 0,
y: 0,
width: SCREEN.width,
height: SCREEN.height,
}
const willZoom = !scaled
if (willZoom) {
const {pageX, pageY} = event.nativeEvent
nextZoomRect = getZoomRectAfterDoubleTap(
imageDimensions,
pageX,
pageY,
)
}
// @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({
...nextZoomRect, // This rect is in screen coordinates
animated: true,
})
} else {
lastTapTS = nowTS
}
},
[imageDimensions, scaled],
)
return ( return (
<View> <View>
@@ -126,17 +134,13 @@ const ImageItem = ({
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
maximumZoomScale={maxScrollViewZoom} maximumZoomScale={maxScrollViewZoom}
contentContainerStyle={styles.imageScrollContainer} contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled} scrollEnabled={true}
onScroll={onScroll}
onScrollEndDrag={onScrollEndDrag} onScrollEndDrag={onScrollEndDrag}
scrollEventThrottle={1} scrollEventThrottle={1}>
{...(swipeToCloseEnabled && {
onScroll,
})}>
{(!loaded || !imageDimensions) && <ImageLoading />} {(!loaded || !imageDimensions) && <ImageLoading />}
<TouchableWithoutFeedback <TouchableWithoutFeedback
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} onPress={handleDoubleTap}
onLongPress={onLongPressHandler}
delayLongPress={delayLongPress}
accessibilityRole="image" accessibilityRole="image"
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint=""> accessibilityHint="">
@@ -161,4 +165,149 @@ const styles = StyleSheet.create({
}, },
}) })
const getZoomRectAfterDoubleTap = (
imageDimensions: ImageDimensions | null,
touchX: number,
touchY: number,
): {
x: number
y: number
width: number
height: number
} => {
if (!imageDimensions) {
return {
x: 0,
y: 0,
width: SCREEN.width,
height: SCREEN.height,
}
}
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_ZOOM,
)
// Unlike in the Android version, we don't constrain the *max* zoom level here.
// Instead, this is done in the ScrollView props so that it constraints pinch too.
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
// We already know the zoom level, so this gives us the rectangle size.
let rectWidth = SCREEN.width / zoom
let rectHeight = SCREEN.height / zoom
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
// We don't want to introduce new black bars or make existing black bars unbalanced.
let minX = 0
let minY = 0
let maxX = SCREEN.width - rectWidth
let maxY = SCREEN.height - rectHeight
if (imageAspect >= screenAspect) {
// The image has horizontal black bars. Exclude them from the safe area.
const renderedHeight = SCREEN.width / imageAspect
const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
minY += horizontalBarHeight
maxY -= horizontalBarHeight
} else {
// The image has vertical black bars. Exclude them from the safe area.
const renderedWidth = SCREEN.height * imageAspect
const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
minX += verticalBarWidth
maxX -= verticalBarWidth
}
// Finally, we can position the rect according to its size and the safe area.
let rectX
if (maxX >= minX) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectX = touchX - touchX / zoom
rectX = Math.min(rectX, maxX)
rectX = Math.max(rectX, minX)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectX = SCREEN.width / 2 - rectWidth / 2
}
let rectY
if (maxY >= minY) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectY = touchY - touchY / zoom
rectY = Math.min(rectY, maxY)
rectY = Math.max(rectY, minY)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectY = SCREEN.height / 2 - rectHeight / 2
}
return {
x: rectX,
y: rectY,
height: rectHeight,
width: rectWidth,
}
}
const getImageStyles = (
image: ImageDimensions | null,
translate: {readonly x: number; readonly y: number} | undefined,
scale?: number,
) => {
if (!image?.width || !image?.height) {
return {width: 0, height: 0}
}
const transform = []
if (translate) {
transform.push({translateX: translate.x})
transform.push({translateY: translate.y})
}
if (scale) {
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
transform.push({scale}, {perspective: new Animated.Value(1000)})
}
return {
width: image.width,
height: image.height,
transform,
}
}
const getImageTransform = (
image: ImageDimensions | null,
screen: ImageDimensions,
) => {
if (!image?.width || !image?.height) {
return [] as const
}
const wScale = screen.width / image.width
const hScale = screen.height / image.height
const scale = Math.min(wScale, hScale)
const {x, y} = getImageTranslate(image, screen)
return [{x, y}, scale] as const
}
const getImageTranslate = (
image: ImageDimensions,
screen: ImageDimensions,
): {x: number; y: number} => {
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
const imageSize = axis === 'x' ? image.width : image.height
const screenSize = axis === 'x' ? screen.width : screen.height
return (screenSize - imageSize) / 2
}
return {
x: getTranslateForAxis('x'),
y: getTranslateForAxis('y'),
}
}
export default React.memo(ImageItem) export default React.memo(ImageItem)
@@ -1,17 +1,16 @@
// default implementation fallback for web // default implementation fallback for web
import React from 'react' import React, {MutableRefObject} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {GestureType} from 'react-native-gesture-handler'
import {ImageSource} from '../../@types' import {ImageSource} from '../../@types'
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
onLongPress: (image: ImageSource) => void pinchGestureRef: MutableRefObject<GestureType | undefined>
delayLongPress: number isScrollViewBeingDragged: boolean
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
} }
const ImageItem = (_props: Props) => { const ImageItem = (_props: Props) => {
@@ -1,47 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {Animated} from 'react-native'
const INITIAL_POSITION = {x: 0, y: 0}
const ANIMATION_CONFIG = {
duration: 200,
useNativeDriver: true,
}
const useAnimatedComponents = () => {
const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
const footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
const toggleVisible = (isVisible: boolean) => {
if (isVisible) {
Animated.parallel([
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
]).start()
} else {
Animated.parallel([
Animated.timing(headerTranslate.y, {
...ANIMATION_CONFIG,
toValue: -300,
}),
Animated.timing(footerTranslate.y, {
...ANIMATION_CONFIG,
toValue: 300,
}),
]).start()
}
}
const headerTransform = headerTranslate.getTranslateTransform()
const footerTransform = footerTranslate.getTranslateTransform()
return [headerTransform, footerTransform, toggleVisible] as const
}
export default useAnimatedComponents
@@ -1,150 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback} from 'react'
import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
import {Dimensions} from '../@types'
const DOUBLE_TAP_DELAY = 300
const MIN_ZOOM = 2
let lastTapTS: number | null = null
/**
* This is iOS only.
* Same functionality for Android implemented inside usePanResponder hook.
*/
function useDoubleTapToZoom(
scrollViewRef: React.RefObject<ScrollView>,
scaled: boolean,
screen: Dimensions,
imageDimensions: Dimensions | null,
) {
const handleDoubleTap = useCallback(
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
const nowTS = new Date().getTime()
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
const getZoomRectAfterDoubleTap = (
touchX: number,
touchY: number,
): {
x: number
y: number
width: number
height: number
} => {
if (!imageDimensions) {
return {
x: 0,
y: 0,
width: screen.width,
height: screen.height,
}
}
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = screen.width / screen.height
const zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_ZOOM,
)
// Unlike in the Android version, we don't constrain the *max* zoom level here.
// Instead, this is done in the ScrollView props so that it constraints pinch too.
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
// We already know the zoom level, so this gives us the rectangle size.
let rectWidth = screen.width / zoom
let rectHeight = screen.height / zoom
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
// We don't want to introduce new black bars or make existing black bars unbalanced.
let minX = 0
let minY = 0
let maxX = screen.width - rectWidth
let maxY = screen.height - rectHeight
if (imageAspect >= screenAspect) {
// The image has horizontal black bars. Exclude them from the safe area.
const renderedHeight = screen.width / imageAspect
const horizontalBarHeight = (screen.height - renderedHeight) / 2
minY += horizontalBarHeight
maxY -= horizontalBarHeight
} else {
// The image has vertical black bars. Exclude them from the safe area.
const renderedWidth = screen.height * imageAspect
const verticalBarWidth = (screen.width - renderedWidth) / 2
minX += verticalBarWidth
maxX -= verticalBarWidth
}
// Finally, we can position the rect according to its size and the safe area.
let rectX
if (maxX >= minX) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectX = touchX - touchX / zoom
rectX = Math.min(rectX, maxX)
rectX = Math.max(rectX, minX)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectX = screen.width / 2 - rectWidth / 2
}
let rectY
if (maxY >= minY) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectY = touchY - touchY / zoom
rectY = Math.min(rectY, maxY)
rectY = Math.max(rectY, minY)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectY = screen.height / 2 - rectHeight / 2
}
return {
x: rectX,
y: rectY,
height: rectHeight,
width: rectWidth,
}
}
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
let nextZoomRect = {
x: 0,
y: 0,
width: screen.width,
height: screen.height,
}
const willZoom = !scaled
if (willZoom) {
const {pageX, pageY} = event.nativeEvent
nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
}
// @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({
...nextZoomRect, // This rect is in screen coordinates
animated: true,
})
} else {
lastTapTS = nowTS
}
},
[imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
)
return handleDoubleTap
}
export default useDoubleTapToZoom
@@ -8,11 +8,29 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {Image, ImageURISource} from 'react-native' import {Image, ImageURISource} from 'react-native'
import {createCache} from '../utils'
import {Dimensions, ImageSource} from '../@types' import {Dimensions, ImageSource} from '../@types'
const CACHE_SIZE = 50 const CACHE_SIZE = 50
type CacheStorageItem = {key: string; value: any}
const createCache = (cacheSize: number) => ({
_storage: [] as CacheStorageItem[],
get(key: string): any {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: any) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const imageDimensionsCache = createCache(CACHE_SIZE) const imageDimensionsCache = createCache(CACHE_SIZE)
const useImageDimensions = (image: ImageSource): Dimensions | null => { const useImageDimensions = (image: ImageSource): Dimensions | null => {
@@ -1,32 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useState} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {Dimensions} from '../@types'
const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
const [currentImageIndex, setImageIndex] = useState(imageIndex)
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const {
nativeEvent: {
contentOffset: {x: scrollX},
},
} = event
if (screen.width) {
const nextIndex = Math.round(scrollX / screen.width)
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
}
}
return [currentImageIndex, onScroll] as const
}
export default useImageIndexChange
@@ -1,25 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect} from 'react'
import {Image} from 'react-native'
import {ImageSource} from '../@types'
const useImagePrefetch = (images: ImageSource[]) => {
useEffect(() => {
images.forEach(image => {
//@ts-ignore
if (image.uri) {
//@ts-ignore
return Image.prefetch(image.uri)
}
})
}, [images])
}
export default useImagePrefetch
@@ -1,431 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect} from 'react'
import {
Animated,
Dimensions,
GestureResponderEvent,
GestureResponderHandlers,
NativeTouchEvent,
PanResponder,
PanResponderGestureState,
} from 'react-native'
import {Position} from '../@types'
import {
getDistanceBetweenTouches,
getImageTranslate,
getImageDimensionsByTranslate,
} from '../utils'
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
const ANDROID_BAR_HEIGHT = 24
const MIN_ZOOM = 2
const MAX_SCALE = 2
const DOUBLE_TAP_DELAY = 300
const OUT_BOUND_MULTIPLIER = 0.75
type Props = {
initialScale: number
initialTranslate: Position
onZoom: (isZoomed: boolean) => void
doubleTapToZoomEnabled: boolean
onLongPress: () => void
delayLongPress: number
}
const usePanResponder = ({
initialScale,
initialTranslate,
onZoom,
doubleTapToZoomEnabled,
onLongPress,
delayLongPress,
}: Props): Readonly<
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
> => {
let numberInitialTouches = 1
let initialTouches: NativeTouchEvent[] = []
let currentScale = initialScale
let currentTranslate = initialTranslate
let tmpScale = 0
let tmpTranslate: Position | null = null
let isDoubleTapPerformed = false
let lastTapTS: number | null = null
let longPressHandlerRef: NodeJS.Timeout | null = null
const meaningfulShift = MIN_DIMENSION * 0.01
const scaleValue = new Animated.Value(initialScale)
const translateValue = new Animated.ValueXY(initialTranslate)
const imageDimensions = getImageDimensionsByTranslate(
initialTranslate,
SCREEN,
)
const getBounds = (scale: number) => {
const scaledImageDimensions = {
width: imageDimensions.width * scale,
height: imageDimensions.height * scale,
}
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
const left = initialTranslate.x - translateDelta.x
const right = left - (scaledImageDimensions.width - SCREEN.width)
const top = initialTranslate.y - translateDelta.y
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
return [top, left, bottom, right]
}
const getTransformAfterDoubleTap = (
touchX: number,
touchY: number,
): [number, Position] => {
let nextScale = initialScale
let nextTranslateX = initialTranslate.x
let nextTranslateY = initialTranslate.y
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
let zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_ZOOM,
)
// Don't zoom so hard that the original image's pixels become blurry.
zoom = Math.min(zoom, MAX_SCALE / initialScale)
nextScale = initialScale * zoom
// Next, let's see if we need to adjust the scaled image translation.
// Ideally, we want the tapped point to stay under the finger after the scaling.
const dx = SCREEN.width / 2 - touchX
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
// Before we try to adjust the translation, check how much wiggle room we have.
// We don't want to introduce new black bars or make existing black bars unbalanced.
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
if (leftBound > rightBound) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateX += dx * zoom - dx
nextTranslateX = Math.min(nextTranslateX, leftBound)
nextTranslateX = Math.max(nextTranslateX, rightBound)
}
if (topBound > bottomBound) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateY += dy * zoom - dy
nextTranslateY = Math.min(nextTranslateY, topBound)
nextTranslateY = Math.max(nextTranslateY, bottomBound)
}
return [
nextScale,
{
x: nextTranslateX,
y: nextTranslateY,
},
]
}
const fitsScreenByWidth = () =>
imageDimensions.width * currentScale < SCREEN_WIDTH
const fitsScreenByHeight = () =>
imageDimensions.height * currentScale < SCREEN_HEIGHT
useEffect(() => {
scaleValue.addListener(({value}) => {
if (typeof onZoom === 'function') {
onZoom(value !== initialScale)
}
})
return () => scaleValue.removeAllListeners()
})
const cancelLongPressHandle = () => {
longPressHandlerRef && clearTimeout(longPressHandlerRef)
}
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) {
return
}
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
},
onPanResponderStart: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
initialTouches = event.nativeEvent.touches
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) {
return
}
const tapTS = Date.now()
// Handle double tap event by calculating diff between first and second taps timestamps
isDoubleTapPerformed = Boolean(
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
let nextScale = initialScale
let nextTranslate = initialTranslate
const willZoom = currentScale === initialScale
if (willZoom) {
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
touchX,
touchY,
)
}
onZoom(willZoom)
Animated.parallel(
[
Animated.timing(translateValue.x, {
toValue: nextTranslate.x,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateValue.y, {
toValue: nextTranslate.y,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(scaleValue, {
toValue: nextScale,
duration: 300,
useNativeDriver: true,
}),
],
{stopTogether: false},
).start(() => {
currentScale = nextScale
currentTranslate = nextTranslate
})
lastTapTS = null
} else {
lastTapTS = Date.now()
}
},
onPanResponderMove: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
const {dx, dy} = gestureState
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
cancelLongPressHandle()
}
// Don't need to handle move because double tap in progress (was handled in onStart)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
cancelLongPressHandle()
return
}
if (
numberInitialTouches === 1 &&
gestureState.numberActiveTouches === 2
) {
numberInitialTouches = 2
initialTouches = event.nativeEvent.touches
}
const isTapGesture =
numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
const isPinchGesture =
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
if (isPinchGesture) {
cancelLongPressHandle()
const initialDistance = getDistanceBetweenTouches(initialTouches)
const currentDistance = getDistanceBetweenTouches(
event.nativeEvent.touches,
)
let nextScale = (currentDistance / initialDistance) * currentScale
/**
* In case image is scaling smaller than initial size ->
* slow down this transition by applying OUT_BOUND_MULTIPLIER
*/
if (nextScale < initialScale) {
nextScale =
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
}
/**
* In case image is scaling down -> move it in direction of initial position
*/
if (currentScale > initialScale && currentScale > nextScale) {
const k = (currentScale - initialScale) / (currentScale - nextScale)
const nextTranslateX =
nextScale < initialScale
? initialTranslate.x
: currentTranslate.x -
(currentTranslate.x - initialTranslate.x) / k
const nextTranslateY =
nextScale < initialScale
? initialTranslate.y
: currentTranslate.y -
(currentTranslate.y - initialTranslate.y) / k
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
}
scaleValue.setValue(nextScale)
tmpScale = nextScale
}
if (isTapGesture && currentScale > initialScale) {
const {x, y} = currentTranslate
// eslint-disable-next-line @typescript-eslint/no-shadow
const {dx, dy} = gestureState
const [topBound, leftBound, bottomBound, rightBound] =
getBounds(currentScale)
let nextTranslateX = x + dx
let nextTranslateY = y + dy
if (nextTranslateX > leftBound) {
nextTranslateX =
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateX < rightBound) {
nextTranslateX =
nextTranslateX -
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY > topBound) {
nextTranslateY =
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY < bottomBound) {
nextTranslateY =
nextTranslateY -
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
}
if (fitsScreenByWidth()) {
nextTranslateX = x
}
if (fitsScreenByHeight()) {
nextTranslateY = y
}
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
}
},
onPanResponderRelease: () => {
cancelLongPressHandle()
if (isDoubleTapPerformed) {
isDoubleTapPerformed = false
}
if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
Animated.timing(scaleValue, {
toValue: tmpScale,
duration: 100,
useNativeDriver: true,
}).start()
}
currentScale = tmpScale
tmpScale = 0
}
if (tmpTranslate) {
const {x, y} = tmpTranslate
const [topBound, leftBound, bottomBound, rightBound] =
getBounds(currentScale)
let nextTranslateX = x
let nextTranslateY = y
if (!fitsScreenByWidth()) {
if (nextTranslateX > leftBound) {
nextTranslateX = leftBound
} else if (nextTranslateX < rightBound) {
nextTranslateX = rightBound
}
}
if (!fitsScreenByHeight()) {
if (nextTranslateY > topBound) {
nextTranslateY = topBound
} else if (nextTranslateY < bottomBound) {
nextTranslateY = bottomBound
}
}
Animated.parallel([
Animated.timing(translateValue.x, {
toValue: nextTranslateX,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(translateValue.y, {
toValue: nextTranslateY,
duration: 100,
useNativeDriver: true,
}),
]).start()
currentTranslate = {x: nextTranslateX, y: nextTranslateY}
tmpTranslate = null
}
},
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false,
})
return [panResponder.panHandlers, scaleValue, translateValue]
}
export default usePanResponder
@@ -1,24 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useState} from 'react'
const useRequestClose = (onRequestClose: () => void) => {
const [opacity, setOpacity] = useState(1)
return [
opacity,
() => {
setOpacity(0)
onRequestClose()
setTimeout(() => setOpacity(1), 0)
},
] as const
}
export default useRequestClose
+95 -38
View File
@@ -10,14 +10,17 @@
import React, { import React, {
ComponentType, ComponentType,
createRef,
useCallback, useCallback,
useRef, useRef,
useEffect,
useMemo, useMemo,
useState,
} from 'react' } from 'react'
import { import {
Animated, Animated,
Dimensions, Dimensions,
NativeSyntheticEvent,
NativeScrollEvent,
StyleSheet, StyleSheet,
View, View,
VirtualizedList, VirtualizedList,
@@ -29,10 +32,8 @@ import {ModalsContainer} from '../../modals/Modal'
import ImageItem from './components/ImageItem/ImageItem' import ImageItem from './components/ImageItem/ImageItem'
import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageDefaultHeader from './components/ImageDefaultHeader'
import useAnimatedComponents from './hooks/useAnimatedComponents'
import useImageIndexChange from './hooks/useImageIndexChange'
import useRequestClose from './hooks/useRequestClose'
import {ImageSource} from './@types' import {ImageSource} from './@types'
import {ScrollView, GestureType} from 'react-native-gesture-handler'
import {Edge, SafeAreaView} from 'react-native-safe-area-context' import {Edge, SafeAreaView} from 'react-native-safe-area-context'
type Props = { type Props = {
@@ -41,22 +42,21 @@ type Props = {
imageIndex: number imageIndex: number
visible: boolean visible: boolean
onRequestClose: () => void onRequestClose: () => void
onLongPress?: (image: ImageSource) => void
onImageIndexChange?: (imageIndex: number) => void
presentationStyle?: ModalProps['presentationStyle'] presentationStyle?: ModalProps['presentationStyle']
animationType?: ModalProps['animationType'] animationType?: ModalProps['animationType']
backgroundColor?: string backgroundColor?: string
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
delayLongPress?: number
HeaderComponent?: ComponentType<{imageIndex: number}> HeaderComponent?: ComponentType<{imageIndex: number}>
FooterComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}>
} }
const DEFAULT_BG_COLOR = '#000' const DEFAULT_BG_COLOR = '#000'
const DEFAULT_DELAY_LONG_PRESS = 800
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const INITIAL_POSITION = {x: 0, y: 0}
const ANIMATION_CONFIG = {
duration: 200,
useNativeDriver: true,
}
function ImageViewing({ function ImageViewing({
images, images,
@@ -64,35 +64,65 @@ function ImageViewing({
imageIndex, imageIndex,
visible, visible,
onRequestClose, onRequestClose,
onLongPress = () => {},
onImageIndexChange,
backgroundColor = DEFAULT_BG_COLOR, backgroundColor = DEFAULT_BG_COLOR,
swipeToCloseEnabled,
doubleTapToZoomEnabled,
delayLongPress = DEFAULT_DELAY_LONG_PRESS,
HeaderComponent, HeaderComponent,
FooterComponent, FooterComponent,
}: Props) { }: Props) {
const imageList = useRef<VirtualizedList<ImageSource>>(null) const imageList = useRef<VirtualizedList<ImageSource>>(null)
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) const [isScaled, setIsScaled] = useState(false)
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) const [isDragging, setIsDragging] = useState(false)
const [headerTransform, footerTransform, toggleBarsVisible] = const [opacity, setOpacity] = useState(1)
useAnimatedComponents() const [currentImageIndex, setImageIndex] = useState(imageIndex)
const [headerTranslate] = useState(
useEffect(() => { () => new Animated.ValueXY(INITIAL_POSITION),
if (onImageIndexChange) {
onImageIndexChange(currentImageIndex)
}
}, [currentImageIndex, onImageIndexChange])
const onZoom = useCallback(
(isScaled: boolean) => {
// @ts-ignore
imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
toggleBarsVisible(!isScaled)
},
[toggleBarsVisible],
) )
const [footerTranslate] = useState(
() => new Animated.ValueXY(INITIAL_POSITION),
)
const toggleBarsVisible = (isVisible: boolean) => {
if (isVisible) {
Animated.parallel([
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
]).start()
} else {
Animated.parallel([
Animated.timing(headerTranslate.y, {
...ANIMATION_CONFIG,
toValue: -300,
}),
Animated.timing(footerTranslate.y, {
...ANIMATION_CONFIG,
toValue: 300,
}),
]).start()
}
}
const onRequestCloseEnhanced = () => {
setOpacity(0)
onRequestClose()
setTimeout(() => setOpacity(1), 0)
}
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const {
nativeEvent: {
contentOffset: {x: scrollX},
},
} = event
if (SCREEN.width) {
const nextIndex = Math.round(scrollX / SCREEN.width)
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
}
}
const onZoom = (nextIsScaled: boolean) => {
toggleBarsVisible(!nextIsScaled)
setIsScaled(false)
}
const edges = useMemo(() => { const edges = useMemo(() => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
@@ -107,10 +137,23 @@ function ImageViewing({
} }
}, [imageList, imageIndex]) }, [imageList, imageIndex])
// This is a hack.
// RNGH doesn't have an easy way to express that pinch of individual items
// should "steal" all pinches from the scroll view. So we're keeping a ref
// to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
const [pinchGestureRefs] = useState(new Map())
for (let imageSrc of images) {
if (!pinchGestureRefs.get(imageSrc)) {
pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
}
}
if (!visible) { if (!visible) {
return null return null
} }
const headerTransform = headerTranslate.getTranslateTransform()
const footerTransform = footerTranslate.getTranslateTransform()
return ( return (
<SafeAreaView <SafeAreaView
style={styles.screen} style={styles.screen}
@@ -134,6 +177,7 @@ function ImageViewing({
data={images} data={images}
horizontal horizontal
pagingEnabled pagingEnabled
scrollEnabled={!isScaled || isDragging}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
getItem={(_, index) => images[index]} getItem={(_, index) => images[index]}
@@ -148,13 +192,26 @@ function ImageViewing({
onZoom={onZoom} onZoom={onZoom}
imageSrc={imageSrc} imageSrc={imageSrc}
onRequestClose={onRequestCloseEnhanced} onRequestClose={onRequestCloseEnhanced}
onLongPress={onLongPress} pinchGestureRef={pinchGestureRefs.get(imageSrc)}
delayLongPress={delayLongPress} isScrollViewBeingDragged={isDragging}
swipeToCloseEnabled={swipeToCloseEnabled}
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
/> />
)} )}
onMomentumScrollEnd={onScroll} renderScrollComponent={props => (
<ScrollView
{...props}
waitFor={Array.from(pinchGestureRefs.values())}
/>
)}
onScrollBeginDrag={() => {
setIsDragging(true)
}}
onScrollEndDrag={() => {
setIsDragging(false)
}}
onMomentumScrollEnd={e => {
setIsScaled(false)
onScroll(e)
}}
//@ts-ignore //@ts-ignore
keyExtractor={(imageSrc, index) => keyExtractor={(imageSrc, index) =>
keyExtractor keyExtractor
@@ -0,0 +1,98 @@
import type {Position} from './@types'
export type TransformMatrix = [
number,
number,
number,
number,
number,
number,
number,
number,
number,
]
// These are affine transforms. See explanation of every cell here:
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
export function createTransform(): TransformMatrix {
'worklet'
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
}
export function applyRounding(t: TransformMatrix) {
'worklet'
t[2] = Math.round(t[2])
t[5] = Math.round(t[5])
// For example: 0.985, 0.99, 0.995, then 1:
t[0] = Math.round(t[0] * 200) / 200
t[4] = Math.round(t[0] * 200) / 200
}
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
// All write operations are applied in-place to avoid unnecessary allocations.
export function readTransform(t: TransformMatrix): [number, number, number] {
'worklet'
const scale = t[0]
const translateX = t[2]
const translateY = t[5]
return [translateX, translateY, scale]
}
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
'worklet'
t[2] += t[0] * x + t[1] * y
t[5] += t[3] * x + t[4] * y
}
export function prependScale(t: TransformMatrix, value: number) {
'worklet'
t[0] *= value
t[1] *= value
t[3] *= value
t[4] *= value
}
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
'worklet'
// In-place matrix multiplication.
const a00 = ta[0],
a01 = ta[1],
a02 = ta[2]
const a10 = ta[3],
a11 = ta[4],
a12 = ta[5]
const a20 = ta[6],
a21 = ta[7],
a22 = ta[8]
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
}
export function prependPan(t: TransformMatrix, translation: Position) {
'worklet'
prependTranslate(t, translation.x, translation.y)
}
export function prependPinch(
t: TransformMatrix,
scale: number,
origin: Position,
translation: Position,
) {
'worklet'
prependTranslate(t, translation.x, translation.y)
prependTranslate(t, origin.x, origin.y)
prependScale(t, scale)
prependTranslate(t, -origin.x, -origin.y)
}
-139
View File
@@ -1,139 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {Animated, NativeTouchEvent} from 'react-native'
import {Dimensions, Position} from './@types'
type CacheStorageItem = {key: string; value: any}
export const createCache = (cacheSize: number) => ({
_storage: [] as CacheStorageItem[],
get(key: string): any {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: any) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
arr.reduce((result, item) => {
const batch = result.pop() || []
if (batch.length < batchSize) {
batch.push(item)
result.push(batch)
} else {
result.push(batch, [item])
}
return result
}, [])
export const getImageTransform = (
image: Dimensions | null,
screen: Dimensions,
) => {
if (!image?.width || !image?.height) {
return [] as const
}
const wScale = screen.width / image.width
const hScale = screen.height / image.height
const scale = Math.min(wScale, hScale)
const {x, y} = getImageTranslate(image, screen)
return [{x, y}, scale] as const
}
export const getImageStyles = (
image: Dimensions | null,
translate: Animated.ValueXY,
scale?: Animated.Value,
) => {
if (!image?.width || !image?.height) {
return {width: 0, height: 0}
}
const transform = translate.getTranslateTransform()
if (scale) {
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
transform.push({scale}, {perspective: new Animated.Value(1000)})
}
return {
width: image.width,
height: image.height,
transform,
}
}
export const getImageTranslate = (
image: Dimensions,
screen: Dimensions,
): Position => {
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
const imageSize = axis === 'x' ? image.width : image.height
const screenSize = axis === 'x' ? screen.width : screen.height
return (screenSize - imageSize) / 2
}
return {
x: getTranslateForAxis('x'),
y: getTranslateForAxis('y'),
}
}
export const getImageDimensionsByTranslate = (
translate: Position,
screen: Dimensions,
): Dimensions => ({
width: screen.width - translate.x * 2,
height: screen.height - translate.y * 2,
})
export const getImageTranslateForScale = (
currentTranslate: Position,
targetScale: number,
screen: Dimensions,
): Position => {
const {width, height} = getImageDimensionsByTranslate(
currentTranslate,
screen,
)
const targetImageDimensions = {
width: width * targetScale,
height: height * targetScale,
}
return getImageTranslate(targetImageDimensions, screen)
}
export const getDistanceBetweenTouches = (
touches: NativeTouchEvent[],
): number => {
const [a, b] = touches
if (a == null || b == null) {
return 0
}
return Math.sqrt(
Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
)
}
+86 -84
View File
@@ -15,94 +15,10 @@ import * as MediaLibrary from 'expo-media-library'
export const Lightbox = observer(function Lightbox() { export const Lightbox = observer(function Lightbox() {
const store = useStores() const store = useStores()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
const onClose = React.useCallback(() => { const onClose = React.useCallback(() => {
store.shell.closeLightbox() store.shell.closeLightbox()
}, [store]) }, [store])
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
Toast.show('Permission to access camera roll is required.')
if (permissionResponse?.canAskAgain) {
requestPermission()
} else {
Toast.show(
'Permission to access camera roll was denied. Please enable it in your system settings.',
)
}
return
}
try {
await saveImageToMediaLibrary({uri})
Toast.show('Saved to your camera roll.')
} catch (e: any) {
Toast.show(`Failed to save image: ${String(e)}`)
}
},
[permissionResponse, requestPermission],
)
const LightboxFooter = React.useCallback(
({imageIndex}: {imageIndex: number}) => {
const lightbox = store.shell.activeLightbox
if (!lightbox) {
return null
}
let altText = ''
let uri = ''
if (lightbox.name === 'images') {
const opts = lightbox as models.ImagesLightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || ''
} else if (lightbox.name === 'profile-image') {
const opts = lightbox as models.ProfileImageLightbox
uri = opts.profileView.avatar || ''
}
return (
<View style={[styles.footer]}>
{altText ? (
<Pressable
onPress={() => setAltExpanded(!isAltExpanded)}
accessibilityRole="button">
<Text
style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
Save
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
Share
</Text>
</Button>
</View>
</View>
)
},
[store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts],
)
if (!store.shell.activeLightbox) { if (!store.shell.activeLightbox) {
return null return null
} else if (store.shell.activeLightbox.name === 'profile-image') { } else if (store.shell.activeLightbox.name === 'profile-image') {
@@ -132,6 +48,92 @@ export const Lightbox = observer(function Lightbox() {
} }
}) })
const LightboxFooter = observer(function LightboxFooter({
imageIndex,
}: {
imageIndex: number
}) {
const store = useStores()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
Toast.show('Permission to access camera roll is required.')
if (permissionResponse?.canAskAgain) {
requestPermission()
} else {
Toast.show(
'Permission to access camera roll was denied. Please enable it in your system settings.',
)
}
return
}
try {
await saveImageToMediaLibrary({uri})
Toast.show('Saved to your camera roll.')
} catch (e: any) {
Toast.show(`Failed to save image: ${String(e)}`)
}
},
[permissionResponse, requestPermission],
)
const lightbox = store.shell.activeLightbox
if (!lightbox) {
return null
}
let altText = ''
let uri = ''
if (lightbox.name === 'images') {
const opts = lightbox as models.ImagesLightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || ''
} else if (lightbox.name === 'profile-image') {
const opts = lightbox as models.ProfileImageLightbox
uri = opts.profileView.avatar || ''
}
return (
<View style={[styles.footer]}>
{altText ? (
<Pressable
onPress={() => setAltExpanded(!isAltExpanded)}
accessibilityRole="button">
<Text
style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
Save
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
Share
</Text>
</Button>
</View>
</View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
footer: { footer: {
paddingTop: 16, paddingTop: 16,
-1
View File
@@ -145,7 +145,6 @@ function LightboxInner({
{imgs[index].alt ? ( {imgs[index].alt ? (
<View style={styles.footer}> <View style={styles.footer}>
<Pressable <Pressable
accessibilityRole="button"
accessibilityLabel="Expand alt text" accessibilityLabel="Expand alt text"
accessibilityHint="If alt text is long, toggles alt text expanded state" accessibilityHint="If alt text is long, toggles alt text expanded state"
onPress={() => { onPress={() => {
+280
View File
@@ -0,0 +1,280 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
SafeAreaView,
StyleSheet,
View,
} from 'react-native'
import {ScrollView, TextInput} from './util'
import {observer} from 'mobx-react-lite'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
enum Stages {
InputEmail,
ConfirmCode,
Done,
}
export const snapPoints = ['90%']
export const Component = observer(function Component({}: {}) {
const pal = usePalette('default')
const store = useStores()
const [stage, setStage] = useState<Stages>(Stages.InputEmail)
const [email, setEmail] = useState<string>(
store.session.currentSession?.email || '',
)
const [confirmationCode, setConfirmationCode] = useState<string>('')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const {isMobile} = useWebMediaQueries()
const onRequestChange = async () => {
if (email === store.session.currentSession?.email) {
setError('Enter your new email above')
return
}
setError('')
setIsProcessing(true)
try {
const res = await store.agent.com.atproto.server.requestEmailUpdate()
if (res.data.tokenRequired) {
setStage(Stages.ConfirmCode)
} else {
await store.agent.com.atproto.server.updateEmail({email: email.trim()})
store.session.updateLocalAccountData({
email: email.trim(),
emailConfirmed: false,
})
Toast.show('Email updated')
setStage(Stages.Done)
}
} catch (e) {
let err = cleanError(String(e))
// TEMP
// while rollout is occuring, we're giving a temporary error message
// you can remove this any time after Oct2023
// -prf
if (err === 'email must be confirmed (temporary)') {
err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`
}
setError(err)
} finally {
setIsProcessing(false)
}
}
const onConfirm = async () => {
setError('')
setIsProcessing(true)
try {
await store.agent.com.atproto.server.updateEmail({
email: email.trim(),
token: confirmationCode.trim(),
})
store.session.updateLocalAccountData({
email: email.trim(),
emailConfirmed: false,
})
Toast.show('Email updated')
setStage(Stages.Done)
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onVerify = async () => {
store.shell.closeModal()
store.shell.openModal({name: 'verify-email'})
}
return (
<KeyboardAvoidingView
behavior="padding"
style={[pal.view, styles.container]}>
<SafeAreaView style={s.flex1}>
<ScrollView
testID="changeEmailModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
{stage === Stages.InputEmail ? 'Change Your Email' : ''}
{stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
{stage === Stages.Done ? 'Email Updated' : ''}
</Text>
</View>
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
{stage === Stages.InputEmail ? (
<>Enter your new email address below.</>
) : stage === Stages.ConfirmCode ? (
<>
An email has been sent to your previous address,{' '}
{store.session.currentSession?.email || ''}. It includes a
confirmation code which you can enter below.
</>
) : (
<>
Your email has been updated but not verified. As a next step,
please verify your new email.
</>
)}
</Text>
{stage === Stages.InputEmail && (
<TextInput
testID="emailInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="alice@mail.com"
placeholderTextColor={pal.colors.textLight}
value={email}
onChangeText={setEmail}
accessible={true}
accessibilityLabel="Email"
accessibilityHint=""
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
/>
)}
{stage === Stages.ConfirmCode && (
<TextInput
testID="confirmCodeInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="XXXXX-XXXXX"
placeholderTextColor={pal.colors.textLight}
value={confirmationCode}
onChangeText={setConfirmationCode}
accessible={true}
accessibilityLabel="Confirmation code"
accessibilityHint=""
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
)}
{error ? (
<ErrorMessage message={error} style={styles.error} />
) : undefined}
<View style={[styles.btnContainer]}>
{isProcessing ? (
<View style={styles.btn}>
<ActivityIndicator color="#fff" />
</View>
) : (
<View style={{gap: 6}}>
{stage === Stages.InputEmail && (
<Button
testID="requestChangeBtn"
type="primary"
onPress={onRequestChange}
accessibilityLabel="Request Change"
accessibilityHint=""
label="Request Change"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
)}
{stage === Stages.ConfirmCode && (
<Button
testID="confirmBtn"
type="primary"
onPress={onConfirm}
accessibilityLabel="Confirm Change"
accessibilityHint=""
label="Confirm Change"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
)}
{stage === Stages.Done && (
<Button
testID="verifyBtn"
type="primary"
onPress={onVerify}
accessibilityLabel="Verify New Email"
accessibilityHint=""
label="Verify New Email"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
)}
<Button
testID="cancelBtn"
type="default"
onPress={() => store.shell.closeModal()}
accessibilityLabel="Cancel"
accessibilityHint=""
label="Cancel"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
</KeyboardAvoidingView>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
error: {
borderRadius: 6,
marginTop: 10,
},
emailContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 12,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 16,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
},
})
+2 -1
View File
@@ -23,6 +23,7 @@ export function Component({
onPressCancel, onPressCancel,
confirmBtnText, confirmBtnText,
confirmBtnStyle, confirmBtnStyle,
cancelBtnText,
}: ConfirmModal) { }: ConfirmModal) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@@ -84,7 +85,7 @@ export function Component({
accessibilityLabel="Cancel" accessibilityLabel="Cancel"
accessibilityHint=""> accessibilityHint="">
<Text type="button-lg" style={pal.textLight}> <Text type="button-lg" style={pal.textLight}>
Cancel {cancelBtnText ?? 'Cancel'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
+1 -1
View File
@@ -266,7 +266,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 10, paddingTop: 10,
fontSize: 16, fontSize: 16,
height: 100, height: 120,
textAlignVertical: 'top', textAlignVertical: 'top',
}, },
btn: { btn: {
+27
View File
@@ -26,6 +26,33 @@ export function Component({}: {}) {
store.shell.closeModal() store.shell.closeModal()
}, [store]) }, [store])
if (store.me.invites === null) {
return (
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
<Text type="title-xl" style={[styles.title, pal.text]}>
Error
</Text>
<Text type="lg" style={[styles.description, pal.text]}>
An error occurred while loading invite codes.
</Text>
<View style={styles.flex1} />
<View
style={[
styles.btnContainer,
isTabletOrDesktop && styles.btnContainerDesktop,
]}>
<Button
type="primary"
label="Done"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onClose}
/>
</View>
</View>
)
}
if (store.me.invites.length === 0) { if (store.me.invites.length === 0) {
return ( return (
<View style={[styles.container, pal.view]} testID="inviteCodesModal"> <View style={[styles.container, pal.view]} testID="inviteCodesModal">
+162
View File
@@ -0,0 +1,162 @@
import React from 'react'
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
import {ScrollView} from './util'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
export const snapPoints = ['50%']
export const Component = observer(function Component({
text,
href,
}: {
text: string
href: string
}) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const potentiallyMisleading = isPossiblyAUrl(text)
const onPressVisit = () => {
store.shell.closeModal()
Linking.openURL(href)
}
return (
<SafeAreaView style={[s.flex1, pal.view]}>
<ScrollView
testID="linkWarningModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
{potentiallyMisleading ? (
<>
<FontAwesomeIcon
icon="circle-exclamation"
color={pal.colors.text}
size={18}
/>
<Text type="title-lg" style={[pal.text, styles.title]}>
Potentially Misleading Link
</Text>
</>
) : (
<Text type="title-lg" style={[pal.text, styles.title]}>
Leaving Bluesky
</Text>
)}
</View>
<View style={{gap: 10}}>
<Text type="lg" style={pal.text}>
This link is taking you to the following website:
</Text>
<LinkBox href={href} />
{potentiallyMisleading && (
<Text type="lg" style={pal.text}>
Make sure this is where you intend to go!
</Text>
)}
</View>
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
<Button
testID="confirmBtn"
type="primary"
onPress={onPressVisit}
accessibilityLabel="Visit Site"
accessibilityHint=""
label="Visit Site"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
<Button
testID="cancelBtn"
type="default"
onPress={() => store.shell.closeModal()}
accessibilityLabel="Cancel"
accessibilityHint=""
label="Cancel"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
</SafeAreaView>
)
})
function LinkBox({href}: {href: string}) {
const pal = usePalette('default')
const [scheme, hostname, rest] = React.useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View style={[pal.view, pal.border, styles.linkBox]}>
<Text type="lg" style={pal.textLight}>
{scheme}
<Text type="lg-bold" style={pal.text}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
},
linkBox: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
gap: 6,
},
})
+16
View File
@@ -30,6 +30,10 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails' import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings' import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount'
import * as LinkWarningModal from './LinkWarning'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
@@ -136,6 +140,18 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'birth-date-settings') { } else if (activeModal?.name === 'birth-date-settings') {
snapPoints = BirthDateSettingsModal.snapPoints snapPoints = BirthDateSettingsModal.snapPoints
element = <BirthDateSettingsModal.Component /> element = <BirthDateSettingsModal.Component />
} else if (activeModal?.name === 'verify-email') {
snapPoints = VerifyEmailModal.snapPoints
element = <VerifyEmailModal.Component {...activeModal} />
} else if (activeModal?.name === 'change-email') {
snapPoints = ChangeEmailModal.snapPoints
element = <ChangeEmailModal.Component />
} else if (activeModal?.name === 'switch-account') {
snapPoints = SwitchAccountModal.snapPoints
element = <SwitchAccountModal.Component />
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else { } else {
return null return null
} }
+11 -2
View File
@@ -28,6 +28,9 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails' import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings' import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as LinkWarningModal from './LinkWarning'
export const ModalsContainer = observer(function ModalsContainer() { export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores() const store = useStores()
@@ -110,6 +113,12 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ModerationDetailsModal.Component {...modal} /> element = <ModerationDetailsModal.Component {...modal} />
} else if (modal.name === 'birth-date-settings') { } else if (modal.name === 'birth-date-settings') {
element = <BirthDateSettingsModal.Component /> element = <BirthDateSettingsModal.Component />
} else if (modal.name === 'verify-email') {
element = <VerifyEmailModal.Component {...modal} />
} else if (modal.name === 'change-email') {
element = <ChangeEmailModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else { } else {
return null return null
} }
@@ -147,11 +156,11 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
container: { container: {
width: 500, width: 600,
// @ts-ignore web only // @ts-ignore web only
maxWidth: '100vw', maxWidth: '100vw',
// @ts-ignore web only // @ts-ignore web only
maxHeight: '100vh', maxHeight: '90vh',
paddingVertical: 20, paddingVertical: 20,
paddingHorizontal: 24, paddingHorizontal: 24,
borderRadius: 8, borderRadius: 8,
+131
View File
@@ -0,0 +1,131 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {Text} from '../util/text/Text'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {UserAvatar} from '../util/UserAvatar'
import {AccountDropdownBtn} from '../util/AccountDropdownBtn'
import {Link} from '../util/Link'
import {makeProfileLink} from 'lib/routes/links'
import {BottomSheetScrollView} from '@gorhom/bottom-sheet'
import {Haptics} from 'lib/haptics'
export const snapPoints = ['40%', '90%']
export function Component({}: {}) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher()
React.useEffect(() => {
Haptics.default()
})
const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked')
store.session.logout()
}, [track, store])
return (
<BottomSheetScrollView
style={[styles.container, pal.view]}
contentContainerStyle={[styles.innerContainer, pal.view]}>
<Text type="title-xl" style={[styles.title, pal.text]}>
Switch Account
</Text>
{isSwitching ? (
<View style={[pal.view, styles.linkCard]}>
<ActivityIndicator />
</View>
) : (
<Link href={makeProfileLink(store.me)} title="Your profile" noFeedback>
<View style={[pal.view, styles.linkCard]}>
<View style={styles.avi}>
<UserAvatar size={40} avatar={store.me.avatar} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{store.me.displayName || store.me.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{store.me.handle}
</Text>
</View>
<TouchableOpacity
testID="signOutBtn"
onPress={isSwitching ? undefined : onPressSignout}
accessibilityRole="button"
accessibilityLabel="Sign out"
accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
<Text type="lg" style={pal.link}>
Sign out
</Text>
</TouchableOpacity>
</View>
</Link>
)}
{store.session.switchableAccounts.map(account => (
<TouchableOpacity
testID={`switchToAccountBtn-${account.handle}`}
key={account.did}
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
onPress={
isSwitching ? undefined : () => onPressSwitchAccount(account)
}
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.handle}`}
accessibilityHint="Switches the account you are logged in to">
<View style={styles.avi}>
<UserAvatar size={40} avatar={account.aviUrl} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text}>
{account.displayName || account.handle}
</Text>
<Text type="sm" style={pal.textLight}>
{account.handle}
</Text>
</View>
<AccountDropdownBtn handle={account.handle} />
</TouchableOpacity>
))}
</BottomSheetScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
innerContainer: {
paddingBottom: 40,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
linkCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
marginBottom: 1,
},
avi: {
marginRight: 12,
},
dimmed: {
opacity: 0.5,
},
})
+323
View File
@@ -0,0 +1,323 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
Pressable,
SafeAreaView,
StyleSheet,
View,
} from 'react-native'
import {Svg, Circle, Path} from 'react-native-svg'
import {ScrollView, TextInput} from './util'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['90%']
enum Stages {
Reminder,
Email,
ConfirmCode,
}
export const Component = observer(function Component({
showReminder,
}: {
showReminder?: boolean
}) {
const pal = usePalette('default')
const store = useStores()
const [stage, setStage] = useState<Stages>(
showReminder ? Stages.Reminder : Stages.Email,
)
const [confirmationCode, setConfirmationCode] = useState<string>('')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const {isMobile} = useWebMediaQueries()
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await store.agent.com.atproto.server.requestEmailConfirmation()
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirm = async () => {
setError('')
setIsProcessing(true)
try {
await store.agent.com.atproto.server.confirmEmail({
email: (store.session.currentSession?.email || '').trim(),
token: confirmationCode.trim(),
})
store.session.updateLocalAccountData({emailConfirmed: true})
Toast.show('Email verified')
store.shell.closeModal()
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onEmailIncorrect = () => {
store.shell.closeModal()
store.shell.openModal({name: 'change-email'})
}
return (
<KeyboardAvoidingView
behavior="padding"
style={[pal.view, styles.container]}>
<SafeAreaView style={s.flex1}>
<ScrollView
testID="verifyEmailModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
{stage === Stages.Reminder && <ReminderIllustration />}
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
{stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
{stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
{stage === Stages.Email ? 'Verify Your Email' : ''}
</Text>
</View>
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
{stage === Stages.Reminder ? (
<>
Your email has not yet been verified. This is an important
security step which we recommend.
</>
) : stage === Stages.Email ? (
<>
This is important in case you ever need to change your email or
reset your password.
</>
) : stage === Stages.ConfirmCode ? (
<>
An email has been sent to{' '}
{store.session.currentSession?.email || ''}. It includes a
confirmation code which you can enter below.
</>
) : (
''
)}
</Text>
{stage === Stages.Email ? (
<>
<View style={styles.emailContainer}>
<FontAwesomeIcon
icon="envelope"
color={pal.colors.text}
size={16}
/>
<Text
type="xl-medium"
style={[pal.text, s.flex1, {minWidth: 0}]}>
{store.session.currentSession?.email || ''}
</Text>
</View>
<Pressable
accessibilityRole="link"
accessibilityLabel="Change my email"
accessibilityHint=""
onPress={onEmailIncorrect}
style={styles.changeEmailLink}>
<Text type="lg" style={pal.link}>
Change
</Text>
</Pressable>
</>
) : stage === Stages.ConfirmCode ? (
<TextInput
testID="confirmCodeInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="XXXXX-XXXXX"
placeholderTextColor={pal.colors.textLight}
value={confirmationCode}
onChangeText={setConfirmationCode}
accessible={true}
accessibilityLabel="Confirmation code"
accessibilityHint=""
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
) : undefined}
{error ? (
<ErrorMessage message={error} style={styles.error} />
) : undefined}
<View style={[styles.btnContainer]}>
{isProcessing ? (
<View style={styles.btn}>
<ActivityIndicator color="#fff" />
</View>
) : (
<View style={{gap: 6}}>
{stage === Stages.Reminder && (
<Button
testID="getStartedBtn"
type="primary"
onPress={() => setStage(Stages.Email)}
accessibilityLabel="Get Started"
accessibilityHint=""
label="Get Started"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
)}
{stage === Stages.Email && (
<>
<Button
testID="sendEmailBtn"
type="primary"
onPress={onSendEmail}
accessibilityLabel="Send Confirmation Email"
accessibilityHint=""
label="Send Confirmation Email"
labelContainerStyle={{
justifyContent: 'center',
padding: 4,
}}
labelStyle={[s.f18]}
/>
<Button
testID="haveCodeBtn"
type="default"
accessibilityLabel="I have a code"
accessibilityHint=""
label="I have a confirmation code"
labelContainerStyle={{
justifyContent: 'center',
padding: 4,
}}
labelStyle={[s.f18]}
onPress={() => setStage(Stages.ConfirmCode)}
/>
</>
)}
{stage === Stages.ConfirmCode && (
<Button
testID="confirmBtn"
type="primary"
onPress={onConfirm}
accessibilityLabel="Confirm"
accessibilityHint=""
label="Confirm"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
)}
<Button
testID="cancelBtn"
type="default"
onPress={() => store.shell.closeModal()}
accessibilityLabel={
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
}
accessibilityHint=""
label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
</KeyboardAvoidingView>
)
})
function ReminderIllustration() {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
return (
<View style={[pal.viewLight, {borderRadius: 8, marginBottom: 20}]}>
<Svg viewBox="0 0 112 84" fill="none" height={200}>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M26 26.4264V55C26 60.5229 30.4772 65 36 65H76C81.5228 65 86 60.5229 86 55V27.4214L63.5685 49.8528C59.6633 53.7581 53.3316 53.7581 49.4264 49.8528L26 26.4264Z"
fill={palInverted.colors.background}
/>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M83.666 19.5784C85.47 21.7297 84.4897 24.7895 82.5044 26.7748L60.669 48.6102C58.3259 50.9533 54.5269 50.9533 52.1838 48.6102L29.9502 26.3766C27.8241 24.2505 26.8952 20.8876 29.0597 18.8005C30.8581 17.0665 33.3045 16 36 16H76C79.0782 16 81.8316 17.3908 83.666 19.5784Z"
fill={palInverted.colors.background}
/>
<Circle cx="82" cy="61" r="13" fill="#20BC07" />
<Path d="M75 61L80 66L89 57" stroke="white" strokeWidth="2" />
</Svg>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
error: {
borderRadius: 6,
marginTop: 10,
},
emailContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 14,
marginTop: 10,
},
changeEmailLink: {
marginHorizontal: 12,
marginBottom: 12,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 16,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
},
})
+49 -9
View File
@@ -22,7 +22,7 @@ import {
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
import {PostThreadModel} from 'state/models/content/post-thread' import {PostThreadModel} from 'state/models/content/post-thread'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {ago} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
@@ -38,6 +38,8 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {TimeElapsed} from '../util/TimeElapsed'
import {isWeb} from 'platform/detection'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@@ -88,7 +90,7 @@ export const FeedItem = observer(function FeedItemImpl({
}, [item]) }, [item])
const onToggleAuthorsExpanded = () => { const onToggleAuthorsExpanded = () => {
setAuthorsExpanded(!isAuthorsExpanded) setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
} }
const authors: Author[] = useMemo(() => { const authors: Author[] = useMemo(() => {
@@ -179,7 +181,6 @@ export const FeedItem = observer(function FeedItemImpl({
} }
return ( return (
// eslint-disable-next-line react-native-a11y/no-nested-touchables
<Link <Link
testID={`feedItem-by-${item.author.handle}`} testID={`feedItem-by-${item.author.handle}`}
style={[ style={[
@@ -211,9 +212,9 @@ export const FeedItem = observer(function FeedItemImpl({
)} )}
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<Pressable <ExpandListPressable
onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined} hasMultipleAuthors={authors.length > 1}
accessible={false}> onToggleAuthorsExpanded={onToggleAuthorsExpanded}>
<CondensedAuthorsList <CondensedAuthorsList
visible={!isAuthorsExpanded} visible={!isAuthorsExpanded}
authors={authors} authors={authors}
@@ -239,9 +240,17 @@ export const FeedItem = observer(function FeedItemImpl({
</> </>
) : undefined} ) : undefined}
<Text style={[pal.text]}> {action}</Text> <Text style={[pal.text]}> {action}</Text>
<Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text> <TimeElapsed timestamp={item.indexedAt}>
{({timeElapsed}) => (
<Text
style={[pal.textLight, styles.pointer]}
title={niceDate(item.indexedAt)}>
{' ' + timeElapsed}
</Text>
)}
</TimeElapsed>
</Text> </Text>
</Pressable> </ExpandListPressable>
{item.isLike || item.isRepost || item.isQuote ? ( {item.isLike || item.isRepost || item.isQuote ? (
<AdditionalPostText additionalPost={item.additionalPost} /> <AdditionalPostText additionalPost={item.additionalPost} />
) : null} ) : null}
@@ -250,6 +259,29 @@ export const FeedItem = observer(function FeedItemImpl({
) )
}) })
function ExpandListPressable({
hasMultipleAuthors,
children,
onToggleAuthorsExpanded,
}: {
hasMultipleAuthors: boolean
children: React.ReactNode
onToggleAuthorsExpanded: () => void
}) {
if (hasMultipleAuthors) {
return (
<Pressable
onPress={onToggleAuthorsExpanded}
style={[styles.expandedAuthorsTrigger]}
accessible={false}>
{children}
</Pressable>
)
} else {
return <>{children}</>
}
}
function CondensedAuthorsList({ function CondensedAuthorsList({
visible, visible,
authors, authors,
@@ -419,6 +451,12 @@ const styles = StyleSheet.create({
overflowHidden: { overflowHidden: {
overflow: 'hidden', overflow: 'hidden',
}, },
pointer: isWeb
? {
// @ts-ignore web only
cursor: 'pointer',
}
: {},
outer: { outer: {
padding: 10, padding: 10,
@@ -466,7 +504,9 @@ const styles = StyleSheet.create({
paddingTop: 4, paddingTop: 4,
paddingLeft: 36, paddingLeft: 36,
}, },
expandedAuthorsTrigger: {
zIndex: 1,
},
expandedAuthorsCloseBtn: { expandedAuthorsCloseBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+1 -1
View File
@@ -75,7 +75,7 @@ function InvitedUser({
<FollowButton <FollowButton
unfollowedType="primary" unfollowedType="primary"
followedType="primary-light" followedType="primary-light"
did={profile.did} profile={profile}
/> />
<Button <Button
testID="dismissBtn" testID="dismissBtn"
+21 -6
View File
@@ -23,7 +23,7 @@ import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isNative, isDesktopWeb} from 'platform/detection' import {isNative} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -78,7 +78,7 @@ export const PostThread = observer(function PostThread({
treeView: boolean treeView: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {isTablet} = useWebMediaQueries() const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null) const ref = useRef<FlatList>(null)
const hasScrolledIntoView = useRef<boolean>(false) const hasScrolledIntoView = useRef<boolean>(false)
const [isRefreshing, setIsRefreshing] = React.useState(false) const [isRefreshing, setIsRefreshing] = React.useState(false)
@@ -189,7 +189,7 @@ export const PostThread = observer(function PostThread({
} else if (item === REPLY_PROMPT) { } else if (item === REPLY_PROMPT) {
return ( return (
<View> <View>
{isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />} {isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
</View> </View>
) )
} else if (item === DELETED) { } else if (item === DELETED) {
@@ -261,7 +261,20 @@ export const PostThread = observer(function PostThread({
} }
return <></> return <></>
}, },
[onRefresh, onPressReply, pal, posts, isTablet, treeView], [
isTablet,
isDesktop,
onPressReply,
pal.border,
pal.viewLight,
pal.textLight,
pal.view,
pal.text,
pal.colors.border,
posts,
onRefresh,
treeView,
],
) )
// loading // loading
@@ -354,7 +367,7 @@ export const PostThread = observer(function PostThread({
data={posts} data={posts}
initialNumToRender={posts.length} initialNumToRender={posts.length}
maintainVisibleContentPosition={ maintainVisibleContentPosition={
isNative && view.isFromCache isNative && view.isFromCache && view.isCachedPostAReply
? MAINTAIN_VISIBLE_CONTENT_POSITION ? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined : undefined
} }
@@ -426,5 +439,7 @@ const styles = StyleSheet.create({
parentSpinner: { parentSpinner: {
paddingVertical: 10, paddingVertical: 10,
}, },
childSpinner: {}, childSpinner: {
paddingBottom: 200,
},
}) })
+97 -93
View File
@@ -34,7 +34,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {TimeElapsed} from 'view/com/util/TimeElapsed' import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {isDesktopWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Tag} from 'view/com/Tag' import {Tag} from 'view/com/Tag'
@@ -52,6 +51,7 @@ export const PostThreadItem = observer(function PostThreadItem({
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [deleted, setDeleted] = React.useState(false) const [deleted, setDeleted] = React.useState(false)
const styles = useStyles()
const record = item.postRecord const record = item.postRecord
const hasEngagement = item.post.likeCount || item.post.repostCount const hasEngagement = item.post.likeCount || item.post.repostCount
@@ -586,6 +586,7 @@ function PostOuterWrapper({
}>) { }>) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const styles = useStyles()
if (treeView && item._depth > 1) { if (treeView && item._depth > 1) {
return ( return (
<View <View
@@ -654,95 +655,98 @@ function ExpandedPostDetails({
) )
} }
const styles = StyleSheet.create({ const useStyles = () => {
outer: { const {isDesktop} = useWebMediaQueries()
borderTopWidth: 1, return StyleSheet.create({
paddingLeft: 8, outer: {
}, borderTopWidth: 1,
outerHighlighted: { paddingLeft: 8,
paddingTop: 16, },
paddingLeft: 8, outerHighlighted: {
paddingRight: 8, paddingTop: 16,
}, paddingLeft: 8,
noTopBorder: { paddingRight: 8,
borderTopWidth: 0, },
}, noTopBorder: {
layout: { borderTopWidth: 0,
flexDirection: 'row', },
gap: 10, layout: {
paddingLeft: 8, flexDirection: 'row',
}, gap: 10,
layoutAvi: {}, paddingLeft: 8,
layoutContent: { },
flex: 1, layoutAvi: {},
paddingRight: 10, layoutContent: {
}, flex: 1,
meta: { paddingRight: 10,
flexDirection: 'row', },
paddingTop: 2, meta: {
paddingBottom: 2, flexDirection: 'row',
}, paddingTop: 2,
metaExpandedLine1: { paddingBottom: 2,
paddingTop: 5, },
paddingBottom: 0, metaExpandedLine1: {
}, paddingTop: 5,
metaItem: { paddingBottom: 0,
paddingRight: 5, },
maxWidth: isDesktopWeb ? 380 : 220, metaItem: {
}, paddingRight: 5,
alert: { maxWidth: isDesktop ? 380 : 220,
marginBottom: 6, },
}, alert: {
postTextContainer: { marginBottom: 6,
flexDirection: 'row', },
alignItems: 'center', postTextContainer: {
flexWrap: 'wrap', flexDirection: 'row',
paddingBottom: 4, alignItems: 'center',
paddingRight: 10, flexWrap: 'wrap',
}, paddingBottom: 4,
postTextLargeContainer: { paddingRight: 10,
paddingHorizontal: 0, },
paddingBottom: 10, postTextLargeContainer: {
}, paddingHorizontal: 0,
translateLink: { paddingBottom: 10,
marginBottom: 6, },
}, translateLink: {
contentHider: { marginBottom: 6,
marginBottom: 6, },
}, contentHider: {
contentHiderChild: { marginBottom: 6,
marginTop: 6, },
}, contentHiderChild: {
expandedInfo: { marginTop: 6,
flexDirection: 'row', },
padding: 10, expandedInfo: {
borderTopWidth: 1, flexDirection: 'row',
borderBottomWidth: 1, padding: 10,
marginTop: 5, borderTopWidth: 1,
marginBottom: 15, borderBottomWidth: 1,
}, marginTop: 5,
expandedInfoItem: { marginBottom: 15,
marginRight: 10, },
}, expandedInfoItem: {
loadMore: { marginRight: 10,
flexDirection: 'row', },
alignItems: 'center', loadMore: {
justifyContent: 'flex-start', flexDirection: 'row',
gap: 4, alignItems: 'center',
paddingHorizontal: 20, justifyContent: 'flex-start',
}, gap: 4,
replyLine: { paddingHorizontal: 20,
width: 2, },
marginLeft: 'auto', replyLine: {
marginRight: 'auto', width: 2,
}, marginLeft: 'auto',
cursor: { marginRight: 'auto',
// @ts-ignore web only },
cursor: 'pointer', cursor: {
}, // @ts-ignore web only
tag: { cursor: 'pointer',
paddingVertical: 4, },
paddingHorizontal: 8, tag: {
borderRadius: 4, paddingVertical: 4,
}, paddingHorizontal: 8,
}) borderRadius: 4,
},
})
}
+6 -2
View File
@@ -33,6 +33,7 @@ export const Feed = observer(function Feed({
onScroll, onScroll,
scrollEventThrottle, scrollEventThrottle,
renderEmptyState, renderEmptyState,
renderEndOfFeed,
testID, testID,
headerOffset = 0, headerOffset = 0,
ListHeaderComponent, ListHeaderComponent,
@@ -45,6 +46,7 @@ export const Feed = observer(function Feed({
onScroll?: OnScrollCb onScroll?: OnScrollCb
scrollEventThrottle?: number scrollEventThrottle?: number
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number
ListHeaderComponent?: () => JSX.Element ListHeaderComponent?: () => JSX.Element
@@ -142,14 +144,16 @@ export const Feed = observer(function Feed({
const FeedFooter = React.useCallback( const FeedFooter = React.useCallback(
() => () =>
feed.isLoading ? ( feed.isLoadingMore ? (
<View style={styles.feedFooter}> <View style={styles.feedFooter}>
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
renderEndOfFeed()
) : ( ) : (
<View /> <View />
), ),
[feed], [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
) )
return ( return (
+51 -47
View File
@@ -28,60 +28,73 @@ export function FollowingEmptyState() {
}, [navigation]) }, [navigation])
const onPressDiscoverFeeds = React.useCallback(() => { const onPressDiscoverFeeds = React.useCallback(() => {
navigation.navigate('Feeds') if (isWeb) {
navigation.navigate('Feeds')
} else {
navigation.navigate('FeedsTab')
navigation.popToTop()
}
}, [navigation]) }, [navigation])
return ( return (
<View style={styles.emptyContainer}> <View style={styles.container}>
<View style={styles.emptyIconContainer}> <View style={styles.inner}>
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> <View style={styles.iconContainer}>
</View> <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
<Text type="xl-medium" style={[s.textCenter, pal.text]}> </View>
Your following feed is empty! Find some accounts to follow to fix this. <Text type="xl-medium" style={[s.textCenter, pal.text]}>
</Text> Your following feed is empty! Follow more users to see what's
<Button happening.
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
</Text> </Text>
<FontAwesomeIcon <Button
icon="angle-right" type="inverted"
style={palInverted.text as FontAwesomeIconStyle} style={styles.emptyBtn}
size={14} onPress={onPressFindAccounts}>
/> <Text type="lg-medium" style={palInverted.text}>
</Button> Find accounts to follow
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
You can also discover new Custom Feeds to follow. You can also discover new Custom Feeds to follow.
</Text>
<Button
type="inverted"
style={[styles.emptyBtn, s.mt10]}
onPress={onPressDiscoverFeeds}>
<Text type="lg-medium" style={palInverted.text}>
Discover new custom feeds
</Text> </Text>
<FontAwesomeIcon <Button
icon="angle-right" type="inverted"
style={palInverted.text as FontAwesomeIconStyle} style={[styles.emptyBtn, s.mt10]}
size={14} onPress={onPressDiscoverFeeds}>
/> <Text type="lg-medium" style={palInverted.text}>
</Button> Discover new custom feeds
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
emptyContainer: { container: {
height: '100%', height: '100%',
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 40, paddingVertical: 40,
paddingHorizontal: 30, paddingHorizontal: 30,
}, },
emptyIconContainer: { inner: {
maxWidth: 460,
},
iconContainer: {
marginBottom: 16, marginBottom: 16,
}, },
emptyIcon: { icon: {
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
@@ -94,13 +107,4 @@ const styles = StyleSheet.create({
paddingHorizontal: 24, paddingHorizontal: 24,
borderRadius: 30, borderRadius: 30,
}, },
feedsTip: {
position: 'absolute',
left: 22,
},
feedsTipArrow: {
marginLeft: 32,
marginTop: 8,
},
}) })
+100
View File
@@ -0,0 +1,100 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
export function FollowingEndOfFeed() {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const onPressFindAccounts = React.useCallback(() => {
if (isWeb) {
navigation.navigate('Search', {})
} else {
navigation.navigate('SearchTab')
navigation.popToTop()
}
}, [navigation])
const onPressDiscoverFeeds = React.useCallback(() => {
if (isWeb) {
navigation.navigate('Feeds')
} else {
navigation.navigate('FeedsTab')
navigation.popToTop()
}
}, [navigation])
return (
<View style={[styles.container, pal.border]}>
<View style={styles.inner}>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
You've reached the end of your feed! Find some more accounts to
follow.
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
You can also discover new Custom Feeds to follow.
</Text>
<Button
type="inverted"
style={[styles.emptyBtn, s.mt10]}
onPress={onPressDiscoverFeeds}>
<Text type="lg-medium" style={palInverted.text}>
Discover new custom feeds
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
paddingTop: 40,
paddingBottom: 80,
paddingHorizontal: 30,
borderTopWidth: 1,
},
inner: {
maxWidth: 460,
},
emptyBtn: {
marginVertical: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 18,
paddingHorizontal: 24,
borderRadius: 30,
},
})
+5 -4
View File
@@ -1,25 +1,26 @@
import React from 'react' import React from 'react'
import {StyleProp, TextStyle, View} from 'react-native' import {StyleProp, TextStyle, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api'
import {Button, ButtonType} from '../util/forms/Button' import {Button, ButtonType} from '../util/forms/Button'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
import {useFollowDid} from 'lib/hooks/useFollowDid' import {useFollowProfile} from 'lib/hooks/useFollowProfile'
export const FollowButton = observer(function FollowButtonImpl({ export const FollowButton = observer(function FollowButtonImpl({
unfollowedType = 'inverted', unfollowedType = 'inverted',
followedType = 'default', followedType = 'default',
did, profile,
onToggleFollow, onToggleFollow,
labelStyle, labelStyle,
}: { }: {
unfollowedType?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType followedType?: ButtonType
did: string profile: AppBskyActorDefs.ProfileViewBasic
onToggleFollow?: (v: boolean) => void onToggleFollow?: (v: boolean) => void
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
}) { }) {
const {state, following, toggle} = useFollowDid({did}) const {state, following, toggle} = useFollowProfile(profile)
const onPress = React.useCallback(async () => { const onPress = React.useCallback(async () => {
try { try {
+1 -1
View File
@@ -200,7 +200,7 @@ export const ProfileCardWithFollowBtn = observer(
noBorder={noBorder} noBorder={noBorder}
followers={followers} followers={followers}
renderButton={ renderButton={
isMe ? undefined : () => <FollowButton did={profile.did} /> isMe ? undefined : () => <FollowButton profile={profile} />
} }
/> />
) )
+2 -2
View File
@@ -392,8 +392,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
{ {
paddingHorizontal: 10, paddingHorizontal: 10,
backgroundColor: showSuggestedFollows backgroundColor: showSuggestedFollows
? colors.blue3 ? pal.colors.text
: pal.viewLight.backgroundColor, : pal.colors.backgroundLight,
}, },
]} ]}
accessibilityRole="button" accessibilityRole="button"
@@ -19,7 +19,7 @@ import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {useFollowDid} from 'lib/hooks/useFollowDid' import {useFollowProfile} from 'lib/hooks/useFollowProfile'
import {Button} from 'view/com/util/forms/Button' import {Button} from 'view/com/util/forms/Button'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
@@ -83,7 +83,7 @@ export function ProfileHeaderSuggestedFollows({
return [] return []
} }
store.me.follows.hydrateProfiles(suggestions) store.me.follows.hydrateMany(suggestions)
return suggestions return suggestions
} catch (e) { } catch (e) {
@@ -218,7 +218,7 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {following, toggle} = useFollowDid({did: profile.did}) const {following, toggle} = useFollowProfile(profile)
const moderation = moderateProfile(profile, store.preferences.moderationOpts) const moderation = moderateProfile(profile, store.preferences.moderationOpts)
const onPress = React.useCallback(async () => { const onPress = React.useCallback(async () => {
+1 -1
View File
@@ -93,7 +93,7 @@ export function HeaderWithInput({
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery} onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery} onSubmitEditing={onSubmitQuery}
autoFocus={isMobile} autoFocus={false}
accessibilityRole="search" accessibilityRole="search"
accessibilityLabel="Search" accessibilityLabel="Search"
accessibilityHint="" accessibilityHint=""
+84 -48
View File
@@ -2,7 +2,7 @@ import React, {forwardRef, ForwardedRef} from 'react'
import {RefreshControl, StyleSheet, View} from 'react-native' import {RefreshControl, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {FoafsModel} from 'state/models/discovery/foafs' import {FoafsModel} from 'state/models/discovery/foafs'
import { import {
SuggestedActorsModel, SuggestedActorsModel,
@@ -10,11 +10,12 @@ import {
} from 'state/models/discovery/suggested-actors' } from 'state/models/discovery/suggested-actors'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
interface Heading { interface Heading {
_reactKey: string _reactKey: string
@@ -36,7 +37,16 @@ interface ProfileView {
type: 'profile-view' type: 'profile-view'
view: AppBskyActorDefs.ProfileViewBasic view: AppBskyActorDefs.ProfileViewBasic
} }
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView interface LoadingPlaceholder {
_reactKey: string
type: 'loading-placeholder'
}
type Item =
| Heading
| RefWrapper
| SuggestWrapper
| ProfileView
| LoadingPlaceholder
// FIXME(dan): Figure out why the false positives // FIXME(dan): Figure out why the false positives
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
@@ -57,23 +67,6 @@ export const Suggestions = observer(
const data = React.useMemo(() => { const data = React.useMemo(() => {
let items: Item[] = [] let items: Item[] = []
if (foafs.popular.length > 0) {
items = items
.concat([
{
_reactKey: '__popular_heading__',
type: 'heading',
title: 'In Your Network',
},
])
.concat(
foafs.popular.map(ref => ({
_reactKey: `popular-${ref.did}`,
type: 'ref',
ref,
})),
)
}
if (suggestedActors.hasContent) { if (suggestedActors.hasContent) {
items = items items = items
.concat([ .concat([
@@ -90,34 +83,73 @@ export const Suggestions = observer(
suggested, suggested,
})), })),
) )
} else if (suggestedActors.isLoading) {
items = items.concat([
{
_reactKey: '__suggested_heading__',
type: 'heading',
title: 'Suggested Follows',
},
{_reactKey: '__suggested_loading__', type: 'loading-placeholder'},
])
} }
for (const source of foafs.sources) { if (foafs.isLoading) {
const item = foafs.foafs.get(source) items = items.concat([
if (!item || item.follows.length === 0) { {
continue _reactKey: '__popular_heading__',
type: 'heading',
title: 'In Your Network',
},
{_reactKey: '__foafs_loading__', type: 'loading-placeholder'},
])
} else {
if (foafs.popular.length > 0) {
items = items
.concat([
{
_reactKey: '__popular_heading__',
type: 'heading',
title: 'In Your Network',
},
])
.concat(
foafs.popular.map(ref => ({
_reactKey: `popular-${ref.did}`,
type: 'ref',
ref,
})),
)
}
for (const source of foafs.sources) {
const item = foafs.foafs.get(source)
if (!item || item.follows.length === 0) {
continue
}
items = items
.concat([
{
_reactKey: `__${item.did}_heading__`,
type: 'heading',
title: `Followed by ${sanitizeDisplayName(
item.displayName || sanitizeHandle(item.handle),
)}`,
},
])
.concat(
item.follows.slice(0, 10).map(view => ({
_reactKey: `${item.did}-${view.did}`,
type: 'profile-view',
view,
})),
)
} }
items = items
.concat([
{
_reactKey: `__${item.did}_heading__`,
type: 'heading',
title: `Followed by ${sanitizeDisplayName(
item.displayName || sanitizeHandle(item.handle),
)}`,
},
])
.concat(
item.follows.slice(0, 10).map(view => ({
_reactKey: `${item.did}-${view.did}`,
type: 'profile-view',
view,
})),
)
} }
return items return items
}, [ }, [
foafs.isLoading,
foafs.popular, foafs.popular,
suggestedActors.isLoading,
suggestedActors.hasContent, suggestedActors.hasContent,
suggestedActors.suggestions, suggestedActors.suggestions,
foafs.sources, foafs.sources,
@@ -183,18 +215,21 @@ export const Suggestions = observer(
</View> </View>
) )
} }
if (item.type === 'loading-placeholder') {
return (
<View>
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
</View>
)
}
return null return null
}, },
[pal], [pal],
) )
if (foafs.isLoading || suggestedActors.isLoading) {
return (
<CenteredView>
<ProfileCardFeedLoadingPlaceholder />
</CenteredView>
)
}
return ( return (
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
@@ -210,6 +245,7 @@ export const Suggestions = observer(
} }
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
contentContainerStyle={s.contentContainer}
/> />
) )
}), }),
+46
View File
@@ -0,0 +1,46 @@
import React from 'react'
import {Pressable} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import * as Toast from '../../com/util/Toast'
export function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores()
const pal = usePalette('default')
const items: DropdownItem[] = [
{
label: 'Remove account',
onPress: () => {
store.session.removeAccount(handle)
Toast.show('Account removed from quick access')
},
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: 'trash',
},
},
]
return (
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown
testID="accountSettingsDropdownBtn"
items={items}
accessibilityLabel="Account options"
accessibilityHint="">
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</NativeDropdown>
</Pressable>
)
}
+2 -1
View File
@@ -22,7 +22,7 @@ export function EmptyState({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View testID={testID} style={[styles.container, style]}> <View testID={testID} style={[styles.container, pal.border, style]}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
{icon === 'user-group' ? ( {icon === 'user-group' ? (
<UserGroupIcon size="64" style={styles.icon} /> <UserGroupIcon size="64" style={styles.icon} />
@@ -50,6 +50,7 @@ const styles = StyleSheet.create({
container: { container: {
paddingVertical: 20, paddingVertical: 20,
paddingHorizontal: 36, paddingHorizontal: 36,
borderTopWidth: 1,
}, },
iconContainer: { iconContainer: {
flexDirection: 'row', flexDirection: 'row',
+1 -1
View File
@@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> {
public render() { public render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<CenteredView> <CenteredView style={{height: '100%', flex: 1}}>
<ErrorScreen <ErrorScreen
title="Oh no!" title="Oh no!"
message="There was an unexpected issue in the application. Please let us know if this happened to you!" message="There was an unexpected issue in the application. Please let us know if this happened to you!"
+69 -56
View File
@@ -4,13 +4,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {Text} from './text/Text' import {Text} from './text/Text'
import {TextLink} from './Link' import {TextLink} from './Link'
import {isDesktopWeb} from 'platform/detection'
import { import {
H1 as ExpoH1, H1 as ExpoH1,
H2 as ExpoH2, H2 as ExpoH2,
H3 as ExpoH3, H3 as ExpoH3,
H4 as ExpoH4, H4 as ExpoH4,
} from '@expo/html-elements' } from '@expo/html-elements'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
/** /**
* These utilities are used to define long documents in an html-like * These utilities are used to define long documents in an html-like
@@ -27,30 +27,35 @@ interface IsChildProps {
// | React.ReactNode // | React.ReactNode
export function H1({children}: React.PropsWithChildren<{}>) { export function H1({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
const typography = useTheme().typography['title-xl'] const typography = useTheme().typography['title-xl']
return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1> return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
} }
export function H2({children}: React.PropsWithChildren<{}>) { export function H2({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
const typography = useTheme().typography['title-lg'] const typography = useTheme().typography['title-lg']
return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2> return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
} }
export function H3({children}: React.PropsWithChildren<{}>) { export function H3({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
const typography = useTheme().typography.title const typography = useTheme().typography.title
return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3> return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
} }
export function H4({children}: React.PropsWithChildren<{}>) { export function H4({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
const typography = useTheme().typography['title-sm'] const typography = useTheme().typography['title-sm']
return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4> return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
} }
export function P({children}: React.PropsWithChildren<{}>) { export function P({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<Text type="md" style={[pal.text, styles.p]}> <Text type="md" style={[pal.text, styles.p]}>
@@ -60,6 +65,7 @@ export function P({children}: React.PropsWithChildren<{}>) {
} }
export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) { export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
const styles = useStyles()
return ( return (
<View style={[styles.ul, isChild && styles.ulChild]}> <View style={[styles.ul, isChild && styles.ulChild]}>
{markChildProps(children)} {markChildProps(children)}
@@ -68,6 +74,7 @@ export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
} }
export function OL({children, isChild}: React.PropsWithChildren<IsChildProps>) { export function OL({children, isChild}: React.PropsWithChildren<IsChildProps>) {
const styles = useStyles()
return ( return (
<View style={[styles.ol, isChild && styles.olChild]}> <View style={[styles.ol, isChild && styles.olChild]}>
{markChildProps(children)} {markChildProps(children)}
@@ -79,6 +86,7 @@ export function LI({
children, children,
value, value,
}: React.PropsWithChildren<{value?: string}>) { }: React.PropsWithChildren<{value?: string}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={styles.li}> <View style={styles.li}>
@@ -91,6 +99,7 @@ export function LI({
} }
export function A({children, href}: React.PropsWithChildren<{href: string}>) { export function A({children, href}: React.PropsWithChildren<{href: string}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<TextLink <TextLink
@@ -112,6 +121,7 @@ export function STRONG({children}: React.PropsWithChildren<{}>) {
} }
export function EM({children}: React.PropsWithChildren<{}>) { export function EM({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<Text type="md" style={[pal.text, styles.em]}> <Text type="md" style={[pal.text, styles.em]}>
@@ -132,58 +142,61 @@ function markChildProps(children: React.ReactNode) {
}) })
} }
const styles = StyleSheet.create({ const useStyles = () => {
h1: { const {isDesktop} = useWebMediaQueries()
marginTop: 20, return StyleSheet.create({
marginBottom: 10, h1: {
letterSpacing: 0.8, marginTop: 20,
}, marginBottom: 10,
h2: { letterSpacing: 0.8,
marginTop: 20, },
marginBottom: 10, h2: {
letterSpacing: 0.8, marginTop: 20,
}, marginBottom: 10,
h3: { letterSpacing: 0.8,
marginTop: 0, },
marginBottom: 10, h3: {
}, marginTop: 0,
h4: { marginBottom: 10,
marginTop: 0, },
marginBottom: 10, h4: {
fontWeight: 'bold', marginTop: 0,
}, marginBottom: 10,
p: { fontWeight: 'bold',
marginBottom: 10, },
}, p: {
ul: { marginBottom: 10,
marginBottom: 10, },
paddingLeft: isDesktopWeb ? 18 : 4, ul: {
}, marginBottom: 10,
ulChild: { paddingLeft: isDesktop ? 18 : 4,
paddingTop: 10, },
marginBottom: 0, ulChild: {
}, paddingTop: 10,
ol: { marginBottom: 0,
marginBottom: 10, },
paddingLeft: isDesktopWeb ? 18 : 4, ol: {
}, marginBottom: 10,
olChild: { paddingLeft: isDesktop ? 18 : 4,
paddingTop: 10, },
marginBottom: 0, olChild: {
}, paddingTop: 10,
li: { marginBottom: 0,
flexDirection: 'row', },
paddingRight: 20, li: {
marginBottom: 10, flexDirection: 'row',
}, paddingRight: 20,
liBullet: { marginBottom: 10,
paddingRight: 10, },
}, liBullet: {
liText: {}, paddingRight: 10,
a: { },
marginBottom: 10, liText: {},
}, a: {
em: { marginBottom: 10,
fontStyle: 'italic', },
}, em: {
}) fontStyle: 'italic',
},
})
}
+28 -4
View File
@@ -23,11 +23,16 @@ import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes' import {router} from '../../../routes'
import {useStores, RootStoreModel} from 'state/index' import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers' import {
import {isAndroid, isDesktopWeb} from 'platform/detection' convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from 'lib/strings/url-helpers'
import {isAndroid} from 'platform/detection'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover' import {PressableWithHover} from './PressableWithHover'
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
type Event = type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -142,6 +147,7 @@ export const TextLink = observer(function TextLink({
dataSet, dataSet,
title, title,
onPress, onPress,
warnOnMismatchingLabel,
...orgProps ...orgProps
}: { }: {
testID?: string testID?: string
@@ -153,13 +159,29 @@ export const TextLink = observer(function TextLink({
lineHeight?: number lineHeight?: number
dataSet?: any dataSet?: any
title?: string title?: string
warnOnMismatchingLabel?: boolean
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
if (warnOnMismatchingLabel && typeof text !== 'string') {
console.error('Unable to detect mismatching label')
}
props.onPress = React.useCallback( props.onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
const requiresWarning =
warnOnMismatchingLabel &&
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
store.shell.openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
href,
})
}
if (onPress) { if (onPress) {
e?.preventDefault?.() e?.preventDefault?.()
// @ts-ignore function signature differs by platform -prf // @ts-ignore function signature differs by platform -prf
@@ -167,7 +189,7 @@ export const TextLink = observer(function TextLink({
} }
return onPressInner(store, navigation, sanitizeUrl(href), e) return onPressInner(store, navigation, sanitizeUrl(href), e)
}, },
[onPress, store, navigation, href], [onPress, store, navigation, href, text, warnOnMismatchingLabel],
) )
const hrefAttrs = useMemo(() => { const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href) const isExternal = isExternalUrl(href)
@@ -224,7 +246,9 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
lineHeight, lineHeight,
...props ...props
}: DesktopWebTextLinkProps) { }: DesktopWebTextLinkProps) {
if (isDesktopWeb) { const {isDesktop} = useWebMediaQueries()
if (isDesktop) {
return ( return (
<TextLink <TextLink
testID={testID} testID={testID}
+3
View File
@@ -174,6 +174,9 @@ export function UserAvatar({
aspect: [1, 1], aspect: [1, 1],
}) })
const item = items[0] const item = items[0]
if (!item) {
return
}
const croppedImage = await openCropper(store, { const croppedImage = await openCropper(store, {
mediaType: 'photo', mediaType: 'photo',
+3
View File
@@ -69,6 +69,9 @@ export function UserBanner({
return return
} }
const items = await openPicker() const items = await openPicker()
if (!items[0]) {
return
}
onSelectNewBanner?.( onSelectNewBanner?.(
await openCropper(store, { await openCropper(store, {
+12 -2
View File
@@ -42,6 +42,7 @@ export function Button({
type = 'primary', type = 'primary',
label, label,
style, style,
labelContainerStyle,
labelStyle, labelStyle,
onPress, onPress,
children, children,
@@ -55,6 +56,7 @@ export function Button({
type?: ButtonType type?: ButtonType
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
labelContainerStyle?: StyleProp<ViewStyle>
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
onPress?: () => void | Promise<void> onPress?: () => void | Promise<void>
testID?: string testID?: string
@@ -173,7 +175,7 @@ export function Button({
} }
return ( return (
<View style={styles.labelContainer}> <View style={[styles.labelContainer, labelContainerStyle]}>
{label && withLoading && isLoading ? ( {label && withLoading && isLoading ? (
<ActivityIndicator size={12} color={typeLabelStyle.color} /> <ActivityIndicator size={12} color={typeLabelStyle.color} />
) : null} ) : null}
@@ -182,7 +184,15 @@ export function Button({
</Text> </Text>
</View> </View>
) )
}, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) }, [
children,
label,
withLoading,
isLoading,
labelContainerStyle,
typeLabelStyle,
labelStyle,
])
return ( return (
<Pressable <Pressable
+1
View File
@@ -91,6 +91,7 @@ export function RichText({
href={link.uri} href={link.uri}
style={[style, lineHeightStyle, pal.link]} style={[style, lineHeightStyle, pal.link]}
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
warnOnMismatchingLabel
/>, />,
) )
} else if (tag && AppBskyRichtextFacet.validateTag(tag).success) { } else if (tag && AppBskyRichtextFacet.validateTag(tag).success) {
+9
View File
@@ -13,6 +13,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {Feed} from '../com/posts/Feed' import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {FeedsTabBar} from '../com/pager/FeedsTabBar'
@@ -110,6 +111,10 @@ export const HomeScreen = withAuthRequired(
return <FollowingEmptyState /> return <FollowingEmptyState />
}, []) }, [])
const renderFollowingEndOfFeed = React.useCallback(() => {
return <FollowingEndOfFeed />
}, [])
const renderCustomFeedEmptyState = React.useCallback(() => { const renderCustomFeedEmptyState = React.useCallback(() => {
return <CustomFeedEmptyState /> return <CustomFeedEmptyState />
}, []) }, [])
@@ -127,6 +132,7 @@ export const HomeScreen = withAuthRequired(
isPageFocused={selectedPage === 0} isPageFocused={selectedPage === 0}
feed={store.me.mainFeed} feed={store.me.mainFeed}
renderEmptyState={renderFollowingEmptyState} renderEmptyState={renderFollowingEmptyState}
renderEndOfFeed={renderFollowingEndOfFeed}
/> />
{customFeeds.map((f, index) => { {customFeeds.map((f, index) => {
return ( return (
@@ -149,11 +155,13 @@ const FeedPage = observer(function FeedPageImpl({
isPageFocused, isPageFocused,
feed, feed,
renderEmptyState, renderEmptyState,
renderEndOfFeed,
}: { }: {
testID?: string testID?: string
feed: PostsFeedModel feed: PostsFeedModel
isPageFocused: boolean isPageFocused: boolean
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@@ -307,6 +315,7 @@ const FeedPage = observer(function FeedPageImpl({
onScroll={onMainScroll} onScroll={onMainScroll}
scrollEventThrottle={100} scrollEventThrottle={100}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset} headerOffset={headerOffset}
/> />
+1
View File
@@ -71,6 +71,7 @@ export const NotificationsScreen = withAuthRequired(
} }
}, [store, screen, onPressLoadLatest]), }, [store, screen, onPressLoadLatest]),
) )
useTabFocusEffect( useTabFocusEffect(
'Notifications', 'Notifications',
React.useCallback( React.useCallback(
+6 -6
View File
@@ -148,18 +148,18 @@ export const SearchScreen = withAuthRequired(
style={pal.view} style={pal.view}
onScroll={onMainScroll} onScroll={onMainScroll}
scrollEventThrottle={100}> scrollEventThrottle={100}>
{query && autocompleteView.searchRes.length ? ( {query && autocompleteView.suggestions.length ? (
<> <>
{autocompleteView.searchRes.map((profile, index) => ( {autocompleteView.suggestions.map((suggestion, index) => (
<ProfileCard <ProfileCard
key={profile.did} key={suggestion.did}
testID={`searchAutoCompleteResult-${profile.handle}`} testID={`searchAutoCompleteResult-${suggestion.handle}`}
profile={profile} profile={suggestion}
noBorder={index === 0} noBorder={index === 0}
/> />
))} ))}
</> </>
) : query && !autocompleteView.searchRes.length ? ( ) : query && !autocompleteView.suggestions.length ? (
<View> <View>
<Text style={[pal.textLight, styles.searchPrompt]}> <Text style={[pal.textLight, styles.searchPrompt]}>
No results found for {autocompleteView.prefix} No results found for {autocompleteView.prefix}
+123 -92
View File
@@ -3,8 +3,8 @@ import {
ActivityIndicator, ActivityIndicator,
Linking, Linking,
Platform, Platform,
Pressable,
StyleSheet, StyleSheet,
Pressable,
TextStyle, TextStyle,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -36,22 +36,21 @@ import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette' import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {AccountData} from 'state/models/session' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {HandIcon, HashtagIcon} from 'lib/icons' import {HandIcon, HashtagIcon} from 'lib/icons'
import {formatCount} from 'view/com/util/numeric/format' import {formatCount} from 'view/com/util/numeric/format'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {reset as resetNavigation} from '../../Navigation'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
// TEMPORARY (APP-700) // TEMPORARY (APP-700)
// remove after backend testing finishes // remove after backend testing finishes
// -prf // -prf
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header' import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
import {STATUS_PAGE_URL} from 'lib/constants' import {STATUS_PAGE_URL} from 'lib/constants'
import {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired( export const SettingsScreen = withAuthRequired(
@@ -61,7 +60,8 @@ export const SettingsScreen = withAuthRequired(
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const [isSwitching, setIsSwitching] = React.useState(false) const [isSwitching, setIsSwitching, onPressSwitchAccount] =
useAccountSwitcher()
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
store.agent, store.agent,
) )
@@ -91,25 +91,6 @@ export const SettingsScreen = withAuthRequired(
}, [screen, store]), }, [screen, store]),
) )
const onPressSwitchAccount = React.useCallback(
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
}
setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
},
[track, setIsSwitching, navigation, store],
)
const onPressAddAccount = React.useCallback(() => { const onPressAddAccount = React.useCallback(() => {
track('Settings:AddAccountButtonClicked') track('Settings:AddAccountButtonClicked')
navigation.navigate('HomeTab') navigation.navigate('HomeTab')
@@ -219,10 +200,25 @@ export const SettingsScreen = withAuthRequired(
<View style={[styles.infoLine]}> <View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}> <Text type="lg-medium" style={pal.text}>
Email:{' '} Email:{' '}
<Text type="lg" style={pal.text}>
{store.session.currentSession?.email}
</Text>
</Text> </Text>
{!store.session.emailNeedsConfirmation && (
<>
<FontAwesomeIcon
icon="check"
size={10}
style={{color: colors.green3, marginRight: 2}}
/>
</>
)}
<Text type="lg" style={pal.text}>
{store.session.currentSession?.email}{' '}
</Text>
<Link
onPress={() => store.shell.openModal({name: 'change-email'})}>
<Text type="lg" style={pal.link}>
Change
</Text>
</Link>
</View> </View>
<View style={[styles.infoLine]}> <View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}> <Text type="lg-medium" style={pal.text}>
@@ -238,6 +234,7 @@ export const SettingsScreen = withAuthRequired(
</Link> </Link>
</View> </View>
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<EmailConfirmationNotice />
</> </>
) : null} ) : null}
<View style={[s.flexRow, styles.heading]}> <View style={[s.flexRow, styles.heading]}>
@@ -325,37 +322,45 @@ export const SettingsScreen = withAuthRequired(
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}> {store.me.invitesAvailable !== null && (
Invite a Friend <>
</Text> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<TouchableOpacity Invite a Friend
testID="inviteFriendBtn" </Text>
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} <TouchableOpacity
onPress={isSwitching ? undefined : onPressInviteCodes} testID="inviteFriendBtn"
accessibilityRole="button" style={[
accessibilityLabel="Invite" styles.linkCard,
accessibilityHint="Opens invite code list"> pal.view,
<View isSwitching && styles.dimmed,
style={[ ]}
styles.iconContainer, onPress={isSwitching ? undefined : onPressInviteCodes}
store.me.invitesAvailable > 0 ? primaryBg : pal.btn, accessibilityRole="button"
]}> accessibilityLabel="Invite"
<FontAwesomeIcon accessibilityHint="Opens invite code list">
icon="ticket" <View
style={ style={[
(store.me.invitesAvailable > 0 styles.iconContainer,
? primaryText store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
: pal.text) as FontAwesomeIconStyle ]}>
} <FontAwesomeIcon
/> icon="ticket"
</View> style={
<Text (store.me.invitesAvailable > 0
type="lg" ? primaryText
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> : pal.text) as FontAwesomeIconStyle
{formatCount(store.me.invitesAvailable)} invite{' '} }
{pluralize(store.me.invitesAvailable, 'code')} available />
</Text> </View>
</TouchableOpacity> <Text
type="lg"
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
{formatCount(store.me.invitesAvailable)} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} available
</Text>
</TouchableOpacity>
</>
)}
<View style={styles.spacer20} /> <View style={styles.spacer20} />
@@ -630,40 +635,66 @@ export const SettingsScreen = withAuthRequired(
}), }),
) )
function AccountDropdownBtn({handle}: {handle: string}) { const EmailConfirmationNotice = observer(
const store = useStores() function EmailConfirmationNoticeImpl() {
const pal = usePalette('default') const pal = usePalette('default')
const items: DropdownItem[] = [ const palInverted = usePalette('inverted')
{ const store = useStores()
label: 'Remove account', const {isMobile} = useWebMediaQueries()
onPress: () => {
store.session.removeAccount(handle) if (!store.session.emailNeedsConfirmation) {
Toast.show('Account removed from quick access') return null
}, }
icon: {
ios: { return (
name: 'trash', <View style={{marginBottom: 20}}>
}, <Text type="xl-bold" style={[pal.text, styles.heading]}>
android: 'ic_delete', Verify email
web: 'trash', </Text>
}, <View
}, style={[
] {
return ( paddingVertical: isMobile ? 12 : 0,
<Pressable accessibilityRole="button" style={s.pl10}> paddingHorizontal: 18,
<NativeDropdown },
testID="accountSettingsDropdownBtn" pal.view,
items={items} ]}>
accessibilityLabel="Account options" <View style={{flexDirection: 'row', marginBottom: 8}}>
accessibilityHint=""> <Pressable
<FontAwesomeIcon style={[
icon="ellipsis-h" palInverted.view,
style={pal.textLight as FontAwesomeIconStyle} {
/> flexDirection: 'row',
</NativeDropdown> gap: 6,
</Pressable> borderRadius: 6,
) paddingHorizontal: 12,
} paddingVertical: 10,
alignItems: 'center',
},
isMobile && {flex: 1},
]}
accessibilityRole="button"
accessibilityLabel="Verify my email"
accessibilityHint=""
onPress={() => store.shell.openModal({name: 'verify-email'})}>
<FontAwesomeIcon
icon="envelope"
color={palInverted.colors.text}
size={16}
/>
<Text type="button" style={palInverted.text}>
Verify My Email
</Text>
</Pressable>
</View>
<Text style={pal.textLight}>
Protect your account by verifying your email.
</Text>
</View>
</View>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
dimmed: { dimmed: {
-3
View File
@@ -11,7 +11,6 @@ export const Composer = observer(function ComposerImpl({
winHeight, winHeight,
replyTo, replyTo,
onPost, onPost,
onClose,
quote, quote,
mention, mention,
}: { }: {
@@ -19,7 +18,6 @@ export const Composer = observer(function ComposerImpl({
winHeight: number winHeight: number
replyTo?: ComposerOpts['replyTo'] replyTo?: ComposerOpts['replyTo']
onPost?: ComposerOpts['onPost'] onPost?: ComposerOpts['onPost']
onClose: () => void
quote?: ComposerOpts['quote'] quote?: ComposerOpts['quote']
mention?: ComposerOpts['mention'] mention?: ComposerOpts['mention']
}) { }) {
@@ -64,7 +62,6 @@ export const Composer = observer(function ComposerImpl({
<ComposePost <ComposePost
replyTo={replyTo} replyTo={replyTo}
onPost={onPost} onPost={onPost}
onClose={onClose}
quote={quote} quote={quote}
mention={mention} mention={mention}
/> />
-3
View File
@@ -13,7 +13,6 @@ export const Composer = observer(function ComposerImpl({
replyTo, replyTo,
quote, quote,
onPost, onPost,
onClose,
mention, mention,
}: { }: {
active: boolean active: boolean
@@ -21,7 +20,6 @@ export const Composer = observer(function ComposerImpl({
replyTo?: ComposerOpts['replyTo'] replyTo?: ComposerOpts['replyTo']
quote: ComposerOpts['quote'] quote: ComposerOpts['quote']
onPost?: ComposerOpts['onPost'] onPost?: ComposerOpts['onPost']
onClose: () => void
mention?: ComposerOpts['mention'] mention?: ComposerOpts['mention']
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -47,7 +45,6 @@ export const Composer = observer(function ComposerImpl({
replyTo={replyTo} replyTo={replyTo}
quote={quote} quote={quote}
onPost={onPost} onPost={onPost}
onClose={onClose}
mention={mention} mention={mention}
/> />
</View> </View>
+29 -26
View File
@@ -273,6 +273,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
label="Feeds" label="Feeds"
accessibilityLabel="Feeds" accessibilityLabel="Feeds"
accessibilityHint="" accessibilityHint=""
bold={isAtFeeds}
onPress={onPressMyFeeds} onPress={onPressMyFeeds}
/> />
<MenuItem <MenuItem
@@ -425,32 +426,34 @@ const InviteCodes = observer(function InviteCodesImpl({
store.shell.openModal({name: 'invite-codes'}) store.shell.openModal({name: 'invite-codes'})
}, [store, track]) }, [store, track])
return ( return (
<TouchableOpacity store.me.invitesAvailable !== null && (
testID="menuItemInviteCodes" <TouchableOpacity
style={[styles.inviteCodes, style]} testID="menuItemInviteCodes"
onPress={onPress} style={[styles.inviteCodes, style]}
accessibilityRole="button" onPress={onPress}
accessibilityLabel={ accessibilityRole="button"
invitesAvailable === 1 accessibilityLabel={
? 'Invite codes: 1 available' invitesAvailable === 1
: `Invite codes: ${invitesAvailable} available` ? 'Invite codes: 1 available'
} : `Invite codes: ${invitesAvailable} available`
accessibilityHint="Opens list of invite codes"> }
<FontAwesomeIcon accessibilityHint="Opens list of invite codes">
icon="ticket" <FontAwesomeIcon
style={[ icon="ticket"
styles.inviteCodesIcon, style={[
store.me.invitesAvailable > 0 ? pal.link : pal.textLight, styles.inviteCodesIcon,
]} store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
size={18} ]}
/> size={18}
<Text />
type="lg-medium" <Text
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> type="lg-medium"
{formatCount(store.me.invitesAvailable)} invite{' '} style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
{pluralize(store.me.invitesAvailable, 'code')} {formatCount(store.me.invitesAvailable)} invite{' '}
</Text> {pluralize(store.me.invitesAvailable, 'code')}
</TouchableOpacity> </Text>
</TouchableOpacity>
)
) )
}) })
+4
View File
@@ -75,6 +75,9 @@ export const BottomBar = observer(function BottomBarImpl({
const onPressProfile = React.useCallback(() => { const onPressProfile = React.useCallback(() => {
onPressTab('MyProfile') onPressTab('MyProfile')
}, [onPressTab]) }, [onPressTab])
const onLongPressProfile = React.useCallback(() => {
store.shell.openModal({name: 'switch-account'})
}, [store])
return ( return (
<Animated.View <Animated.View
@@ -202,6 +205,7 @@ export const BottomBar = observer(function BottomBarImpl({
</View> </View>
} }
onPress={onPressProfile} onPress={onPressProfile}
onLongPress={onLongPressProfile}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityLabel="Profile" accessibilityLabel="Profile"
accessibilityHint="" accessibilityHint=""

Some files were not shown because too many files have changed in this diff Show More