Merge branch 'main' into hailey/use-eas-version-code
This commit is contained in:
@@ -6,7 +6,7 @@ Get the app itself:
|
||||
|
||||
- **Web: [bsky.app](https://bsky.app)**
|
||||
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
|
||||
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
|
||||
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app)**
|
||||
|
||||
## Development Resources
|
||||
|
||||
@@ -21,7 +21,7 @@ The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentrali
|
||||
- [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)
|
||||
- [Blogpost on self-authenticating data structures](https://bsky.social/about/blog/3-6-2022-a-self-authenticating-social-protocol)
|
||||
|
||||
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
@@ -33,12 +34,6 @@ describe('Create account', () => {
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Home screen', () => {
|
||||
const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
@@ -44,14 +44,14 @@ describe('Home screen', () => {
|
||||
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost posts', async () => {
|
||||
const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
|
||||
@@ -65,7 +65,7 @@ describe('Home screen', () => {
|
||||
await expect(element(by.id('repostModal'))).not.toBeVisible()
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report posts', async () => {
|
||||
|
||||
@@ -28,9 +28,10 @@ describe('invite-codes', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
@@ -40,12 +41,6 @@ describe('invite-codes', () => {
|
||||
await element(by.id('handleInput')).typeText('e2e-test')
|
||||
await device.takeScreenshot('4- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,16 +28,17 @@ describe('invite-codes', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await element(by.id('phoneInput')).typeText('5558675309')
|
||||
await element(by.id('phoneInput')).typeText('2345551234')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('5- requested code')
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
@@ -46,12 +47,6 @@ describe('invite-codes', () => {
|
||||
await element(by.id('handleInput')).typeText('e2e-test')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
|
||||
|
||||
describe('Mod lists', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -62,38 +62,39 @@ describe('Mod lists', () => {
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit modlist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
// DISABLED e2e environment is real finicky about avatar uploads -prf
|
||||
// it('Set avi via the edit modlist modal', async () => {
|
||||
// await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// await element(by.id('headerDropdownBtn')).tap()
|
||||
// await element(by.text('Edit list details')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
// await element(by.id('changeAvatarBtn')).tap()
|
||||
// await element(by.text('Library')).tap()
|
||||
// await sleep(3e3)
|
||||
// await element(by.id('saveBtn')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
// await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// // have to wait for the toast to clear
|
||||
// await waitFor(element(by.id('headerDropdownBtn')))
|
||||
// .toBeVisible()
|
||||
// .withTimeout(5000)
|
||||
// })
|
||||
|
||||
it('Remove avi via the edit modlist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
// it('Remove avi via the edit modlist modal', async () => {
|
||||
// await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// await element(by.id('headerDropdownBtn')).tap()
|
||||
// await element(by.text('Edit list details')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
// await element(by.id('changeAvatarBtn')).tap()
|
||||
// await element(by.text('Remove')).tap()
|
||||
// await element(by.id('saveBtn')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
// await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// // have to wait for the toast to clear
|
||||
// await waitFor(element(by.id('headerDropdownBtn')))
|
||||
// .toBeVisible()
|
||||
// .withTimeout(5000)
|
||||
// })
|
||||
|
||||
it('Delete the modlist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('Profile screen', () => {
|
||||
const posts = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
|
||||
@@ -155,14 +155,14 @@ describe('Profile screen', () => {
|
||||
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost posts', async () => {
|
||||
const posts = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
|
||||
@@ -176,7 +176,7 @@ describe('Profile screen', () => {
|
||||
await expect(element(by.id('repostModal'))).not.toBeVisible()
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report posts', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('Self-labeling', () => {
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
const posts = by.id('feedItem-by-alice.test')
|
||||
await element(by.id('e2eRefreshHome')).tap()
|
||||
await expect(
|
||||
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
|
||||
).toExist()
|
||||
|
||||
@@ -18,9 +18,10 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
@@ -40,13 +41,7 @@ describe('Create account', () => {
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
|
||||
await element(by.id('continueBtn')).tap()
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('failed text verification correctly goes back to the code input screen', async () => {
|
||||
@@ -57,9 +52,10 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification2@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Thread screen', () => {
|
||||
const post = by.id('postThreadItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(post)).atIndex(0),
|
||||
@@ -57,7 +57,7 @@ describe('Thread screen', () => {
|
||||
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost the root post', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('Thread screen', () => {
|
||||
const post = by.id('postThreadItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
|
||||
@@ -99,7 +99,7 @@ describe('Thread screen', () => {
|
||||
await expect(element(by.id('repostModal'))).not.toBeVisible()
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report the root post', async () => {
|
||||
|
||||
+2
-1
@@ -56,9 +56,10 @@ export async function login(
|
||||
if (takeScreenshots) {
|
||||
await device.takeScreenshot('2- opened service selector')
|
||||
}
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
if (takeScreenshots) {
|
||||
await device.takeScreenshot('3- input custom service')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="origin-when-cross-origin">
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
-->
|
||||
<link rel="preconnect" href="https://bsky.social">
|
||||
<link rel="preconnect" href="https://bsky.network">
|
||||
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
|
||||
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
@@ -219,7 +224,7 @@
|
||||
<noscript>
|
||||
<h1 lang="en">JavaScript Required</h1>
|
||||
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://blueskyweb.xyz">blueskyweb.xyz</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
{% endblock -%}
|
||||
|
||||
@@ -26,11 +26,22 @@
|
||||
- Start the dev servers
|
||||
- `git clone git@github.com:bluesky-social/atproto.git`
|
||||
- `cd atproto`
|
||||
- `brew install pnpm`
|
||||
- `brew install jq`
|
||||
- `pnpm i`
|
||||
- `pnpm build`
|
||||
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
|
||||
- Launch a Postgres database on port 5432
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
- Run the dev app
|
||||
- iOS: `yarn ios`
|
||||
- Xcode must be installed for this to run.
|
||||
- A simulator must be preconfigured in Xcode settings.
|
||||
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
|
||||
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
|
||||
- Pods must be installed:
|
||||
- From the project directory root: `cd ios && pod install`.
|
||||
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
|
||||
- Android: `yarn android`
|
||||
- Web: `yarn web`
|
||||
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
|
||||
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
'uk',
|
||||
'ca',
|
||||
'zh-CN',
|
||||
'it',
|
||||
],
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ButtonProps = React.PropsWithChildren<
|
||||
Pick<PressableProps, 'disabled' | 'onPress'> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function Outer({
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
|
||||
sheet.current?.snapToIndex(i)
|
||||
@@ -41,8 +42,7 @@ export function Outer({
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
sheet.current?.close()
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
@@ -53,6 +53,15 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose?.()
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
@@ -63,6 +72,7 @@ export function Outer({
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
ref={sheet}
|
||||
index={-1}
|
||||
@@ -77,7 +87,7 @@ export function Outer({
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onClose}>
|
||||
onChange={onChange}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
@@ -105,8 +115,8 @@ export function Inner(props: DialogInnerProps) {
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.pt_3xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
|
||||
@@ -121,11 +131,13 @@ export function ScrollableInner(props: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_lg,
|
||||
a.pt_3xl,
|
||||
a.p_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
@@ -139,21 +151,21 @@ export function ScrollableInner(props: DialogInnerProps) {
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_sm,
|
||||
a.z_10,
|
||||
{
|
||||
top: a.pt_lg.paddingTop,
|
||||
width: 35,
|
||||
height: 4,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
top: a.pt_lg.paddingTop,
|
||||
width: 35,
|
||||
height: 4,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ export function IconCircle({
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: size === 'lg' ? 52 : 64,
|
||||
height: size === 'lg' ? 52 : 64,
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
|
||||
},
|
||||
+12
-1
@@ -148,6 +148,10 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +162,13 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
|
||||
* Intended to behave as a web anchor tag. For more complex routing, use a
|
||||
* `Button`.
|
||||
*/
|
||||
export function Link({children, to, action = 'push', ...rest}: LinkProps) {
|
||||
export function Link({
|
||||
children,
|
||||
to,
|
||||
action = 'push',
|
||||
download,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
to,
|
||||
displayText: typeof children === 'string' ? children : '',
|
||||
@@ -177,6 +187,7 @@ export function Link({children, to, action = 'push', ...rest}: LinkProps) {
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
@@ -41,7 +39,6 @@ export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
||||
|
||||
export function Root({children, isInvalid = false}: RootProps) {
|
||||
const inputRef = React.useRef<TextInput>(null)
|
||||
const rootRef = React.useRef<View>(null)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
@@ -72,35 +69,17 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||
],
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root || !isWeb) return
|
||||
// @ts-ignore web only
|
||||
root.tabIndex = -1
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
ref={rootRef}
|
||||
role="none"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.px_md,
|
||||
{
|
||||
paddingVertical: 14,
|
||||
},
|
||||
]}
|
||||
// onPressIn/out don't work on android web
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -187,7 +166,6 @@ export function createInput(Component: typeof TextInput) {
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
value={value}
|
||||
@@ -204,14 +182,17 @@ export function createInput(Component: typeof TextInput) {
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.px_xs,
|
||||
android({
|
||||
paddingBottom: 2,
|
||||
}),
|
||||
{
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 60 : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
},
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -238,10 +219,14 @@ export function createInput(Component: typeof TextInput) {
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
export function Label({
|
||||
nativeID,
|
||||
children,
|
||||
}: React.PropsWithChildren<{nativeID?: string}>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
nativeID={nativeID}
|
||||
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium, a.mb_sm]}>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -309,7 +294,6 @@ export function Suffix({
|
||||
const ctx = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
style={[
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||
|
||||
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||
AccessibilityProps &
|
||||
React.PropsWithChildren<{}>
|
||||
React.PropsWithChildren<{testID?: string}>
|
||||
|
||||
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||
multiple?: boolean
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* APP-700
|
||||
*
|
||||
* This is a temporary debug setting we're running on the Web build to
|
||||
* help the protocol team test some changes.
|
||||
*
|
||||
* It should be removed in ~2 weeks. It should only be used on the Web
|
||||
* version of the app.
|
||||
*/
|
||||
|
||||
import {useState, useCallback, useEffect} from 'react'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import * as Storage from 'lib/storage'
|
||||
|
||||
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
|
||||
const [enabled, setEnabled] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
if (await isEnabled()) {
|
||||
setEnabled(true)
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!enabled) {
|
||||
Storage.saveString('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
setEnabled(true)
|
||||
} else {
|
||||
Storage.remove('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
setEnabled(false)
|
||||
}
|
||||
}, [setEnabled, enabled, agent])
|
||||
|
||||
return [enabled, toggle]
|
||||
}
|
||||
|
||||
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
|
||||
if (enabled) {
|
||||
Storage.saveString('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
} else {
|
||||
Storage.remove('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyDebugHeader(agent: BskyAgent) {
|
||||
if (await isEnabled()) {
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
async function isEnabled() {
|
||||
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import {compressIfNeeded} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
const files = await RNFS.readDir(
|
||||
let files = await RNFS.readDir(
|
||||
RNFS.LibraryDirectoryPath.split('/')
|
||||
.slice(0, -5)
|
||||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/'),
|
||||
)
|
||||
files = files.filter(file => file.path.endsWith('.JPG'))
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
|
||||
@@ -140,6 +140,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
return AppLanguage.ca
|
||||
case 'zh-CN':
|
||||
return AppLanguage.zh_CN
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
||||
import {messages as messagesCa} from '#/locale/locales/ca/messages'
|
||||
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
||||
import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
@@ -65,6 +66,11 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEn})
|
||||
|
||||
@@ -56,6 +56,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/zh-CN/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
mod = await import(`./locales/en/messages`)
|
||||
break
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum AppLanguage {
|
||||
uk = 'uk',
|
||||
ca = 'ca',
|
||||
zh_CN = 'zh-CN',
|
||||
it = 'it',
|
||||
}
|
||||
|
||||
interface AppLanguageConfig {
|
||||
@@ -37,6 +38,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'},
|
||||
{code2: AppLanguage.it, name: 'Italiano - Italian'},
|
||||
]
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
|
||||
+475
-402
File diff suppressed because it is too large
Load Diff
+508
-435
File diff suppressed because it is too large
Load Diff
+474
-401
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+486
-413
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+475
-402
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import {
|
||||
flatten,
|
||||
TextStyleProp,
|
||||
} from '#/alf'
|
||||
import {H2, P, leading} from '#/components/Typography'
|
||||
import {P, leading, Text} from '#/components/Typography'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
@@ -209,16 +209,18 @@ export function Title({
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
return (
|
||||
<H2
|
||||
<Text
|
||||
style={[
|
||||
a.pb_sm,
|
||||
a.text_4xl,
|
||||
a.font_bold,
|
||||
{
|
||||
lineHeight: leading(a.text_4xl, a.leading_tight),
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</H2>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export type FeedConfig = {
|
||||
default: boolean
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {
|
||||
bulkWriteFollows,
|
||||
sortPrimaryAlgorithmFeeds,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
usePreferencesQuery,
|
||||
useSetFeedViewPreferencesMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function StepFollowingFeed() {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function StepInterests() {
|
||||
const {_} = useLingui()
|
||||
@@ -139,7 +139,7 @@ export function StepInterests() {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<View style={[a.align_start]} testID="onboardingInterests">
|
||||
<IconCircle
|
||||
icon={isError ? EmojiSad : Hashtag}
|
||||
style={[
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
|
||||
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
function AnimatedDivider() {
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SuggestedAccountCardPlaceholder,
|
||||
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function Inner({
|
||||
profiles,
|
||||
@@ -175,7 +175,11 @@ export function StepSuggestedAccounts() {
|
||||
)}
|
||||
onPress={handleContinue}>
|
||||
<ButtonText>
|
||||
<Trans>Follow All</Trans>
|
||||
{dids.length === 20 ? (
|
||||
<Trans>Follow All</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={saving ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function StepTopicalFeeds() {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -26,12 +26,6 @@ export interface EditProfileModal {
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export interface ServerInputModal {
|
||||
name: 'server-input'
|
||||
initialService: string
|
||||
onSelect: (url: string) => void
|
||||
}
|
||||
|
||||
export interface ModerationDetailsModal {
|
||||
name: 'moderation-details'
|
||||
context: 'account' | 'content'
|
||||
@@ -222,7 +216,6 @@ export type Modal =
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| EditImageModal
|
||||
| ServerInputModal
|
||||
| RepostModal
|
||||
| SelfLabelModal
|
||||
| ThreadgateModal
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {ActionCrop} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
rotate?: number
|
||||
scale?: number
|
||||
position?: Position
|
||||
flipHorizontal?: boolean
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
attributes: ImageManipulationAttributes = {
|
||||
aspectRatio: 'None',
|
||||
scale: 1,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
rotate: 0,
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: Omit<RNImage, 'size'>) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
setRotate(degrees: number) {
|
||||
this.attributes.rotate = degrees
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipVertical() {
|
||||
this.attributes.flipVertical = !this.attributes.flipVertical
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipHorizontal() {
|
||||
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
get ratioMultipliers() {
|
||||
return {
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
None: this.width / this.height,
|
||||
}
|
||||
}
|
||||
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
||||
if (ratioMultiplier === 1) {
|
||||
return {
|
||||
height: maxSide,
|
||||
width: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioMultiplier < 1) {
|
||||
return {
|
||||
width: maxSide * ratioMultiplier,
|
||||
height: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: maxSide,
|
||||
height: maxSide / ratioMultiplier,
|
||||
}
|
||||
}
|
||||
|
||||
setAltText(altText: string) {
|
||||
this.altText = altText.trim()
|
||||
}
|
||||
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Web manipulation
|
||||
async manipulate(
|
||||
_attributes: {
|
||||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||
hiddenPosts: defaults.hiddenPosts,
|
||||
externalEmbeds: defaults.externalEmbeds,
|
||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const schema = z.object({
|
||||
hiddenPosts: z.array(z.string()).optional(), // should move to server
|
||||
useInAppBrowser: z.boolean().optional(),
|
||||
lastSelectedHomeFeed: z.string().optional(),
|
||||
pdsAddressHistory: z.array(z.string()).optional(),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -91,4 +92,5 @@ export const defaults: Schema = {
|
||||
hiddenPosts: [],
|
||||
useInAppBrowser: undefined,
|
||||
lastSelectedHomeFeed: undefined,
|
||||
pdsAddressHistory: [],
|
||||
}
|
||||
|
||||
@@ -83,19 +83,19 @@ export function HomeLoggedOutCTA() {
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz"
|
||||
href="https://bsky.social"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz/blog"
|
||||
href="https://bsky.social/about/blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz/join"
|
||||
href="https://bsky.social/about/join"
|
||||
text={_(msg`Jobs`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
||||
import {s, colors} from 'lib/styles'
|
||||
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
|
||||
export const SplashScreen = ({
|
||||
onPressSignin,
|
||||
@@ -20,6 +29,22 @@ export const SplashScreen = ({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -58,6 +83,51 @@ export const SplashScreen = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<View style={{position: 'relative'}}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: pal.textLight.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: pal.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
size={10}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{height: insets.bottom}} />
|
||||
</ErrorBoundary>
|
||||
</CenteredView>
|
||||
)
|
||||
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
btns: {
|
||||
paddingBottom: 40,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
fontSize: 21,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
|
||||
export const SplashScreen = ({
|
||||
onDismiss,
|
||||
@@ -98,24 +102,84 @@ export const SplashScreen = ({
|
||||
|
||||
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = ev.target.value
|
||||
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz"
|
||||
text="Business"
|
||||
href="https://bsky.social"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz/blog"
|
||||
text="Blog"
|
||||
href="https://bsky.social/about/blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz/join"
|
||||
text="Jobs"
|
||||
href="https://bsky.social/about/join"
|
||||
text={_(msg`Jobs`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
|
||||
<View style={styles.footerDivider} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
}}>
|
||||
<Text aria-hidden={true} style={[pal.textLight]}>
|
||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
size={12}
|
||||
style={[pal.textLight, {flexShrink: 0}]}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={sanitizedLang}
|
||||
onChange={onChangeAppLanguage}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
MozAppearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}>
|
||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
||||
<option key={l.code2} value={l.code2}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -188,9 +252,10 @@ const useStyles = () => {
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 20,
|
||||
},
|
||||
footerLink: {
|
||||
marginRight: 20,
|
||||
},
|
||||
footerDivider: {flexGrow: 1},
|
||||
footerLink: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
@@ -13,7 +14,6 @@ import {StepHeader} from './StepHeader'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TextInput} from '../util/TextInput'
|
||||
import {Button} from '../../util/forms/Button'
|
||||
import {Policies} from './Policies'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {isWeb} from 'platform/detection'
|
||||
@@ -21,7 +21,14 @@ import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {logger} from '#/logger'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
|
||||
function sanitizeDate(date: Date): Date {
|
||||
if (!date || date.toString() === 'Invalid Date') {
|
||||
@@ -43,16 +50,12 @@ export function Step1({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: uiState.serviceUrl,
|
||||
onSelect: (url: string) =>
|
||||
uiDispatch({type: 'set-service-url', value: url}),
|
||||
})
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
}, [uiDispatch, uiState.serviceUrl, openModal])
|
||||
}, [serverInputControl])
|
||||
|
||||
const onPressWaitlist = React.useCallback(() => {
|
||||
openModal({name: 'waitlist'})
|
||||
@@ -64,23 +67,72 @@ export function Step1({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)}>
|
||||
<View>
|
||||
<Button
|
||||
testID="selectServiceButton"
|
||||
type="default"
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}
|
||||
onPress={onPressSelectService}>
|
||||
<FontAwesomeIcon icon="server" size={21} color={pal.colors.text} />
|
||||
</Button>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
|
||||
/>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</Text>
|
||||
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
|
||||
<View
|
||||
style={[
|
||||
pal.borderDark,
|
||||
{flexDirection: 'row', alignItems: 'center'},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, {marginLeft: 14}]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="selectServiceButton"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={onPressSelectService}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.text,
|
||||
{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingRight: 12,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
]}>
|
||||
{toNiceDomain(uiState.serviceUrl)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
pal.btn,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 8,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</StepHeader>
|
||||
</View>
|
||||
|
||||
{!uiState.serviceDescription ? (
|
||||
<ActivityIndicator />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
@@ -24,7 +25,9 @@ import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {styles} from './styles'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
|
||||
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -51,19 +54,16 @@ export const ForgotPasswordForm = ({
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const {screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
useEffect(() => {
|
||||
screen('Signin:ForgotPassword')
|
||||
}, [screen])
|
||||
|
||||
const onPressSelectService = () => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
}
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
}, [serverInputControl])
|
||||
|
||||
const onPressNext = async () => {
|
||||
if (!EmailValidator.validate(email)) {
|
||||
@@ -96,6 +96,10 @@ export const ForgotPasswordForm = ({
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
<Trans>Reset password</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -25,7 +25,9 @@ import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {styles} from './styles'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
|
||||
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -58,15 +60,11 @@ export const LoginForm = ({
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const passwordInputRef = useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const {login} = useSessionApi()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
const onPressSelectService = () => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
track('Signin:PressedSelectService')
|
||||
}
|
||||
@@ -130,6 +128,11 @@ export const LoginForm = ({
|
||||
const isReady = !!serviceDescription && !!identifier && !!password
|
||||
return (
|
||||
<View testID="loginForm">
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
|
||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||
<Trans>Sign into</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {PROD_SERVICE} from 'lib/constants'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text, P} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
|
||||
export function ServerInputDialog({
|
||||
control,
|
||||
onSelect,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onSelect: (url: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
|
||||
persisted.get('pdsAddressHistory') || [],
|
||||
)
|
||||
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
|
||||
const [customAddress, setCustomAddress] = React.useState('')
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
let url
|
||||
if (fixedOption[0] === 'custom') {
|
||||
url = customAddress.trim().toLowerCase()
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
url = fixedOption[0]
|
||||
}
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
if (url === 'localhost' || url.startsWith('localhost:')) {
|
||||
url = `http://${url}`
|
||||
} else {
|
||||
url = `https://${url}`
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedOption[0] === 'custom') {
|
||||
if (!pdsAddressHistory.includes(url)) {
|
||||
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
|
||||
setPdsAddressHistory(newHistory)
|
||||
persisted.write('pdsAddressHistory', newHistory)
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(url)
|
||||
}, [
|
||||
fixedOption,
|
||||
customAddress,
|
||||
onSelect,
|
||||
pdsAddressHistory,
|
||||
setPdsAddressHistory,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
accessibilityLabelledBy="dialog-title">
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose Service</Trans>
|
||||
</Text>
|
||||
<P nativeID="dialog-description" style={[a.text_sm]}>
|
||||
<Trans>Select the service that hosts your data.</Trans>
|
||||
</P>
|
||||
|
||||
<ToggleButton.Group
|
||||
label="Preferences"
|
||||
values={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
|
||||
{_(msg`Bluesky`)}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
testID="customSelectBtn"
|
||||
name="custom"
|
||||
label={_(msg`Custom`)}>
|
||||
{_(msg`Custom`)}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
|
||||
{fixedOption[0] === 'custom' && (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
]}>
|
||||
<TextField.Label nativeID="address-input-label">
|
||||
<Trans>Server address</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Globe} />
|
||||
<Dialog.Input
|
||||
testID="customServerTextInput"
|
||||
value={customAddress}
|
||||
onChangeText={setCustomAddress}
|
||||
label={_(msg`my-server.com`)}
|
||||
accessibilityLabelledBy="address-input-label"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</TextField.Root>
|
||||
{pdsAddressHistory.length > 0 && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
|
||||
{pdsAddressHistory.map(uri => (
|
||||
<Button
|
||||
key={uri}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
label={uri}
|
||||
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
|
||||
onPress={() => setCustomAddress(uri)}>
|
||||
<ButtonText>{uri}</ButtonText>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<P
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your hosting
|
||||
provider. Custom hosting is now available in beta for
|
||||
developers.
|
||||
</Trans>
|
||||
</P>
|
||||
</View>
|
||||
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
testID="doneBtn"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="small"
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Done`)}>
|
||||
{_(msg`Done`)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {useTheme} from 'lib/ThemeContext'
|
||||
import {isUriImage} from 'lib/media/util'
|
||||
import {downloadAndResize} from 'lib/media/manip'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -252,6 +253,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
lineHeight: 23.4, // 1.3*16
|
||||
// This is broken on ios right now, so don't set it there.
|
||||
lineHeight: isIOS ? undefined : 23.4, // 1.3*16
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
import {StyleSheet, Pressable, View} from 'react-native'
|
||||
import {StyleSheet, Pressable, View, Linking} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {ScrollView} from './util'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
@@ -129,6 +129,10 @@ function AdultContentEnabledPref() {
|
||||
}
|
||||
}, [variables, preferences, mutate, _])
|
||||
|
||||
const onAdultContentLinkPress = React.useCallback(() => {
|
||||
Linking.openURL('https://bsky.app/')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={s.mb10}>
|
||||
{isIOS ? (
|
||||
@@ -138,8 +142,9 @@ function AdultContentEnabledPref() {
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://bsky.app"
|
||||
href=""
|
||||
text="bsky.app"
|
||||
onPress={onAdultContentLinkPress}
|
||||
/>
|
||||
.
|
||||
</Trans>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useModals, useModalControls} from '#/state/modals'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
import * as ThreadgateModal from './Threadgate'
|
||||
@@ -74,9 +73,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = <EditProfileModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'server-input') {
|
||||
snapPoints = ServerInputModal.snapPoints
|
||||
element = <ServerInputModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'report') {
|
||||
snapPoints = ReportModal.snapPoints
|
||||
element = <ReportModal.Component {...activeModal} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import {useModals, useModalControls} from '#/state/modals'
|
||||
import type {Modal as ModalIface} from '#/state/modals'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportModal from './report/Modal'
|
||||
import * as AppealLabelModal from './AppealLabel'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
@@ -84,8 +83,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ConfirmModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-profile') {
|
||||
element = <EditProfileModal.Component {...modal} />
|
||||
} else if (modal.name === 'server-input') {
|
||||
element = <ServerInputModal.Component {...modal} />
|
||||
} else if (modal.name === 'report') {
|
||||
element = <ReportModal.Component {...modal} />
|
||||
} else if (modal.name === 'appeal-label') {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import React, {useState} from 'react'
|
||||
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
|
||||
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
export function Component({onSelect}: {onSelect: (url: string) => void}) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const [customUrl, setCustomUrl] = useState<string>('')
|
||||
const {_} = useLingui()
|
||||
const {closeModal} = useModalControls()
|
||||
|
||||
const doSelect = (url: string) => {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
closeModal()
|
||||
onSelect(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[pal.view, s.flex1]} testID="serverInputModal">
|
||||
<Text type="2xl-bold" style={[pal.text, s.textCenter]}>
|
||||
<Trans>Choose Service</Trans>
|
||||
</Text>
|
||||
<ScrollView style={styles.inner}>
|
||||
<View style={styles.group}>
|
||||
{LOGIN_INCLUDE_DEV_SERVERS ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="localDevServerButton"
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(LOCAL_DEV_SERVICE)}
|
||||
accessibilityRole="button">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Local dev server</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(STAGING_SERVICE)}
|
||||
accessibilityRole="button">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Staging</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : undefined}
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(PROD_SERVICE)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select Bluesky Social`)}
|
||||
accessibilityHint="Sets Bluesky Social as your service provider">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Bluesky.Social</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.group}>
|
||||
<Text style={[pal.text, styles.label]}>
|
||||
<Trans>Other service</Trans>
|
||||
</Text>
|
||||
<View style={s.flexRow}>
|
||||
<TextInput
|
||||
testID="customServerTextInput"
|
||||
style={[pal.borderDark, pal.text, styles.textInput]}
|
||||
placeholder="e.g. https://bsky.app"
|
||||
placeholderTextColor={colors.gray4}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
value={customUrl}
|
||||
onChangeText={setCustomUrl}
|
||||
accessibilityLabel={_(msg`Custom domain`)}
|
||||
// TODO: Simplify this wording further to be understandable by everyone
|
||||
accessibilityHint={_(
|
||||
msg`Use your domain as your Bluesky client service provider`,
|
||||
)}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="customServerSelectBtn"
|
||||
style={[pal.borderDark, pal.text, styles.textInputBtn]}
|
||||
onPress={() => doSelect(customUrl)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Confirm service. ${
|
||||
customUrl === ''
|
||||
? _(msg`Button disabled. Input custom domain to proceed.`)
|
||||
: ''
|
||||
}`}
|
||||
accessibilityHint=""
|
||||
// TODO - accessibility: Need to inform state change on failure
|
||||
disabled={customUrl === ''}>
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]}
|
||||
size={18}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inner: {
|
||||
padding: 14,
|
||||
},
|
||||
group: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderTopLeftRadius: 6,
|
||||
borderBottomLeftRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textInputBtn: {
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 0,
|
||||
borderTopRightRadius: 6,
|
||||
borderBottomRightRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.blue3,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 6,
|
||||
},
|
||||
btnText: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: colors.white,
|
||||
},
|
||||
checkIcon: {
|
||||
position: 'relative',
|
||||
...Platform.select({
|
||||
android: {
|
||||
top: 8,
|
||||
},
|
||||
ios: {
|
||||
top: 2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
|
||||
const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
|
||||
export const snapPoints = [575]
|
||||
|
||||
|
||||
@@ -233,36 +233,29 @@ let PagerTabBar = ({
|
||||
},
|
||||
],
|
||||
}))
|
||||
const pendingHeaderHeight = React.useRef<null | number>(null)
|
||||
const headerRef = React.useRef(null)
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.tabBarMobile, headerTransform]}>
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
onLayout={e => {
|
||||
if (isHeaderReady) {
|
||||
onHeaderOnlyLayout(e.nativeEvent.layout.height)
|
||||
pendingHeaderHeight.current = null
|
||||
} else {
|
||||
// Stash it away for when `isHeaderReady` turns `true` later.
|
||||
pendingHeaderHeight.current = e.nativeEvent.layout.height
|
||||
}
|
||||
}}>
|
||||
<View ref={headerRef} pointerEvents="box-none" collapsable={false}>
|
||||
{renderHeader?.()}
|
||||
{
|
||||
// When `isHeaderReady` turns `true`, we want to send the parent layout.
|
||||
// However, if that didn't lead to a layout change, parent `onLayout` wouldn't get called again.
|
||||
// We're conditionally rendering an empty view so that we can send the last measurement.
|
||||
// It wouldn't be enough to place `onLayout` on the parent node because
|
||||
// this would risk measuring before `isHeaderReady` has turned `true`.
|
||||
// Instead, we'll render a brand node conditionally and get fresh layout.
|
||||
isHeaderReady && (
|
||||
<View
|
||||
// It wouldn't be enough to do this in a `ref` of an effect because,
|
||||
// even if `isHeaderReady` might have turned `true`, the associated
|
||||
// layout might not have been performed yet on the native side.
|
||||
onLayout={() => {
|
||||
// We're assuming the parent `onLayout` already ran (parent -> child ordering).
|
||||
if (pendingHeaderHeight.current !== null) {
|
||||
onHeaderOnlyLayout(pendingHeaderHeight.current)
|
||||
pendingHeaderHeight.current = null
|
||||
}
|
||||
// @ts-ignore
|
||||
headerRef.current?.measure(
|
||||
(_x: number, _y: number, _width: number, height: number) => {
|
||||
onHeaderOnlyLayout(height)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {
|
||||
ThreadNode,
|
||||
ThreadPost,
|
||||
ThreadNotFound,
|
||||
ThreadBlocked,
|
||||
usePostThreadQuery,
|
||||
sortThread,
|
||||
} from '#/state/queries/post-thread'
|
||||
@@ -45,22 +47,33 @@ import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
|
||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 1}
|
||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
|
||||
// We don't insert any elements before the root row while loading.
|
||||
// So the row we want to use as the scroll anchor is the first row.
|
||||
minIndexForVisible: 0,
|
||||
}
|
||||
|
||||
const TOP_COMPONENT = {_reactKey: '__top_component__'}
|
||||
const REPLY_PROMPT = {_reactKey: '__reply__'}
|
||||
const DELETED = {_reactKey: '__deleted__'}
|
||||
const BLOCKED = {_reactKey: '__blocked__'}
|
||||
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
|
||||
const LOAD_MORE = {_reactKey: '__load_more__'}
|
||||
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
|
||||
|
||||
type YieldedItem =
|
||||
| ThreadPost
|
||||
type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
|
||||
type RowItem =
|
||||
| YieldedItem
|
||||
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
|
||||
| typeof TOP_COMPONENT
|
||||
| typeof REPLY_PROMPT
|
||||
| typeof DELETED
|
||||
| typeof BLOCKED
|
||||
| typeof CHILD_SPINNER
|
||||
| typeof LOAD_MORE
|
||||
| typeof BOTTOM_COMPONENT
|
||||
|
||||
type ThreadSkeletonParts = {
|
||||
parents: YieldedItem[]
|
||||
highlightedPost: ThreadNode
|
||||
replies: YieldedItem[]
|
||||
}
|
||||
|
||||
export function PostThread({
|
||||
uri,
|
||||
@@ -152,10 +165,6 @@ function PostThreadLoaded({
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
const ref = useRef<ListMethods>(null)
|
||||
const highlightedPostRef = useRef<View | null>(null)
|
||||
const needsScrollAdjustment = useRef<boolean>(
|
||||
!isNative || // web always uses scroll adjustment
|
||||
(thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
|
||||
)
|
||||
const [maxVisible, setMaxVisible] = React.useState(100)
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const treeView = React.useMemo(
|
||||
@@ -163,50 +172,74 @@ function PostThreadLoaded({
|
||||
[threadViewPrefs, thread],
|
||||
)
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
let arr = Array.from(
|
||||
flattenThreadSkeleton(
|
||||
// On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
|
||||
// This ensures that the first render contains no parents--even if they are already available in the cache.
|
||||
// We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
|
||||
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
|
||||
const [deferParents, setDeferParents] = React.useState(isNative)
|
||||
|
||||
const skeleton = React.useMemo(
|
||||
() =>
|
||||
createThreadSkeleton(
|
||||
sortThread(thread, threadViewPrefs),
|
||||
hasSession,
|
||||
treeView,
|
||||
),
|
||||
)
|
||||
[thread, threadViewPrefs, hasSession, treeView],
|
||||
)
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
const {parents, highlightedPost, replies} = skeleton
|
||||
let arr: RowItem[] = []
|
||||
if (highlightedPost.type === 'post') {
|
||||
const isRoot =
|
||||
!highlightedPost.parent && !highlightedPost.ctx.isParentLoading
|
||||
if (isRoot) {
|
||||
// No parents to load.
|
||||
arr.push(TOP_COMPONENT)
|
||||
} else {
|
||||
if (highlightedPost.ctx.isParentLoading || deferParents) {
|
||||
// We're loading parents of the highlighted post.
|
||||
// In this case, we don't render anything above the post.
|
||||
// If you add something here, you'll need to update both
|
||||
// maintainVisibleContentPosition and onContentSizeChange
|
||||
// to "hold onto" the correct row instead of the first one.
|
||||
} else {
|
||||
// Everything is loaded.
|
||||
arr.push(TOP_COMPONENT)
|
||||
for (const parent of parents) {
|
||||
arr.push(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
arr.push(highlightedPost)
|
||||
if (!highlightedPost.post.viewer?.replyDisabled) {
|
||||
arr.push(REPLY_PROMPT)
|
||||
}
|
||||
if (highlightedPost.ctx.isChildLoading) {
|
||||
arr.push(CHILD_SPINNER)
|
||||
} else {
|
||||
for (const reply of replies) {
|
||||
arr.push(reply)
|
||||
}
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
}
|
||||
}
|
||||
if (arr.length > maxVisible) {
|
||||
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
||||
}
|
||||
if (arr.indexOf(CHILD_SPINNER) === -1) {
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
}
|
||||
return arr
|
||||
}, [thread, treeView, maxVisible, threadViewPrefs, hasSession])
|
||||
}, [skeleton, maxVisible, deferParents])
|
||||
|
||||
/**
|
||||
* NOTE
|
||||
* Scroll positioning
|
||||
*
|
||||
* This callback is run if needsScrollAdjustment.current == true, which is...
|
||||
* - On web: always
|
||||
* - On native: when the placeholder cache is not being used
|
||||
*
|
||||
* It then only runs when viewing a reply, and the goal is to scroll the
|
||||
* reply into view.
|
||||
*
|
||||
* On native, if the placeholder cache is being used then maintainVisibleContentPosition
|
||||
* is a more effective solution, so we use that. Otherwise, typically we're loading from
|
||||
* the react-query cache, so we just need to immediately scroll down to the post.
|
||||
*
|
||||
* On desktop, maintainVisibleContentPosition isn't supported so we just always use
|
||||
* this technique.
|
||||
*
|
||||
* -prf
|
||||
*/
|
||||
const onContentSizeChange = React.useCallback(() => {
|
||||
// This is only used on the web to keep the post in view when its parents load.
|
||||
// On native, we rely on `maintainVisibleContentPosition` instead.
|
||||
const didAdjustScrollWeb = useRef<boolean>(false)
|
||||
const onContentSizeChangeWeb = React.useCallback(() => {
|
||||
// only run once
|
||||
if (!needsScrollAdjustment.current) {
|
||||
if (didAdjustScrollWeb.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// wait for loading to finish
|
||||
if (thread.type === 'post' && !!thread.parent) {
|
||||
function onMeasure(pageY: number) {
|
||||
@@ -215,21 +248,13 @@ function PostThreadLoaded({
|
||||
offset: pageY,
|
||||
})
|
||||
}
|
||||
if (isNative) {
|
||||
highlightedPostRef.current?.measure(
|
||||
(_x, _y, _width, _height, _pageX, pageY) => {
|
||||
onMeasure(pageY)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Measure synchronously to avoid a layout jump.
|
||||
const domNode = highlightedPostRef.current
|
||||
if (domNode) {
|
||||
const pageY = (domNode as any as Element).getBoundingClientRect().top
|
||||
onMeasure(pageY)
|
||||
}
|
||||
// Measure synchronously to avoid a layout jump.
|
||||
const domNode = highlightedPostRef.current
|
||||
if (domNode) {
|
||||
const pageY = (domNode as any as Element).getBoundingClientRect().top
|
||||
onMeasure(pageY)
|
||||
}
|
||||
needsScrollAdjustment.current = false
|
||||
didAdjustScrollWeb.current = true
|
||||
}
|
||||
}, [thread])
|
||||
|
||||
@@ -244,7 +269,7 @@ function PostThreadLoaded({
|
||||
}, [setIsPTRing, onRefresh])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item, index}: {item: YieldedItem; index: number}) => {
|
||||
({item, index}: {item: RowItem; index: number}) => {
|
||||
if (item === TOP_COMPONENT) {
|
||||
return isTabletOrMobile ? (
|
||||
<ViewHeader
|
||||
@@ -257,7 +282,7 @@ function PostThreadLoaded({
|
||||
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
|
||||
</View>
|
||||
)
|
||||
} else if (item === DELETED) {
|
||||
} else if (isThreadNotFound(item)) {
|
||||
return (
|
||||
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
|
||||
<Text type="lg-bold" style={pal.textLight}>
|
||||
@@ -265,7 +290,7 @@ function PostThreadLoaded({
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item === BLOCKED) {
|
||||
} else if (isThreadBlocked(item)) {
|
||||
return (
|
||||
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
|
||||
<Text type="lg-bold" style={pal.textLight}>
|
||||
@@ -322,7 +347,8 @@ function PostThreadLoaded({
|
||||
: undefined
|
||||
return (
|
||||
<View
|
||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
|
||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
||||
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
|
||||
<PostThreadItem
|
||||
post={item.post}
|
||||
record={item.record}
|
||||
@@ -355,6 +381,7 @@ function PostThreadLoaded({
|
||||
pal.colors.border,
|
||||
posts,
|
||||
onRefresh,
|
||||
deferParents,
|
||||
treeView,
|
||||
_,
|
||||
],
|
||||
@@ -364,17 +391,14 @@ function PostThreadLoaded({
|
||||
<List
|
||||
ref={ref}
|
||||
data={posts}
|
||||
initialNumToRender={!isNative ? posts.length : undefined}
|
||||
maintainVisibleContentPosition={
|
||||
!needsScrollAdjustment.current
|
||||
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
||||
: undefined
|
||||
}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onPTR}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
@@ -486,41 +510,68 @@ function isThreadPost(v: unknown): v is ThreadPost {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
|
||||
}
|
||||
|
||||
function* flattenThreadSkeleton(
|
||||
function isThreadNotFound(v: unknown): v is ThreadNotFound {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
|
||||
}
|
||||
|
||||
function isThreadBlocked(v: unknown): v is ThreadBlocked {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
|
||||
}
|
||||
|
||||
function createThreadSkeleton(
|
||||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
treeView: boolean,
|
||||
isTraversingReplies: boolean = false,
|
||||
): ThreadSkeletonParts {
|
||||
return {
|
||||
parents: Array.from(flattenThreadParents(node, hasSession)),
|
||||
highlightedPost: node,
|
||||
replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
|
||||
}
|
||||
}
|
||||
|
||||
function* flattenThreadParents(
|
||||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
): Generator<YieldedItem, void> {
|
||||
if (node.type === 'post') {
|
||||
if (!node.ctx.isParentLoading) {
|
||||
if (node.parent) {
|
||||
yield* flattenThreadSkeleton(node.parent, hasSession, treeView, false)
|
||||
} else if (!isTraversingReplies) {
|
||||
yield TOP_COMPONENT
|
||||
}
|
||||
if (node.parent) {
|
||||
yield* flattenThreadParents(node.parent, hasSession)
|
||||
}
|
||||
if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) {
|
||||
if (!node.ctx.isHighlightedPost) {
|
||||
yield node
|
||||
}
|
||||
} else if (node.type === 'not-found') {
|
||||
yield node
|
||||
} else if (node.type === 'blocked') {
|
||||
yield node
|
||||
}
|
||||
}
|
||||
|
||||
function* flattenThreadReplies(
|
||||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
treeView: boolean,
|
||||
): Generator<YieldedItem, void> {
|
||||
if (node.type === 'post') {
|
||||
if (!hasSession && hasPwiOptOut(node)) {
|
||||
return
|
||||
}
|
||||
yield node
|
||||
if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
|
||||
yield REPLY_PROMPT
|
||||
if (!node.ctx.isHighlightedPost) {
|
||||
yield node
|
||||
}
|
||||
if (node.replies?.length) {
|
||||
for (const reply of node.replies) {
|
||||
yield* flattenThreadSkeleton(reply, hasSession, treeView, true)
|
||||
yield* flattenThreadReplies(reply, hasSession, treeView)
|
||||
if (!treeView && !node.ctx.isHighlightedPost) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (node.ctx.isChildLoading) {
|
||||
yield CHILD_SPINNER
|
||||
}
|
||||
} else if (node.type === 'not-found') {
|
||||
yield DELETED
|
||||
yield node
|
||||
} else if (node.type === 'blocked') {
|
||||
yield BLOCKED
|
||||
yield node
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Shadow, useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {
|
||||
useProfileFollowMutationQueue,
|
||||
useProfileQuery,
|
||||
} from 'state/queries/profile'
|
||||
import {useRequireAuth} from 'state/session'
|
||||
|
||||
export function PostThreadFollowBtn({did}: {did: string}) {
|
||||
const {data: profile, isLoading} = useProfileQuery({did})
|
||||
|
||||
// We will never hit this - the profile will always be cached or loaded above
|
||||
// but it keeps the typechecker happy
|
||||
if (isLoading || !profile) return null
|
||||
|
||||
return <PostThreadFollowBtnLoaded profile={profile} />
|
||||
}
|
||||
|
||||
function PostThreadFollowBtnLoaded({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const navigation = useNavigation()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const requireAuth = useRequireAuth()
|
||||
|
||||
const isFollowing = !!profile.viewer?.following
|
||||
const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
|
||||
|
||||
// This prevents the button from disappearing as soon as we follow.
|
||||
const showFollowBtn = React.useMemo(
|
||||
() => !isFollowing || !wasFollowing,
|
||||
[isFollowing, wasFollowing],
|
||||
)
|
||||
|
||||
/**
|
||||
* We want this button to stay visible even after following, so that the user can unfollow if they want.
|
||||
* However, we need it to disappear after we push to a screen and then come back. We also need it to
|
||||
* show up if we view the post while following, go to the profile and unfollow, then come back to the
|
||||
* post.
|
||||
*
|
||||
* We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
|
||||
* we could do this only on focus because the transition animation gives us time to not notice the
|
||||
* sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
|
||||
* button renders. So, we update the state in both cases.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const updateWasFollowing = () => {
|
||||
if (wasFollowing !== isFollowing) {
|
||||
setWasFollowing(isFollowing)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
|
||||
const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus()
|
||||
unsubscribeBlur()
|
||||
}
|
||||
}, [isFollowing, wasFollowing, navigation])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
if (!isFollowing) {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:FollowButtonClicked')
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to follow', {message: String(e)})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:UnfollowButtonClicked')
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unfollow', {message: String(e)})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
|
||||
|
||||
if (!showFollowBtn) return null
|
||||
|
||||
return (
|
||||
<View style={{width: isTabletOrDesktop ? 130 : 120}}>
|
||||
<View style={styles.btnOuter}>
|
||||
<TouchableOpacity
|
||||
testID="followBtn"
|
||||
onPress={onPress}
|
||||
style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
|
||||
accessibilityHint={_(
|
||||
msg`Shows posts from ${profile.handle} in your feed`,
|
||||
)}>
|
||||
{isTabletOrDesktop && (
|
||||
<FontAwesomeIcon
|
||||
icon={!isFollowing ? 'plus' : 'check'}
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
type="button"
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
||||
numberOfLines={1}>
|
||||
{!isFollowing ? <Trans>Follow</Trans> : <Trans>Following</Trans>}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btnOuter: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 50,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -30,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
@@ -42,6 +42,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||
import {ThreadPost} from '#/state/queries/post-thread'
|
||||
import {useSession} from 'state/session'
|
||||
import {WhoCanReply} from '../threadgate/WhoCanReply'
|
||||
|
||||
export function PostThreadItem({
|
||||
@@ -113,7 +114,6 @@ export function PostThreadItem({
|
||||
}
|
||||
|
||||
function PostThreadItemDeleted() {
|
||||
const styles = useStyles()
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
|
||||
@@ -163,7 +163,7 @@ let PostThreadItemLoaded = ({
|
||||
const [limitLines, setLimitLines] = React.useState(
|
||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const styles = useStyles()
|
||||
const {currentAccount} = useSession()
|
||||
const hasEngagement = post.likeCount || post.repostCount
|
||||
|
||||
const rootUri = record.reply?.root?.uri || post.uri
|
||||
@@ -249,7 +249,7 @@ let PostThreadItemLoaded = ({
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
<View style={styles.layout}>
|
||||
<View style={[styles.layout]}>
|
||||
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
@@ -262,33 +262,18 @@ let PostThreadItemLoaded = ({
|
||||
<View style={styles.layoutContent}>
|
||||
<View
|
||||
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
|
||||
<View style={[s.flexRow]}>
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
<TimeElapsed timestamp={post.indexedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
title={niceDate(post.indexedAt)}>
|
||||
· {timeElapsed}
|
||||
</Text>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
{isAuthorMuted && (
|
||||
@@ -315,16 +300,16 @@ let PostThreadItemLoaded = ({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
{currentAccount?.did !== post.author.did && (
|
||||
<PostThreadFollowBtn did={post.author.did} />
|
||||
)}
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
<ContentHider
|
||||
@@ -490,6 +475,7 @@ let PostThreadItemLoaded = ({
|
||||
: 8,
|
||||
},
|
||||
]}>
|
||||
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
|
||||
{!isThreadedChild && (
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
@@ -515,7 +501,12 @@ let PostThreadItemLoaded = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.layoutContent}>
|
||||
<View
|
||||
style={
|
||||
isThreadedChild
|
||||
? styles.layoutContentThreaded
|
||||
: styles.layoutContent
|
||||
}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
@@ -626,7 +617,6 @@ function PostOuterWrapper({
|
||||
}>) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
const styles = useStyles()
|
||||
if (treeView && depth > 0) {
|
||||
return (
|
||||
<View
|
||||
@@ -703,94 +693,88 @@ function ExpandedPostDetails({
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
outerHighlighted: {
|
||||
paddingTop: 16,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
noTopBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
layoutAvi: {},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
metaExpandedLine1: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
metaItem: {
|
||||
paddingRight: 5,
|
||||
maxWidth: isDesktop ? 380 : 220,
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
postTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
translateLink: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHider: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
},
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginTop: 5,
|
||||
marginBottom: 15,
|
||||
},
|
||||
expandedInfoItem: {
|
||||
marginRight: 10,
|
||||
},
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
replyLine: {
|
||||
width: 2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
cursor: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
outerHighlighted: {
|
||||
paddingTop: 16,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
noTopBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
layoutAvi: {},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
marginLeft: 10,
|
||||
},
|
||||
layoutContentThreaded: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 2,
|
||||
},
|
||||
metaExpandedLine1: {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
postTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
translateLink: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHider: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
},
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginTop: 5,
|
||||
marginBottom: 15,
|
||||
},
|
||||
expandedInfoItem: {
|
||||
marginRight: 10,
|
||||
},
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
replyLine: {
|
||||
width: 2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
cursor: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -26,65 +26,80 @@ interface Props {
|
||||
onSubmitQuery: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
export function SearchInput({
|
||||
query,
|
||||
setIsInputFocused,
|
||||
onChangeQuery,
|
||||
onPressCancelSearch,
|
||||
onSubmitQuery,
|
||||
style,
|
||||
}: Props) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
|
||||
const onPressCancelSearchInner = React.useCallback(() => {
|
||||
onPressCancelSearch()
|
||||
textInput.current?.blur()
|
||||
}, [onPressCancelSearch, textInput])
|
||||
|
||||
return (
|
||||
<View style={[pal.viewLight, styles.container, style]}>
|
||||
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
|
||||
<TextInput
|
||||
testID="searchTextInput"
|
||||
ref={textInput}
|
||||
placeholder={_(msg`Search`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
selectTextOnFocus
|
||||
returnKeyType="search"
|
||||
value={query}
|
||||
style={[pal.text, styles.input]}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
onFocus={() => setIsInputFocused?.(true)}
|
||||
onBlur={() => setIsInputFocused?.(false)}
|
||||
onChangeText={onChangeQuery}
|
||||
onSubmitEditing={onSubmitQuery}
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{query ? (
|
||||
<TouchableOpacity
|
||||
onPress={onPressCancelSearchInner}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Clear search query`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
export interface SearchInputRef {
|
||||
focus?: () => void
|
||||
}
|
||||
|
||||
export const SearchInput = React.forwardRef<SearchInputRef, Props>(
|
||||
function SearchInput(
|
||||
{
|
||||
query,
|
||||
setIsInputFocused,
|
||||
onChangeQuery,
|
||||
onPressCancelSearch,
|
||||
onSubmitQuery,
|
||||
style,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
|
||||
const onPressCancelSearchInner = React.useCallback(() => {
|
||||
onPressCancelSearch()
|
||||
textInput.current?.blur()
|
||||
}, [onPressCancelSearch, textInput])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => textInput.current?.focus(),
|
||||
blur: () => textInput.current?.blur(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<View style={[pal.viewLight, styles.container, style]}>
|
||||
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
|
||||
<TextInput
|
||||
testID="searchTextInput"
|
||||
ref={textInput}
|
||||
placeholder={_(msg`Search`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
selectTextOnFocus
|
||||
returnKeyType="search"
|
||||
value={query}
|
||||
style={[pal.text, styles.input]}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
onFocus={() => setIsInputFocused?.(true)}
|
||||
onBlur={() => setIsInputFocused?.(false)}
|
||||
onChangeText={onChangeQuery}
|
||||
onSubmitEditing={onSubmitQuery}
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{query ? (
|
||||
<TouchableOpacity
|
||||
onPress={onPressCancelSearchInner}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Clear search query`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -208,7 +208,7 @@ let PostCtrls = ({
|
||||
{big && (
|
||||
<View style={styles.ctrlBig}>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
testID="shareBtn"
|
||||
style={[styles.btn]}
|
||||
onPress={onShare}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -39,6 +39,7 @@ import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
||||
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
|
||||
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
|
||||
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
|
||||
import {faDownload} from '@fortawesome/free-solid-svg-icons/faDownload'
|
||||
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
|
||||
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
|
||||
import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
|
||||
@@ -143,6 +144,7 @@ library.add(
|
||||
faCommentSlash,
|
||||
faComments,
|
||||
faCompass,
|
||||
faDownload,
|
||||
faEllipsis,
|
||||
faEnvelope,
|
||||
faEye,
|
||||
|
||||
@@ -37,8 +37,8 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
|
||||
The Community Guidelines have been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/community-guidelines"
|
||||
text="blueskyweb.xyz/support/community-guidelines"
|
||||
href="https://bsky.social/about/support/community-guidelines"
|
||||
text="bsky.social/about/support/community-guidelines"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -34,8 +34,8 @@ export const CopyrightPolicyScreen = (_props: Props) => {
|
||||
The Copyright Policy has been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/community-guidelines"
|
||||
text="blueskyweb.xyz/support/community-guidelines"
|
||||
href="https://bsky.social/about/support/copyright"
|
||||
text="bsky.social/about/support/copyright"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
+164
-86
@@ -1,5 +1,11 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View, type FlatList} from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
View,
|
||||
type FlatList,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
@@ -8,9 +14,10 @@ import {Link} from 'view/com/util/Link'
|
||||
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {ComposeIcon2, CogIcon} from 'lib/icons'
|
||||
import {ComposeIcon2, CogIcon, MagnifyingGlassIcon2} from 'lib/icons'
|
||||
import {s} from 'lib/styles'
|
||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {SearchInput, SearchInputRef} from 'view/com/util/forms/SearchInput'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {
|
||||
LoadingPlaceholder,
|
||||
@@ -35,7 +42,11 @@ import {
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
||||
|
||||
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
|
||||
|
||||
@@ -121,6 +132,7 @@ export function FeedsScreen(_props: Props) {
|
||||
} = useSearchPopularFeedsMutation()
|
||||
const {hasSession} = useSession()
|
||||
const listRef = React.useRef<FlatList>(null)
|
||||
const searchInputRef = React.useRef<SearchInputRef>(null)
|
||||
|
||||
/**
|
||||
* A search query is present. We may not have search results yet.
|
||||
@@ -207,12 +219,7 @@ export function FeedsScreen(_props: Props) {
|
||||
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
||||
})
|
||||
} else {
|
||||
if (preferences?.feeds?.saved.length === 0) {
|
||||
slices.push({
|
||||
key: 'savedFeedNoResults',
|
||||
type: 'savedFeedNoResults',
|
||||
})
|
||||
} else {
|
||||
if (preferences?.feeds?.saved.length !== 0) {
|
||||
const {saved, pinned} = preferences.feeds
|
||||
|
||||
slices = slices.concat(
|
||||
@@ -330,14 +337,26 @@ export function FeedsScreen(_props: Props) {
|
||||
|
||||
const renderHeaderBtn = React.useCallback(() => {
|
||||
return (
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
<View style={styles.headerBtnGroup}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={searchInputRef.current?.focus}>
|
||||
<MagnifyingGlassIcon2
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
style={pal.textLight}
|
||||
/>
|
||||
</Pressable>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}, [pal, _])
|
||||
|
||||
@@ -380,34 +399,48 @@ export function FeedsScreen(_props: Props) {
|
||||
) {
|
||||
return (
|
||||
<View style={s.p10}>
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'savedFeedsHeader') {
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>My Feeds</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
accessibilityLabel={_(msg`Edit My Feeds`)}
|
||||
accessibilityHint="">
|
||||
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <View />
|
||||
return (
|
||||
<>
|
||||
{!isMobile && (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>Feeds</Trans>
|
||||
</Text>
|
||||
<View style={styles.headerBtnGroup}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={searchInputRef.current?.focus}>
|
||||
<MagnifyingGlassIcon2
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
style={pal.icon}
|
||||
/>
|
||||
</Pressable>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
accessibilityLabel={_(msg`Edit My Feeds`)}
|
||||
accessibilityHint="">
|
||||
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />}
|
||||
</>
|
||||
)
|
||||
} else if (item.type === 'savedFeedNoResults') {
|
||||
return (
|
||||
<View
|
||||
@@ -425,45 +458,17 @@ export function FeedsScreen(_props: Props) {
|
||||
} else if (item.type === 'popularFeedsHeader') {
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
{
|
||||
// This is first in the flatlist without a session -esb
|
||||
marginTop: hasSession ? 16 : 0,
|
||||
paddingLeft: isMobile ? 12 : undefined,
|
||||
paddingRight: 10,
|
||||
paddingBottom: isMobile ? 6 : undefined,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>Discover new feeds</Trans>
|
||||
</Text>
|
||||
|
||||
{!isMobile && (
|
||||
<SearchInput
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
style={{flex: 1, maxWidth: 250}}
|
||||
/>
|
||||
)}
|
||||
<FeedsAboutHeader />
|
||||
<View style={{paddingHorizontal: 12, paddingBottom: 12}}>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isMobile && (
|
||||
<View style={{paddingHorizontal: 8, paddingBottom: 10}}>
|
||||
<SearchInput
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (item.type === 'popularFeedsLoading') {
|
||||
@@ -495,15 +500,20 @@ export function FeedsScreen(_props: Props) {
|
||||
return null
|
||||
},
|
||||
[
|
||||
_,
|
||||
hasSession,
|
||||
isMobile,
|
||||
pal,
|
||||
pal.view,
|
||||
pal.border,
|
||||
pal.text,
|
||||
pal.icon,
|
||||
pal.textLight,
|
||||
_,
|
||||
preferences?.feeds?.saved?.length,
|
||||
query,
|
||||
onChangeQuery,
|
||||
onPressCancelSearch,
|
||||
onSubmitQuery,
|
||||
onChangeSearchFocus,
|
||||
hasSession,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -518,8 +528,6 @@ export function FeedsScreen(_props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{preferences ? <View /> : <ActivityIndicator />}
|
||||
|
||||
<List
|
||||
ref={listRef}
|
||||
style={[!isTabletOrDesktop && s.flex1, styles.list]}
|
||||
@@ -626,6 +634,71 @@ function SavedFeedLoadingPlaceholder() {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsSavedHeader() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
isWeb
|
||||
? [
|
||||
a.flex_row,
|
||||
a.px_md,
|
||||
a.py_lg,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]
|
||||
: [
|
||||
{flexDirection: 'row-reverse'},
|
||||
a.p_lg,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]
|
||||
}>
|
||||
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>My Feeds</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>All the feeds you've saved, right in one place.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsAboutHeader() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
isWeb
|
||||
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
|
||||
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
|
||||
}>
|
||||
<IconCircle
|
||||
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
|
||||
size="lg"
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>Discover New Feeds</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
Custom feeds built by the community bring you new experiences and
|
||||
help you find the content you love.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -663,4 +736,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
headerBtnGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 15,
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -34,8 +34,8 @@ export const PrivacyPolicyScreen = (_props: Props) => {
|
||||
The Privacy Policy has been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
text="blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text="bsky.social/about/support/privacy-policy"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text, P} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {InlineLink, Link} from '#/components/Link'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
|
||||
export function ExportCarDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const downloadUrl = React.useMemo(() => {
|
||||
const agent = getAgent()
|
||||
if (!currentAccount || !agent.session) {
|
||||
return '' // shouldnt ever happen
|
||||
}
|
||||
// eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz
|
||||
const url = new URL(agent.pdsUrl || agent.service)
|
||||
url.pathname = '/xrpc/com.atproto.sync.getRepo'
|
||||
url.searchParams.set('did', agent.session.did)
|
||||
return url.toString()
|
||||
}, [currentAccount])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
accessibilityLabelledBy="dialog-title">
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Export My Data</Trans>
|
||||
</Text>
|
||||
<P nativeID="dialog-description" style={[a.text_sm]}>
|
||||
<Trans>
|
||||
Your account repository, containing all public data records, can
|
||||
be downloaded as a "CAR" file. This file does not include media
|
||||
embeds, such as images, or your private data, which must be
|
||||
fetched separately.
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<Link
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Download CAR file`)}
|
||||
to={downloadUrl}
|
||||
download="repo.car">
|
||||
<ButtonText>
|
||||
<Trans>Download CAR file</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
|
||||
<P
|
||||
style={[
|
||||
a.py_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
]}>
|
||||
<Trans>
|
||||
This feature is in beta. You can read more about repository
|
||||
exports in{' '}
|
||||
<InlineLink
|
||||
to="https://atproto.com/blog/repo-export"
|
||||
style={[a.text_sm]}>
|
||||
this blogpost.
|
||||
</InlineLink>
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
testID="doneBtn"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Done`)}>
|
||||
{_(msg`Done`)}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{!gtMobile && <View style={{height: 40}} />}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -17,14 +17,6 @@ import {
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import * as AppInfo from 'lib/app-info'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {ScrollView} from '../com/util/Views'
|
||||
import {Link, TextLink} from '../com/util/Link'
|
||||
import {Text} from '../com/util/text/Text'
|
||||
import * as Toast from '../com/util/Toast'
|
||||
import {UserAvatar} from '../com/util/UserAvatar'
|
||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
@@ -34,8 +26,6 @@ import {NavigationProp} from 'lib/routes/types'
|
||||
import {HandIcon, HashtagIcon} from 'lib/icons'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
||||
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
@@ -48,22 +38,12 @@ import {
|
||||
useRequireAltTextEnabled,
|
||||
useSetRequireAltTextEnabled,
|
||||
} from '#/state/preferences'
|
||||
import {
|
||||
useSession,
|
||||
useSessionApi,
|
||||
SessionAccount,
|
||||
getAgent,
|
||||
} from '#/state/session'
|
||||
import {useSession, useSessionApi, SessionAccount} from '#/state/session'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useClearPreferencesMutation} from '#/state/queries/preferences'
|
||||
import {useInviteCodesQuery} from '#/state/queries/invites'
|
||||
// TODO import {useInviteCodesQuery} from '#/state/queries/invites'
|
||||
import {clear as clearStorage} from '#/state/persisted/store'
|
||||
import {clearLegacyStorage} from '#/state/persisted/legacy'
|
||||
|
||||
// TEMPORARY (APP-700)
|
||||
// remove after backend testing finishes
|
||||
// -prf
|
||||
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
|
||||
import {STATUS_PAGE_URL} from 'lib/constants'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -75,6 +55,19 @@ import {
|
||||
useSetInAppBrowser,
|
||||
} from '#/state/preferences/in-app-browser'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {ScrollView} from 'view/com/util/Views'
|
||||
import {Link, TextLink} from 'view/com/util/Link'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
||||
import {ExportCarDialog} from './ExportCarDialog'
|
||||
|
||||
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
||||
const pal = usePalette('default')
|
||||
@@ -159,23 +152,22 @@ export function SettingsScreen({}: Props) {
|
||||
const {screen, track} = useAnalytics()
|
||||
const {openModal} = useModalControls()
|
||||
const {isSwitchingAccounts, accounts, currentAccount} = useSession()
|
||||
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
|
||||
getAgent(),
|
||||
)
|
||||
const {mutate: clearPreferences} = useClearPreferencesMutation()
|
||||
const {data: invites} = useInviteCodesQuery()
|
||||
const invitesAvailable = invites?.available?.length ?? 0
|
||||
// TODO
|
||||
// const {data: invites} = useInviteCodesQuery()
|
||||
// const invitesAvailable = invites?.available?.length ?? 0
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const exportCarControl = useDialogControl()
|
||||
|
||||
const primaryBg = useCustomPalette<ViewStyle>({
|
||||
light: {backgroundColor: colors.blue0},
|
||||
dark: {backgroundColor: colors.blue6},
|
||||
})
|
||||
const primaryText = useCustomPalette<TextStyle>({
|
||||
light: {color: colors.blue3},
|
||||
dark: {color: colors.blue2},
|
||||
})
|
||||
// const primaryBg = useCustomPalette<ViewStyle>({
|
||||
// light: {backgroundColor: colors.blue0},
|
||||
// dark: {backgroundColor: colors.blue6},
|
||||
// })
|
||||
// const primaryText = useCustomPalette<TextStyle>({
|
||||
// light: {color: colors.blue3},
|
||||
// dark: {color: colors.blue2},
|
||||
// })
|
||||
|
||||
const dangerBg = useCustomPalette<ViewStyle>({
|
||||
light: {backgroundColor: colors.red1},
|
||||
@@ -214,10 +206,16 @@ export function SettingsScreen({}: Props) {
|
||||
})
|
||||
}, [track, queryClient, openModal, currentAccount])
|
||||
|
||||
const onPressExportRepository = React.useCallback(() => {
|
||||
exportCarControl.open()
|
||||
}, [exportCarControl])
|
||||
|
||||
/* TODO
|
||||
const onPressInviteCodes = React.useCallback(() => {
|
||||
track('Settings:InvitecodesButtonClicked')
|
||||
openModal({name: 'invite-codes'})
|
||||
}, [track, openModal])
|
||||
*/
|
||||
|
||||
const onPressLanguageSettings = React.useCallback(() => {
|
||||
navigation.navigate('LanguageSettings')
|
||||
@@ -282,6 +280,8 @@ export function SettingsScreen({}: Props) {
|
||||
|
||||
return (
|
||||
<View style={s.hContentRegion} testID="settingsScreen">
|
||||
<ExportCarDialog control={exportCarControl} />
|
||||
|
||||
<SimpleViewHeader
|
||||
showBackButton={isMobile}
|
||||
style={[
|
||||
@@ -395,51 +395,57 @@ export function SettingsScreen({}: Props) {
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Invite a Friend</Trans>
|
||||
</Text>
|
||||
{/* TODO (
|
||||
<>
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Invite a Friend</Trans>
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
testID="inviteFriendBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Invite`)}
|
||||
accessibilityHint={_(msg`Opens invite code list`)}
|
||||
disabled={invites?.disabled}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={
|
||||
(invitesAvailable > 0
|
||||
? primaryText
|
||||
: pal.text) as FontAwesomeIconStyle
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}>
|
||||
{invites?.disabled ? (
|
||||
<Trans>
|
||||
Your invite codes are hidden when logged in using an App
|
||||
Password
|
||||
</Trans>
|
||||
) : invitesAvailable === 1 ? (
|
||||
<Trans>{invitesAvailable} invite code available</Trans>
|
||||
) : (
|
||||
<Trans>{invitesAvailable} invite codes available</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="inviteFriendBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Invite`)}
|
||||
accessibilityHint={_(msg`Opens invite code list`)}
|
||||
disabled={invites?.disabled}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={
|
||||
(invitesAvailable > 0
|
||||
? primaryText
|
||||
: pal.text) as FontAwesomeIconStyle
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
type="lg"
|
||||
style={invitesAvailable > 0 ? pal.link : pal.text}>
|
||||
{invites?.disabled ? (
|
||||
<Trans>
|
||||
Your invite codes are hidden when logged in using an App
|
||||
Password
|
||||
</Trans>
|
||||
) : invitesAvailable === 1 ? (
|
||||
<Trans>{invitesAvailable} invite code available</Trans>
|
||||
) : (
|
||||
<Trans>{invitesAvailable} invite codes available</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
<View style={styles.spacer20} />
|
||||
</>
|
||||
)*/}
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Accessibility</Trans>
|
||||
@@ -735,6 +741,29 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Change Password</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="exportRepositoryBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressExportRepository}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Export my data`)}
|
||||
accessibilityHint={_(
|
||||
msg`Download Bluesky account data (repository)`,
|
||||
)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="download"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text} numberOfLines={1}>
|
||||
<Trans>Export My Data</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCard]}
|
||||
onPress={onPressDeleteAccount}
|
||||
@@ -756,9 +785,6 @@ export function SettingsScreen({}: Props) {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.spacer20} />
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Developer Tools</Trans>
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={onPressSystemLog}
|
||||
@@ -769,14 +795,6 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>System log</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{__DEV__ ? (
|
||||
<ToggleButton
|
||||
type="default-light"
|
||||
label="Experiment: Use AppView Proxy"
|
||||
isSelected={debugHeaderEnabled}
|
||||
onPress={toggleDebugHeader}
|
||||
/>
|
||||
) : null}
|
||||
{__DEV__ ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
@@ -863,13 +881,13 @@ export function SettingsScreen({}: Props) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms of Service`)}
|
||||
/>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
/>
|
||||
</View>
|
||||
@@ -14,19 +14,19 @@ export function Links() {
|
||||
|
||||
<View style={[a.gap_md, a.align_start]}>
|
||||
<InlineLink
|
||||
to="https://blueskyweb.xyz"
|
||||
to="https://bsky.social"
|
||||
warnOnMismatchingTextChild
|
||||
style={[a.text_md]}>
|
||||
External
|
||||
</InlineLink>
|
||||
<InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}>
|
||||
<InlineLink to="https://bsky.social" style={[a.text_md]}>
|
||||
<H3>External with custom children</H3>
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://blueskyweb.xyz"
|
||||
to="https://bsky.social"
|
||||
warnOnMismatchingTextChild
|
||||
style={[a.text_lg]}>
|
||||
https://blueskyweb.xyz
|
||||
https://bsky.social
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://bsky.app/profile/bsky.app"
|
||||
|
||||
@@ -21,11 +21,11 @@ export function Typography() {
|
||||
|
||||
<RichText
|
||||
resolveFacets
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
/>
|
||||
<RichText
|
||||
resolveFacets
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
style={[a.text_xl]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -33,8 +33,8 @@ export const TermsOfServiceScreen = (_props: Props) => {
|
||||
<Trans>The Terms of Service have been moved to</Trans>{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
text="blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text="bsky.social/about/support/tos"
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -44,12 +44,10 @@ import {formatCountShortOnly} from 'view/com/util/numeric/format'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSetDrawerOpen} from '#/state/shell'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession, SessionAccount} from '#/state/session'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {useInviteCodesQuery} from '#/state/queries/invites'
|
||||
import {NavSignupCard} from '#/view/shell/NavSignupCard'
|
||||
import {TextLink} from '../com/util/Link'
|
||||
|
||||
@@ -228,8 +226,7 @@ let DrawerContent = ({}: {}): React.ReactNode => {
|
||||
|
||||
{hasSession ? (
|
||||
<>
|
||||
<InviteCodes />
|
||||
<View style={{height: 10}} />
|
||||
<View style={{height: 16}} />
|
||||
<SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
|
||||
<HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
|
||||
<NotificationsMenuItem
|
||||
@@ -255,13 +252,13 @@ let DrawerContent = ({}: {}): React.ReactNode => {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms of Service`)}
|
||||
/>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
/>
|
||||
</View>
|
||||
@@ -621,56 +618,6 @@ function MenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
let InviteCodes = ({}: {}): React.ReactNode => {
|
||||
const {track} = useAnalytics()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
const pal = usePalette('default')
|
||||
const {data: invites} = useInviteCodesQuery()
|
||||
const invitesAvailable = invites?.available?.length ?? 0
|
||||
const {openModal} = useModalControls()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
track('Menu:ItemClicked', {url: '#invite-codes'})
|
||||
setDrawerOpen(false)
|
||||
openModal({name: 'invite-codes'})
|
||||
}, [openModal, track, setDrawerOpen])
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="menuItemInviteCodes"
|
||||
style={styles.inviteCodes}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Invite codes: ${invitesAvailable} available`)}
|
||||
accessibilityHint={_(msg`Opens list of invite codes`)}
|
||||
disabled={invites?.disabled}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={18}
|
||||
/>
|
||||
<Text
|
||||
type="lg-medium"
|
||||
style={invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
{invites?.disabled ? (
|
||||
<Trans>
|
||||
Your invite codes are hidden when logged in using an App Password
|
||||
</Trans>
|
||||
) : invitesAvailable === 1 ? (
|
||||
<Trans>{invitesAvailable} invite code available</Trans>
|
||||
) : (
|
||||
<Trans>{invitesAvailable} invite codes available</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
InviteCodes = React.memo(InviteCodes)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
view: {
|
||||
flex: 1,
|
||||
|
||||
@@ -334,7 +334,7 @@ export function DesktopLeftNav() {
|
||||
}
|
||||
iconFilled={
|
||||
<HashtagIcon
|
||||
strokeWidth={2.5}
|
||||
strokeWidth={4}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={isDesktop ? 24 : 28}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {DesktopSearch} from './Search'
|
||||
import {DesktopFeeds} from './Feeds'
|
||||
@@ -9,12 +8,9 @@ import {TextLink} from 'view/com/util/Link'
|
||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
|
||||
import {s} from 'lib/styles'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans, msg, plural} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useInviteCodesQuery} from '#/state/queries/invites'
|
||||
|
||||
export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
const pal = usePalette('default')
|
||||
@@ -80,7 +76,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy`)}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
@@ -89,7 +85,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms`)}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
@@ -103,78 +99,11 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{hasSession && <InviteCodes />}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteCodes() {
|
||||
const pal = usePalette('default')
|
||||
const {openModal} = useModalControls()
|
||||
const {data: invites} = useInviteCodesQuery()
|
||||
const invitesAvailable = invites?.available?.length ?? 0
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
openModal({name: 'invite-codes'})
|
||||
}, [openModal])
|
||||
|
||||
if (!invites) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (invites?.disabled) {
|
||||
return (
|
||||
<View style={[styles.inviteCodes, pal.border]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[styles.inviteCodesIcon, pal.textLight]}
|
||||
size={16}
|
||||
/>
|
||||
<Text type="md-medium" style={pal.textLight}>
|
||||
<Trans>
|
||||
Your invite codes are hidden when logged in using an App Password
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.inviteCodes, pal.border]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(
|
||||
plural(invitesAvailable, {
|
||||
one: 'Invite codes: # available',
|
||||
other: 'Invite codes: # available',
|
||||
}),
|
||||
)}
|
||||
accessibilityHint={_(msg`Opens list of invite codes`)}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[
|
||||
styles.inviteCodesIcon,
|
||||
invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||
]}
|
||||
size={16}
|
||||
/>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||
<Plural
|
||||
value={formatCount(invitesAvailable)}
|
||||
one="# invite code available"
|
||||
other="# invite codes available"
|
||||
/>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
rightNav: {
|
||||
// @ts-ignore web only
|
||||
@@ -193,18 +122,6 @@ const styles = StyleSheet.create({
|
||||
messageLine: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
|
||||
inviteCodes: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
inviteCodesIcon: {
|
||||
marginTop: 2,
|
||||
marginRight: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
desktopFeedsContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover"
|
||||
/>
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
-->
|
||||
<link rel="preconnect" href="https://bsky.social">
|
||||
<link rel="preconnect" href="https://bsky.network">
|
||||
<title>%WEB_TITLE%</title>
|
||||
<style>
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user