Merge branch 'main' into hailey/use-eas-version-code

This commit is contained in:
Hailey
2024-02-15 13:32:10 -08:00
committed by GitHub
84 changed files with 12103 additions and 6270 deletions
+2 -2
View File
@@ -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.*`.
+3 -8
View File
@@ -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()
})
})
+4 -4
View File
@@ -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 () => {
+3 -8
View File
@@ -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()
})
})
+33 -32
View File
@@ -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()
+4 -4
View File
@@ -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 () => {
+1
View File
@@ -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()
+5 -9
View File
@@ -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')
+4 -4
View File
@@ -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
View File
@@ -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')
}
+6 -1
View File
@@ -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 -%}
+11
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ module.exports = {
'uk',
'ca',
'zh-CN',
'it',
],
catalogs: [
{
+1
View File
@@ -52,6 +52,7 @@ export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress'> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
}
+33 -21
View File
@@ -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
View File
@@ -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
+20 -36
View File
@@ -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={[
+1 -1
View File
@@ -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
-60
View File
@@ -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'
}
+2 -1
View File
@@ -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,
+2
View File
@@ -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
}
+6
View File
@@ -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})
+4
View File
@@ -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
+2
View File
@@ -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[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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()
-7
View File
@@ -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
+146
View File
@@ -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
}
}
+1
View File
@@ -112,6 +112,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
hiddenPosts: defaults.hiddenPosts,
externalEmbeds: defaults.externalEmbeds,
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
pdsAddressHistory: defaults.pdsAddressHistory,
}
}
+2
View File
@@ -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: [],
}
+3 -3
View File
@@ -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]}
/>
+78 -1
View File
@@ -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',
},
})
+75 -10
View File
@@ -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: {},
})
}
+77 -25
View File
@@ -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 />
+13 -9
View File
@@ -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>
+10 -7
View File
@@ -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>
+173
View File
@@ -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>
-4
View File
@@ -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} />
-3
View File
@@ -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') {
-189
View File
@@ -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,
},
}),
},
})
+1 -1
View File
@@ -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]
+14 -21
View File
@@ -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)
},
)
}}
/>
)
+136 -85
View File
@@ -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,
},
})
+111 -127
View File
@@ -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)}>
&middot;&nbsp;{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',
},
})
+71 -56
View File
@@ -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,
+1 -1
View File
@@ -208,7 +208,7 @@ let PostCtrls = ({
{big && (
<View style={styles.ctrlBig}>
<TouchableOpacity
testID="likeBtn"
testID="shareBtn"
style={[styles.btn]}
onPress={onShare}
accessibilityRole="button"
+2
View File
@@ -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,
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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',
},
})
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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"
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+3 -56
View File
@@ -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,
+1 -1
View File
@@ -334,7 +334,7 @@ export function DesktopLeftNav() {
}
iconFilled={
<HashtagIcon
strokeWidth={2.5}
strokeWidth={4}
style={pal.text as FontAwesomeIconStyle}
size={isDesktop ? 24 : 28}
/>
+4 -87
View File
@@ -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,
+5
View File
@@ -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>
/**