Merge remote-tracking branch 'origin/main' into 3p-moderators
* origin/main: (69 commits) Update .po files use `showControls` to show/hide live text icon on ios (#2982) Fix dim mode unread notif color Make dim theme dim (#2966) Add handle validation to create account UI (#2959) Normalize relative day (#2874) increase timeout to 15s (#2958) use `useOpenLink` hook for links in ALF (#2975) Rename Home Feed Prefs to Following Feed Prefs (#2965) Refactor feed header components (#2964) patch react-navigation to fix history bug (#2955) Use EAS managed build number, run build/submit on GH Actions (#2841) Fix `numberOfLines` not updating on iOS 15 (#2956) Fix UITextView line height adjustment for DynamicType, always use the max width for the view (#2916) Navigate back from a deleted post's route (#2948) Add optional close callback to Dialog (#2947) Fix flash when pressing into just-created post (#2945) Last usage (#2944) Update blogpost URL in ExportCarDialog.tsx (#2939) Prefer full posts for post thread placeholder (#2943) ...
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
||||
import {s, colors} from 'lib/styles'
|
||||
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
|
||||
export const SplashScreen = ({
|
||||
onPressSignin,
|
||||
@@ -20,6 +29,22 @@ export const SplashScreen = ({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -58,6 +83,51 @@ export const SplashScreen = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<View style={{position: 'relative'}}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: pal.textLight.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: pal.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
size={10}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{height: insets.bottom}} />
|
||||
</ErrorBoundary>
|
||||
</CenteredView>
|
||||
)
|
||||
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
btns: {
|
||||
paddingBottom: 40,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
fontSize: 21,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
|
||||
export const SplashScreen = ({
|
||||
onDismiss,
|
||||
@@ -98,24 +102,84 @@ export const SplashScreen = ({
|
||||
|
||||
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = ev.target.value
|
||||
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
href="https://bsky.social"
|
||||
text="Business"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/blog"
|
||||
text="Blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/join"
|
||||
text="Jobs"
|
||||
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: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import {WebView, WebViewNavigation} from 'react-native-webview'
|
||||
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {CreateAccountState} from 'view/com/auth/create/state'
|
||||
|
||||
const ALLOWED_HOSTS = [
|
||||
'bsky.social',
|
||||
'bsky.app',
|
||||
'staging.bsky.app',
|
||||
'staging.bsky.dev',
|
||||
'js.hcaptcha.com',
|
||||
'newassets.hcaptcha.com',
|
||||
'api2.hcaptcha.com',
|
||||
]
|
||||
|
||||
export function CaptchaWebView({
|
||||
url,
|
||||
stateParam,
|
||||
uiState,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
url: string
|
||||
stateParam: string
|
||||
uiState?: CreateAccountState
|
||||
onSuccess: (code: string) => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const redirectHost = React.useMemo(() => {
|
||||
if (!uiState?.serviceUrl) return 'bsky.app'
|
||||
|
||||
return uiState?.serviceUrl &&
|
||||
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
|
||||
? 'staging.bsky.app'
|
||||
: 'bsky.app'
|
||||
}, [uiState?.serviceUrl])
|
||||
|
||||
const wasSuccessful = React.useRef(false)
|
||||
|
||||
const onShouldStartLoadWithRequest = React.useCallback(
|
||||
(event: ShouldStartLoadRequest) => {
|
||||
const urlp = new URL(event.url)
|
||||
return ALLOWED_HOSTS.includes(urlp.host)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onNavigationStateChange = React.useCallback(
|
||||
(e: WebViewNavigation) => {
|
||||
if (wasSuccessful.current) return
|
||||
|
||||
const urlp = new URL(e.url)
|
||||
if (urlp.host !== redirectHost) return
|
||||
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
wasSuccessful.current = true
|
||||
onSuccess(code)
|
||||
},
|
||||
[redirectHost, stateParam, onSuccess, onError],
|
||||
)
|
||||
|
||||
return (
|
||||
<WebView
|
||||
source={{uri: url}}
|
||||
javaScriptEnabled
|
||||
style={styles.webview}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 10,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
// @ts-ignore web only, we will always redirect to the app on web (CORS)
|
||||
const REDIRECT_HOST = new URL(window.location.href).host
|
||||
|
||||
export function CaptchaWebView({
|
||||
url,
|
||||
stateParam,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
url: string
|
||||
stateParam: string
|
||||
onSuccess: (code: string) => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const onLoad = React.useCallback(() => {
|
||||
// @ts-ignore web
|
||||
const frame: HTMLIFrameElement = document.getElementById(
|
||||
'captcha-iframe',
|
||||
) as HTMLIFrameElement
|
||||
|
||||
try {
|
||||
// @ts-ignore web
|
||||
const href = frame?.contentWindow?.location.href
|
||||
if (!href) return
|
||||
const urlp = new URL(href)
|
||||
|
||||
// This shouldn't happen with CORS protections, but for good measure
|
||||
if (urlp.host !== REDIRECT_HOST) return
|
||||
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
onSuccess(code)
|
||||
} catch (e) {
|
||||
// We don't need to handle this
|
||||
}
|
||||
}, [stateParam, onSuccess, onError])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
style={styles.iframe}
|
||||
id="captcha-iframe"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iframe: {
|
||||
flex: 1,
|
||||
borderWidth: 0,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
})
|
||||
@@ -13,33 +13,25 @@ import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useCreateAccount, submit} from './state'
|
||||
import {useCreateAccount, useSubmitCreateAccount} from './state'
|
||||
import {useServiceQuery} from '#/state/queries/service'
|
||||
import {
|
||||
usePreferencesSetBirthDateMutation,
|
||||
useSetSaveFeedsMutation,
|
||||
DEFAULT_PROD_FEEDS,
|
||||
} from '#/state/queries/preferences'
|
||||
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
|
||||
import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
|
||||
|
||||
import {Step1} from './Step1'
|
||||
import {Step2} from './Step2'
|
||||
import {Step3} from './Step3'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {TextLink} from '../../util/Link'
|
||||
import {getAgent} from 'state/session'
|
||||
import {createFullHandle, validateHandle} from 'lib/strings/handles'
|
||||
|
||||
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
|
||||
const {screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const [uiState, uiDispatch] = useCreateAccount()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {createAccount} = useSessionApi()
|
||||
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
|
||||
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const submit = useSubmitCreateAccount(uiState, uiDispatch)
|
||||
|
||||
React.useEffect(() => {
|
||||
screen('CreateAccount')
|
||||
@@ -84,33 +76,52 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
|
||||
if (!uiState.canNext) {
|
||||
return
|
||||
}
|
||||
if (uiState.step < 3) {
|
||||
uiDispatch({type: 'next'})
|
||||
} else {
|
||||
|
||||
if (uiState.step === 2) {
|
||||
if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
|
||||
return
|
||||
}
|
||||
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
try {
|
||||
await submit({
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
const res = await getAgent().resolveHandle({
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
})
|
||||
setBirthDate({birthDate: uiState.birthDate})
|
||||
if (IS_PROD(uiState.serviceUrl)) {
|
||||
setSavedFeeds(DEFAULT_PROD_FEEDS)
|
||||
|
||||
if (res.data.did) {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`That handle is already taken.`),
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// dont need to handle here
|
||||
} catch (e) {
|
||||
// Don't need to handle
|
||||
} finally {
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
}
|
||||
|
||||
if (!uiState.isCaptchaRequired) {
|
||||
try {
|
||||
await submit()
|
||||
} catch {
|
||||
// dont need to handle here
|
||||
}
|
||||
// We don't need to go to the next page if there wasn't a captcha required
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uiDispatch({type: 'next'})
|
||||
}, [
|
||||
uiState,
|
||||
uiState.canNext,
|
||||
uiState.step,
|
||||
uiState.isCaptchaRequired,
|
||||
uiState.handle,
|
||||
uiState.userDomain,
|
||||
uiDispatch,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
setBirthDate,
|
||||
setSavedFeeds,
|
||||
_,
|
||||
submit,
|
||||
])
|
||||
|
||||
// rendering
|
||||
|
||||
@@ -73,6 +73,10 @@ export function Step1({
|
||||
/>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Hosting provider</Trans>
|
||||
@@ -88,7 +92,7 @@ export function Step1({
|
||||
style={[pal.textLight, {marginLeft: 14}]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="loginSelectServiceButton"
|
||||
testID="selectServiceButton"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
@@ -259,9 +263,6 @@ export function Step1({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -269,7 +270,7 @@ export function Step1({
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
dateInputButton: {
|
||||
borderWidth: 1,
|
||||
|
||||
+106
-273
@@ -1,35 +1,26 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {
|
||||
CreateAccountState,
|
||||
CreateAccountDispatch,
|
||||
requestVerificationCode,
|
||||
} from './state'
|
||||
import {View} from 'react-native'
|
||||
import {CreateAccountState, CreateAccountDispatch} from './state'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
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 {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import parsePhoneNumber from 'libphonenumber-js'
|
||||
import {COUNTRY_CODES} from '#/lib/country-codes'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
createFullHandle,
|
||||
IsValidHandle,
|
||||
validateHandle,
|
||||
} from 'lib/strings/handles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
/** STEP 3: Your user handle
|
||||
* @field User handle
|
||||
*/
|
||||
export function Step2({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
@@ -39,269 +30,111 @@ export function Step2({
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const t = useTheme()
|
||||
|
||||
const onPressRequest = React.useCallback(() => {
|
||||
const phoneNumber = parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)
|
||||
if (phoneNumber && phoneNumber.isValid()) {
|
||||
requestVerificationCode({uiState, uiDispatch, _})
|
||||
} else {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(
|
||||
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
|
||||
),
|
||||
})
|
||||
}
|
||||
}, [uiState, uiDispatch, _])
|
||||
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
|
||||
handleChars: false,
|
||||
frontLength: false,
|
||||
totalLength: true,
|
||||
overall: false,
|
||||
})
|
||||
|
||||
const onPressRetry = React.useCallback(() => {
|
||||
uiDispatch({type: 'set-has-requested-verification-code', value: false})
|
||||
}, [uiDispatch])
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
|
||||
|
||||
const phoneNumberFormatted = React.useMemo(
|
||||
() =>
|
||||
uiState.hasRequestedVerificationCode
|
||||
? parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)?.formatInternational()
|
||||
: '',
|
||||
[
|
||||
uiState.hasRequestedVerificationCode,
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
],
|
||||
// Disabling this, because we only want to run this when we focus the screen
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onHandleChange = React.useCallback(
|
||||
(value: string) => {
|
||||
if (uiState.error) {
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
}
|
||||
|
||||
setValidCheck(validateHandle(value, uiState.userDomain))
|
||||
uiDispatch({type: 'set-handle', value})
|
||||
},
|
||||
[uiDispatch, uiState.error, uiState.userDomain],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
|
||||
|
||||
{!uiState.hasRequestedVerificationCode ? (
|
||||
<>
|
||||
<View style={s.pb10}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="phoneCountry">
|
||||
<Trans>Country</Trans>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
||||
<View style={s.pb10}>
|
||||
<View style={s.mb20}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="e.g. alice"
|
||||
value={uiState.handle}
|
||||
editable
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChange={onHandleChange}
|
||||
// TODO: Add explicit text label
|
||||
accessibilityLabel={_(msg`User handle`)}
|
||||
accessibilityHint={_(msg`Input your user handle`)}
|
||||
/>
|
||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
{position: 'relative'},
|
||||
isAndroid && {
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
borderRadius: 4,
|
||||
},
|
||||
]}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={uiState.phoneCountry}
|
||||
onValueChange={value =>
|
||||
uiDispatch({type: 'set-phone-country', value})
|
||||
}
|
||||
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 21,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputIOS: {
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputWeb: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
'-moz-appearance': 'none',
|
||||
'-webkit-appearance': 'none',
|
||||
appearance: 'none',
|
||||
outline: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
accessibilityLabel={_(msg`Select your phone's country`)}
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="phoneCountry"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 1,
|
||||
right: 1,
|
||||
bottom: 1,
|
||||
width: 40,
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{uiState.error ? (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={false} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
{uiState.error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="phoneNumber">
|
||||
<Trans>Phone number</Trans>
|
||||
) : undefined}
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={validCheck.handleChars} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
<Trans>May only contain letters and numbers</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="phoneInput"
|
||||
icon="phone"
|
||||
placeholder={_(msg`Enter your phone number`)}
|
||||
value={uiState.verificationPhone}
|
||||
editable
|
||||
onChange={value =>
|
||||
uiDispatch({type: 'set-verification-phone', value})
|
||||
}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input phone number for SMS verification`,
|
||||
)}
|
||||
accessibilityLabelledBy="phoneNumber"
|
||||
keyboardType="phone-pad"
|
||||
autoCapitalize="none"
|
||||
autoComplete="tel"
|
||||
autoCorrect={false}
|
||||
autoFocus={true}
|
||||
</View>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon
|
||||
valid={validCheck.frontLength && validCheck.totalLength}
|
||||
/>
|
||||
<Text type="sm" style={[pal.textLight, s.mt5]}>
|
||||
<Trans>
|
||||
Please enter a phone number that can receive SMS text messages.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={isMobile ? {} : {flexDirection: 'row'}}>
|
||||
{uiState.isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
{!validCheck.totalLength ? (
|
||||
<Text style={[t.atoms.text]}>
|
||||
<Trans>May not be longer than 253 characters</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Button
|
||||
testID="requestCodeBtn"
|
||||
type="primary"
|
||||
label={_(msg`Request code`)}
|
||||
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
|
||||
style={
|
||||
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
|
||||
}
|
||||
onPress={onPressRequest}
|
||||
/>
|
||||
<Text style={[t.atoms.text, a.text_md]}>
|
||||
<Trans>Must be at least 3 characters</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={s.pb20}>
|
||||
<View
|
||||
style={[
|
||||
s.flexRow,
|
||||
s.mb5,
|
||||
s.alignCenter,
|
||||
{justifyContent: 'space-between'},
|
||||
]}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={pal.text}
|
||||
nativeID="verificationCode">
|
||||
<Trans>Verification code</Trans>{' '}
|
||||
</Text>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onPressRetry}
|
||||
accessibilityLabel={_(msg`Retry.`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}>
|
||||
<View style={styles.touchable}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={pal.link}
|
||||
nativeID="verificationCode">
|
||||
<Trans>Retry</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
<TextInput
|
||||
testID="codeInput"
|
||||
icon="hashtag"
|
||||
placeholder={_(msg`XXXXXX`)}
|
||||
value={uiState.verificationCode}
|
||||
editable
|
||||
onChange={value =>
|
||||
uiDispatch({type: 'set-verification-code', value})
|
||||
}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input the verification code we have texted to you`,
|
||||
)}
|
||||
accessibilityLabelledBy="verificationCode"
|
||||
keyboardType="phone-pad"
|
||||
autoCapitalize="none"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
autoCorrect={false}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Text type="sm" style={[pal.textLight, s.mt5]}>
|
||||
<Trans>
|
||||
Please enter the verification code sent to{' '}
|
||||
{phoneNumberFormatted}.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
|
||||
touchable: {
|
||||
...(isWeb && {cursor: 'pointer'}),
|
||||
},
|
||||
})
|
||||
function IsValidIcon({valid}: {valid: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!valid) {
|
||||
return <Check size="md" style={{color: t.palette.negative_500}} />
|
||||
}
|
||||
|
||||
return <Times size="md" style={{color: t.palette.positive_700}} />
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {CreateAccountState, CreateAccountDispatch} from './state'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
CreateAccountState,
|
||||
CreateAccountDispatch,
|
||||
useSubmitCreateAccount,
|
||||
} from './state'
|
||||
import {StepHeader} from './StepHeader'
|
||||
import {s} from 'lib/styles'
|
||||
import {TextInput} from '../util/TextInput'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
/** STEP 3: Your user handle
|
||||
* @field User handle
|
||||
*/
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
|
||||
const CAPTCHA_PATH = '/gate/signup'
|
||||
|
||||
export function Step3({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
@@ -21,33 +25,66 @@ export function Step3({
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const submit = useSubmitCreateAccount(uiState, uiDispatch)
|
||||
|
||||
const [completed, setCompleted] = React.useState(false)
|
||||
|
||||
const stateParam = React.useMemo(() => nanoid(15), [])
|
||||
const url = React.useMemo(() => {
|
||||
const newUrl = new URL(uiState.serviceUrl)
|
||||
newUrl.pathname = CAPTCHA_PATH
|
||||
newUrl.searchParams.set(
|
||||
'handle',
|
||||
createFullHandle(uiState.handle, uiState.userDomain),
|
||||
)
|
||||
newUrl.searchParams.set('state', stateParam)
|
||||
newUrl.searchParams.set('colorScheme', theme.colorScheme)
|
||||
|
||||
console.log(newUrl)
|
||||
|
||||
return newUrl.href
|
||||
}, [
|
||||
uiState.serviceUrl,
|
||||
uiState.handle,
|
||||
uiState.userDomain,
|
||||
stateParam,
|
||||
theme.colorScheme,
|
||||
])
|
||||
|
||||
const onSuccess = React.useCallback(
|
||||
(code: string) => {
|
||||
setCompleted(true)
|
||||
submit(code)
|
||||
},
|
||||
[submit],
|
||||
)
|
||||
|
||||
const onError = React.useCallback(() => {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Error receiving captcha response.`),
|
||||
})
|
||||
}, [_, uiDispatch])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
||||
<View style={s.pb10}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="e.g. alice"
|
||||
value={uiState.handle}
|
||||
editable
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChange={value => uiDispatch({type: 'set-handle', value})}
|
||||
// TODO: Add explicit text label
|
||||
accessibilityLabel={_(msg`User handle`)}
|
||||
accessibilityHint={_(msg`Input your user handle`)}
|
||||
/>
|
||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
||||
</Text>
|
||||
</Text>
|
||||
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
|
||||
<View style={[styles.container, completed && styles.center]}>
|
||||
{!completed ? (
|
||||
<CaptchaWebView
|
||||
url={url}
|
||||
stateParam={stateParam}
|
||||
uiState={uiState}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<ActivityIndicator size="large" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
@@ -58,5 +95,20 @@ export function Step3({
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
|
||||
touchable: {
|
||||
...(isWeb && {cursor: 'pointer'}),
|
||||
},
|
||||
container: {
|
||||
minHeight: 500,
|
||||
width: '100%',
|
||||
paddingBottom: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
center: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ export function StepHeader({
|
||||
children,
|
||||
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
|
||||
const pal = usePalette('default')
|
||||
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
|
||||
const numSteps = 3
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View>
|
||||
|
||||
+125
-177
@@ -1,20 +1,23 @@
|
||||
import {useReducer} from 'react'
|
||||
import {useCallback, useReducer} from 'react'
|
||||
import {
|
||||
ComAtprotoServerDescribeServer,
|
||||
ComAtprotoServerCreateAccount,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
import {getAge} from 'lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
|
||||
import {ApiContext as SessionApiContext} from '#/state/session'
|
||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
|
||||
import {useOnboardingDispatch} from '#/state/shell/onboarding'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {
|
||||
DEFAULT_PROD_FEEDS,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
useSetSaveFeedsMutation,
|
||||
} from 'state/queries/preferences'
|
||||
|
||||
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
|
||||
@@ -29,10 +32,6 @@ export type CreateAccountAction =
|
||||
| {type: 'set-invite-code'; value: string}
|
||||
| {type: 'set-email'; value: string}
|
||||
| {type: 'set-password'; value: string}
|
||||
| {type: 'set-phone-country'; value: CountryCode}
|
||||
| {type: 'set-verification-phone'; value: string}
|
||||
| {type: 'set-verification-code'; value: string}
|
||||
| {type: 'set-has-requested-verification-code'; value: boolean}
|
||||
| {type: 'set-handle'; value: string}
|
||||
| {type: 'set-birth-date'; value: Date}
|
||||
| {type: 'next'}
|
||||
@@ -49,10 +48,6 @@ export interface CreateAccountState {
|
||||
inviteCode: string
|
||||
email: string
|
||||
password: string
|
||||
phoneCountry: CountryCode
|
||||
verificationPhone: string
|
||||
verificationCode: string
|
||||
hasRequestedVerificationCode: boolean
|
||||
handle: string
|
||||
birthDate: Date
|
||||
|
||||
@@ -60,13 +55,14 @@ export interface CreateAccountState {
|
||||
canBack: boolean
|
||||
canNext: boolean
|
||||
isInviteCodeRequired: boolean
|
||||
isPhoneVerificationRequired: boolean
|
||||
isCaptchaRequired: boolean
|
||||
}
|
||||
|
||||
export type CreateAccountDispatch = (action: CreateAccountAction) => void
|
||||
|
||||
export function useCreateAccount() {
|
||||
const {_} = useLingui()
|
||||
|
||||
return useReducer(createReducer({_}), {
|
||||
step: 1,
|
||||
error: undefined,
|
||||
@@ -77,144 +73,126 @@ export function useCreateAccount() {
|
||||
inviteCode: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phoneCountry: 'US',
|
||||
verificationPhone: '',
|
||||
verificationCode: '',
|
||||
hasRequestedVerificationCode: false,
|
||||
handle: '',
|
||||
birthDate: DEFAULT_DATE,
|
||||
|
||||
canBack: false,
|
||||
canNext: false,
|
||||
isInviteCodeRequired: false,
|
||||
isPhoneVerificationRequired: false,
|
||||
isCaptchaRequired: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestVerificationCode({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
}: {
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
_: I18nContext['_']
|
||||
}) {
|
||||
const phoneNumber = parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)?.number
|
||||
if (!phoneNumber) {
|
||||
return
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
|
||||
try {
|
||||
const agent = new BskyAgent({service: uiState.serviceUrl})
|
||||
await agent.com.atproto.temp.requestPhoneVerification({
|
||||
phoneNumber,
|
||||
})
|
||||
uiDispatch({type: 'set-has-requested-verification-code', value: true})
|
||||
} catch (e: any) {
|
||||
logger.error(
|
||||
`Failed to request sms verification code (${e.status} status)`,
|
||||
{message: e},
|
||||
)
|
||||
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
|
||||
}
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
}
|
||||
export function useSubmitCreateAccount(
|
||||
uiState: CreateAccountState,
|
||||
uiDispatch: CreateAccountDispatch,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const {createAccount} = useSessionApi()
|
||||
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
|
||||
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
export async function submit({
|
||||
createAccount,
|
||||
onboardingDispatch,
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
}: {
|
||||
createAccount: SessionApiContext['createAccount']
|
||||
onboardingDispatch: OnboardingDispatchContext
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
_: I18nContext['_']
|
||||
}) {
|
||||
if (!uiState.email) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter your email.`),
|
||||
})
|
||||
}
|
||||
if (!EmailValidator.validate(uiState.email)) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Your email appears to be invalid.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.password) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your password.`),
|
||||
})
|
||||
}
|
||||
if (
|
||||
uiState.isPhoneVerificationRequired &&
|
||||
(!uiState.verificationPhone || !uiState.verificationCode)
|
||||
) {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter the code you received by SMS.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.handle) {
|
||||
uiDispatch({type: 'set-step', value: 3})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your handle.`),
|
||||
})
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
return useCallback(
|
||||
async (verificationCode?: string) => {
|
||||
if (!uiState.email) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
console.log('no email?')
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter your email.`),
|
||||
})
|
||||
}
|
||||
if (!EmailValidator.validate(uiState.email)) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Your email appears to be invalid.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.password) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your password.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.handle) {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your handle.`),
|
||||
})
|
||||
}
|
||||
if (uiState.isCaptchaRequired && !verificationCode) {
|
||||
uiDispatch({type: 'set-step', value: 3})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please complete the verification captcha.`),
|
||||
})
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
|
||||
try {
|
||||
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
|
||||
await createAccount({
|
||||
service: uiState.serviceUrl,
|
||||
email: uiState.email,
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
password: uiState.password,
|
||||
inviteCode: uiState.inviteCode.trim(),
|
||||
verificationPhone: uiState.verificationPhone.trim(),
|
||||
verificationCode: uiState.verificationCode.trim(),
|
||||
})
|
||||
} catch (e: any) {
|
||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||
errMsg = _(
|
||||
msg`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
)
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
} else if (e.error === 'InvalidPhoneVerification') {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
}
|
||||
try {
|
||||
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
|
||||
await createAccount({
|
||||
service: uiState.serviceUrl,
|
||||
email: uiState.email,
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
password: uiState.password,
|
||||
inviteCode: uiState.inviteCode.trim(),
|
||||
verificationCode: uiState.isCaptchaRequired
|
||||
? verificationCode
|
||||
: undefined,
|
||||
})
|
||||
setBirthDate({birthDate: uiState.birthDate})
|
||||
if (IS_PROD_SERVICE(uiState.serviceUrl)) {
|
||||
setSavedFeeds(DEFAULT_PROD_FEEDS)
|
||||
}
|
||||
} catch (e: any) {
|
||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||
errMsg = _(
|
||||
msg`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
)
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
}
|
||||
|
||||
if ([400, 429].includes(e.status)) {
|
||||
logger.warn('Failed to create account', {message: e})
|
||||
} else {
|
||||
logger.error(`Failed to create account (${e.status} status)`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if ([400, 429].includes(e.status)) {
|
||||
logger.warn('Failed to create account', {message: e})
|
||||
} else {
|
||||
logger.error(`Failed to create account (${e.status} status)`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
|
||||
throw e
|
||||
}
|
||||
const error = cleanError(errMsg)
|
||||
const isHandleError = error.toLowerCase().includes('handle')
|
||||
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
|
||||
uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
|
||||
}
|
||||
},
|
||||
[
|
||||
uiState.email,
|
||||
uiState.password,
|
||||
uiState.handle,
|
||||
uiState.isCaptchaRequired,
|
||||
uiState.serviceUrl,
|
||||
uiState.userDomain,
|
||||
uiState.inviteCode,
|
||||
uiState.birthDate,
|
||||
uiDispatch,
|
||||
_,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
setBirthDate,
|
||||
setSavedFeeds,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function is13(state: CreateAccountState) {
|
||||
@@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
|
||||
case 'set-password': {
|
||||
return compute({...state, password: action.value})
|
||||
}
|
||||
case 'set-phone-country': {
|
||||
return compute({...state, phoneCountry: action.value})
|
||||
}
|
||||
case 'set-verification-phone': {
|
||||
return compute({
|
||||
...state,
|
||||
verificationPhone: action.value,
|
||||
hasRequestedVerificationCode: false,
|
||||
})
|
||||
}
|
||||
case 'set-verification-code': {
|
||||
return compute({...state, verificationCode: action.value.trim()})
|
||||
}
|
||||
case 'set-has-requested-verification-code': {
|
||||
return compute({...state, hasRequestedVerificationCode: action.value})
|
||||
}
|
||||
case 'set-handle': {
|
||||
return compute({...state, handle: action.value})
|
||||
}
|
||||
@@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
let increment = 1
|
||||
if (state.step === 1 && !state.isPhoneVerificationRequired) {
|
||||
increment = 2
|
||||
}
|
||||
return compute({...state, error: '', step: state.step + increment})
|
||||
return compute({...state, error: '', step: state.step + 1})
|
||||
}
|
||||
case 'back': {
|
||||
let decrement = 1
|
||||
if (state.step === 3 && !state.isPhoneVerificationRequired) {
|
||||
decrement = 2
|
||||
}
|
||||
return compute({...state, error: '', step: state.step - decrement})
|
||||
return compute({...state, error: '', step: state.step - 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,22 +283,16 @@ function compute(state: CreateAccountState): CreateAccountState {
|
||||
!!state.password
|
||||
} else if (state.step === 2) {
|
||||
canNext =
|
||||
!state.isPhoneVerificationRequired ||
|
||||
(!!state.verificationPhone &&
|
||||
isValidVerificationCode(state.verificationCode))
|
||||
!!state.handle && validateHandle(state.handle, state.userDomain).overall
|
||||
} else if (state.step === 3) {
|
||||
canNext = !!state.handle
|
||||
// Step 3 will automatically redirect as soon as the captcha completes
|
||||
canNext = false
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
canBack: state.step > 1,
|
||||
canNext,
|
||||
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
|
||||
isPhoneVerificationRequired:
|
||||
!!state.serviceDescription?.phoneVerificationRequired,
|
||||
isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
|
||||
}
|
||||
}
|
||||
|
||||
function isValidVerificationCode(str: string): boolean {
|
||||
return /[0-9]{6}/.test(str)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 {BSKY_SERVICE} from 'lib/constants'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
@@ -26,7 +26,7 @@ export function ServerInputDialog({
|
||||
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
|
||||
persisted.get('pdsAddressHistory') || [],
|
||||
)
|
||||
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
|
||||
const [fixedOption, setFixedOption] = React.useState([BSKY_SERVICE])
|
||||
const [customAddress, setCustomAddress] = React.useState('')
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
@@ -86,7 +86,7 @@ export function ServerInputDialog({
|
||||
label="Preferences"
|
||||
values={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
|
||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
||||
{_(msg`Bluesky`)}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
|
||||
@@ -46,7 +46,7 @@ export function FeedPage({
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const {isSandbox, hasSession} = useSession()
|
||||
const {hasSession} = useSession()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation()
|
||||
@@ -119,7 +119,7 @@ export function FeedPage({
|
||||
style={[pal.text, {fontWeight: 'bold'}]}
|
||||
text={
|
||||
<>
|
||||
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
|
||||
Bluesky{' '}
|
||||
{hasNew && (
|
||||
<View
|
||||
style={{
|
||||
@@ -138,7 +138,7 @@ export function FeedPage({
|
||||
{hasSession && (
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/home-feed"
|
||||
href="/settings/following-feed"
|
||||
style={{fontWeight: 'bold'}}
|
||||
accessibilityLabel={_(msg`Feed Preferences`)}
|
||||
accessibilityHint=""
|
||||
@@ -154,16 +154,7 @@ export function FeedPage({
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}, [
|
||||
isDesktop,
|
||||
pal.view,
|
||||
pal.text,
|
||||
pal.textLight,
|
||||
hasNew,
|
||||
_,
|
||||
isSandbox,
|
||||
hasSession,
|
||||
])
|
||||
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '#/state/queries/preferences'
|
||||
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
export function FeedSourceCard({
|
||||
feedUri,
|
||||
@@ -82,6 +83,7 @@ export function FeedSourceCardLoaded({
|
||||
pinOnSave?: boolean
|
||||
showMinimalPlaceholder?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -266,8 +268,8 @@ export function FeedSourceCardLoaded({
|
||||
|
||||
{showDescription && feed.description ? (
|
||||
<RichText
|
||||
style={[pal.textLight, styles.description]}
|
||||
richText={feed.description}
|
||||
style={[t.atoms.text_contrast_high, styles.description]}
|
||||
value={feed.description}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {HomeHeaderLayout} from './HomeHeaderLayout'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {TabBar} from '../pager/TabBar'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
|
||||
export function HomeHeader(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
if (isDesktop) {
|
||||
return null
|
||||
}
|
||||
return <HomeHeaderInner {...props} />
|
||||
}
|
||||
|
||||
export function HomeHeaderInner(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const pinnedNames = feeds.map(f => f.displayName)
|
||||
|
||||
if (!hasPinnedCustom) {
|
||||
return pinnedNames.concat('Feeds ✨')
|
||||
}
|
||||
return pinnedNames
|
||||
}, [hasPinnedCustom, feeds])
|
||||
|
||||
const onPressFeedsLink = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.navigate('Feeds')
|
||||
} else {
|
||||
navigation.navigate('FeedsTab')
|
||||
navigation.popToTop()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
if (!hasPinnedCustom && index === items.length - 1) {
|
||||
onPressFeedsLink()
|
||||
} else if (props.onSelect) {
|
||||
props.onSelect(index)
|
||||
}
|
||||
},
|
||||
[items.length, onPressFeedsLink, props, hasPinnedCustom],
|
||||
)
|
||||
|
||||
return (
|
||||
<HomeHeaderLayout>
|
||||
<TabBar
|
||||
key={items.join(',')}
|
||||
onPressSelected={props.onPressSelected}
|
||||
selectedPage={props.selectedPage}
|
||||
onSelect={onSelect}
|
||||
testID={props.testID}
|
||||
items={items}
|
||||
indicatorColor={pal.colors.link}
|
||||
/>
|
||||
</HomeHeaderLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {HomeHeaderLayoutMobile as HomeHeaderLayout} from './HomeHeaderLayoutMobile'
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
|
||||
export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
if (isMobile) {
|
||||
return <HomeHeaderLayoutMobile>{children}</HomeHeaderLayoutMobile>
|
||||
} else {
|
||||
return <HomeHeaderLayoutTablet>{children}</HomeHeaderLayoutTablet>
|
||||
}
|
||||
}
|
||||
|
||||
function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
||||
const pal = usePalette('default')
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
|
||||
return (
|
||||
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
||||
<Animated.View
|
||||
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
|
||||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
zIndex: 1,
|
||||
// @ts-ignore Web only -prf
|
||||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
})
|
||||
+18
-70
@@ -1,7 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {TabBar} from 'view/com/pager/TabBar'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Link} from '../util/Link'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
@@ -13,11 +11,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useSetDrawerOpen} from '#/state/shell/drawer-open'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
|
||||
import {IS_DEV} from '#/env'
|
||||
@@ -25,49 +19,17 @@ import {atoms} from '#/alf'
|
||||
import {Link as Link2} from '#/components/Link'
|
||||
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
|
||||
|
||||
export function FeedsTabBar(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
export function HomeHeaderLayoutMobile({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!hasSession) return []
|
||||
|
||||
const pinnedNames = feeds.map(f => f.displayName)
|
||||
|
||||
if (!hasPinnedCustom) {
|
||||
return pinnedNames.concat('Feeds ✨')
|
||||
}
|
||||
return pinnedNames
|
||||
}, [hasSession, hasPinnedCustom, feeds])
|
||||
|
||||
const onPressFeedsLink = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.navigate('Feeds')
|
||||
} else {
|
||||
navigation.navigate('FeedsTab')
|
||||
navigation.popToTop()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
if (hasSession && !hasPinnedCustom && index === items.length - 1) {
|
||||
onPressFeedsLink()
|
||||
} else if (props.onSelect) {
|
||||
props.onSelect(index)
|
||||
}
|
||||
},
|
||||
[items.length, onPressFeedsLink, props, hasSession, hasPinnedCustom],
|
||||
)
|
||||
|
||||
const onPressAvi = React.useCallback(() => {
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
@@ -113,35 +75,21 @@ export function FeedsTabBar(
|
||||
<ColorPalette size="md" />
|
||||
</Link2>
|
||||
)}
|
||||
|
||||
{hasSession && (
|
||||
<Link
|
||||
testID="viewHeaderHomeFeedPrefsBtn"
|
||||
href="/settings/home-feed"
|
||||
hitSlop={HITSLOP_10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Home Feed Preferences`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
testID="viewHeaderHomeFeedPrefsBtn"
|
||||
href="/settings/following-feed"
|
||||
hitSlop={HITSLOP_10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Following Feed Preferences`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{items.length > 0 && (
|
||||
<TabBar
|
||||
key={items.join(',')}
|
||||
onPressSelected={props.onPressSelected}
|
||||
selectedPage={props.selectedPage}
|
||||
onSelect={onSelect}
|
||||
testID={props.testID}
|
||||
items={items}
|
||||
indicatorColor={pal.colors.link}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type Props = {
|
||||
onTap: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
}
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
|
||||
@@ -37,11 +37,18 @@ type Props = {
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
showControls,
|
||||
}: Props) => {
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const translationY = useSharedValue(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
@@ -144,7 +151,7 @@ const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
onLoad={() => setLoaded(true)}
|
||||
enableLiveTextInteraction={!scaled}
|
||||
enableLiveTextInteraction={showControls && !scaled}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
}
|
||||
|
||||
const ImageItem = (_props: Props) => {
|
||||
|
||||
@@ -122,6 +122,7 @@ function ImageViewing({
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestClose}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
showControls={showControls}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||
import {RichText as RichTextCom} from '#/components/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -12,6 +12,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
@@ -119,9 +120,9 @@ export const ListCard = ({
|
||||
{descriptionRichText ? (
|
||||
<View style={styles.details}>
|
||||
<RichTextCom
|
||||
style={[pal.text, s.flex1]}
|
||||
style={[a.flex_1]}
|
||||
numberOfLines={20}
|
||||
richText={descriptionRichText}
|
||||
value={descriptionRichText}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './FeedsTabBarMobile'
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {TabBar} from 'view/com/pager/TabBar'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TextLink} from '#/view/com/util/Link'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
||||
export function FeedsTabBar(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const {isMobile, isTablet} = useWebMediaQueries()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
if (isMobile) {
|
||||
return <FeedsTabBarMobile {...props} />
|
||||
} else if (isTablet) {
|
||||
if (hasSession) {
|
||||
return <FeedsTabBarTablet {...props} />
|
||||
} else {
|
||||
return <FeedsTabBarPublic />
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function FeedsTabBarPublic() {
|
||||
const pal = usePalette('default')
|
||||
const {isSandbox} = useSession()
|
||||
|
||||
return (
|
||||
<CenteredView sideBorders>
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
]}>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/"
|
||||
style={[pal.text, {fontWeight: 'bold'}]}
|
||||
text={
|
||||
<>
|
||||
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
|
||||
{/*hasNew && (
|
||||
<View
|
||||
style={{
|
||||
top: -8,
|
||||
backgroundColor: colors.blue3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
)*/}
|
||||
</>
|
||||
}
|
||||
// onPress={emitSoftReset}
|
||||
/>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsTabBarTablet(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
const pal = usePalette('default')
|
||||
const {hasSession} = useSession()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!hasSession) return []
|
||||
|
||||
const pinnedNames = feeds.map(f => f.displayName)
|
||||
|
||||
if (!hasPinnedCustom) {
|
||||
return pinnedNames.concat('Feeds ✨')
|
||||
}
|
||||
return pinnedNames
|
||||
}, [hasSession, hasPinnedCustom, feeds])
|
||||
|
||||
const onPressDiscoverFeeds = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.navigate('Feeds')
|
||||
} else {
|
||||
navigation.navigate('FeedsTab')
|
||||
navigation.popToTop()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
if (hasSession && !hasPinnedCustom && index === items.length - 1) {
|
||||
onPressDiscoverFeeds()
|
||||
} else if (props.onSelect) {
|
||||
props.onSelect(index)
|
||||
}
|
||||
},
|
||||
[items.length, onPressDiscoverFeeds, props, hasSession, hasPinnedCustom],
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
||||
<Animated.View
|
||||
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
|
||||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
<TabBar
|
||||
key={items.join(',')}
|
||||
{...props}
|
||||
onSelect={onSelect}
|
||||
items={items}
|
||||
indicatorColor={pal.colors.link}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
zIndex: 1,
|
||||
// @ts-ignore Web only -prf
|
||||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
})
|
||||
@@ -233,36 +233,29 @@ let PagerTabBar = ({
|
||||
},
|
||||
],
|
||||
}))
|
||||
const pendingHeaderHeight = React.useRef<null | number>(null)
|
||||
const headerRef = React.useRef(null)
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.tabBarMobile, headerTransform]}>
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
onLayout={e => {
|
||||
if (isHeaderReady) {
|
||||
onHeaderOnlyLayout(e.nativeEvent.layout.height)
|
||||
pendingHeaderHeight.current = null
|
||||
} else {
|
||||
// Stash it away for when `isHeaderReady` turns `true` later.
|
||||
pendingHeaderHeight.current = e.nativeEvent.layout.height
|
||||
}
|
||||
}}>
|
||||
<View ref={headerRef} pointerEvents="box-none" collapsable={false}>
|
||||
{renderHeader?.()}
|
||||
{
|
||||
// When `isHeaderReady` turns `true`, we want to send the parent layout.
|
||||
// However, if that didn't lead to a layout change, parent `onLayout` wouldn't get called again.
|
||||
// We're conditionally rendering an empty view so that we can send the last measurement.
|
||||
// It wouldn't be enough to place `onLayout` on the parent node because
|
||||
// this would risk measuring before `isHeaderReady` has turned `true`.
|
||||
// Instead, we'll render a brand node conditionally and get fresh layout.
|
||||
isHeaderReady && (
|
||||
<View
|
||||
// It wouldn't be enough to do this in a `ref` of an effect because,
|
||||
// even if `isHeaderReady` might have turned `true`, the associated
|
||||
// layout might not have been performed yet on the native side.
|
||||
onLayout={() => {
|
||||
// We're assuming the parent `onLayout` already ran (parent -> child ordering).
|
||||
if (pendingHeaderHeight.current !== null) {
|
||||
onHeaderOnlyLayout(pendingHeaderHeight.current)
|
||||
pendingHeaderHeight.current = null
|
||||
}
|
||||
// @ts-ignore
|
||||
headerRef.current?.measure(
|
||||
(_x: number, _y: number, _width: number, height: number) => {
|
||||
onHeaderOnlyLayout(height)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {
|
||||
ThreadNode,
|
||||
ThreadPost,
|
||||
ThreadNotFound,
|
||||
ThreadBlocked,
|
||||
usePostThreadQuery,
|
||||
sortThread,
|
||||
} from '#/state/queries/post-thread'
|
||||
@@ -41,26 +43,40 @@ import {
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
|
||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 1}
|
||||
// FlatList maintainVisibleContentPosition breaks if too many items
|
||||
// are prepended. This seems to be an optimal number based on *shrug*.
|
||||
const PARENTS_CHUNK_SIZE = 15
|
||||
|
||||
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,
|
||||
@@ -153,61 +169,92 @@ 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 [maxParents, setMaxParents] = React.useState(
|
||||
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
|
||||
)
|
||||
const [maxVisible, setMaxVisible] = React.useState(100)
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const [maxReplies, setMaxReplies] = React.useState(100)
|
||||
const treeView = React.useMemo(
|
||||
() => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
|
||||
[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,
|
||||
),
|
||||
)
|
||||
if (arr.length > maxVisible) {
|
||||
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
||||
}
|
||||
if (arr.indexOf(CHILD_SPINNER) === -1) {
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
[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
|
||||
let startIndex = Math.max(0, parents.length - maxParents)
|
||||
if (startIndex === 0) {
|
||||
arr.push(TOP_COMPONENT)
|
||||
} else {
|
||||
// When progressively revealing parents, rendering a placeholder
|
||||
// here will cause scrolling jumps. Don't add it unless you test it.
|
||||
// QT'ing this thread is a great way to test all the scrolling hacks:
|
||||
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
|
||||
}
|
||||
for (let i = startIndex; i < parents.length; i++) {
|
||||
arr.push(parents[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
arr.push(highlightedPost)
|
||||
if (!highlightedPost.post.viewer?.replyDisabled) {
|
||||
arr.push(REPLY_PROMPT)
|
||||
}
|
||||
if (highlightedPost.ctx.isChildLoading) {
|
||||
arr.push(CHILD_SPINNER)
|
||||
} else {
|
||||
for (let i = 0; i < replies.length; i++) {
|
||||
arr.push(replies[i])
|
||||
if (i === maxReplies) {
|
||||
arr.push(LOAD_MORE)
|
||||
break
|
||||
}
|
||||
}
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}, [thread, treeView, maxVisible, threadViewPrefs, hasSession])
|
||||
}, [skeleton, deferParents, maxParents, maxReplies])
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -216,36 +263,41 @@ 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])
|
||||
|
||||
const onPTR = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await onRefresh()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh posts thread', {message: err})
|
||||
// On native, we reveal parents in chunks. Although they're all already
|
||||
// loaded and FlatList already has its own virtualization, unfortunately FlatList
|
||||
// has a bug that causes the content to jump around if too many items are getting
|
||||
// prepended at once. It also jumps around if items get prepended during scroll.
|
||||
// To work around this, we prepend rows after scroll bumps against the top and rests.
|
||||
const needsBumpMaxParents = React.useRef(false)
|
||||
const onStartReached = React.useCallback(() => {
|
||||
if (maxParents < skeleton.parents.length) {
|
||||
needsBumpMaxParents.current = true
|
||||
}
|
||||
setIsPTRing(false)
|
||||
}, [setIsPTRing, onRefresh])
|
||||
}, [maxParents, skeleton.parents.length])
|
||||
const bumpMaxParentsIfNeeded = React.useCallback(() => {
|
||||
if (!isNative) {
|
||||
return
|
||||
}
|
||||
if (needsBumpMaxParents.current) {
|
||||
needsBumpMaxParents.current = false
|
||||
setMaxParents(n => n + PARENTS_CHUNK_SIZE)
|
||||
}
|
||||
}, [])
|
||||
const onMomentumScrollEnd = bumpMaxParentsIfNeeded
|
||||
const onScrollToTop = bumpMaxParentsIfNeeded
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item, index}: {item: YieldedItem; index: number}) => {
|
||||
({item, index}: {item: RowItem; index: number}) => {
|
||||
if (item === TOP_COMPONENT) {
|
||||
return isTabletOrMobile ? (
|
||||
<ViewHeader
|
||||
@@ -258,7 +310,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}>
|
||||
@@ -266,7 +318,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}>
|
||||
@@ -277,7 +329,7 @@ function PostThreadLoaded({
|
||||
} else if (item === LOAD_MORE) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setMaxVisible(n => n + 50)}
|
||||
onPress={() => setMaxReplies(n => n + 50)}
|
||||
style={[pal.border, pal.view, styles.itemContainer]}
|
||||
accessibilityLabel={_(msg`Load more posts`)}
|
||||
accessibilityHint="">
|
||||
@@ -321,9 +373,12 @@ function PostThreadLoaded({
|
||||
const next = isThreadPost(posts[index - 1])
|
||||
? (posts[index - 1] as ThreadPost)
|
||||
: undefined
|
||||
const hasUnrevealedParents =
|
||||
index === 0 && maxParents < skeleton.parents.length
|
||||
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}
|
||||
@@ -335,7 +390,9 @@ function PostThreadLoaded({
|
||||
hasMore={item.ctx.hasMore}
|
||||
showChildReplyLine={item.ctx.showChildReplyLine}
|
||||
showParentReplyLine={item.ctx.showParentReplyLine}
|
||||
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
|
||||
hasPrecedingItem={
|
||||
!!prev?.ctx.showChildReplyLine || hasUnrevealedParents
|
||||
}
|
||||
onPostReply={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
@@ -356,7 +413,10 @@ function PostThreadLoaded({
|
||||
pal.colors.border,
|
||||
posts,
|
||||
onRefresh,
|
||||
deferParents,
|
||||
treeView,
|
||||
skeleton.parents.length,
|
||||
maxParents,
|
||||
_,
|
||||
],
|
||||
)
|
||||
@@ -365,17 +425,15 @@ 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}
|
||||
onStartReached={onStartReached}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
onScrollToTop={onScrollToTop}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
@@ -487,41 +545,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 {RichText} from '#/components/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
@@ -27,7 +27,6 @@ import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../util/moderation/LabelsOnMe'
|
||||
import {PostSandboxWarning} from '../util/PostSandboxWarning'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
@@ -44,6 +43,8 @@ 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'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function PostThreadItem({
|
||||
post,
|
||||
@@ -164,8 +165,6 @@ let PostThreadItemLoaded = ({
|
||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const {currentAccount} = useSession()
|
||||
const hasEngagement = post.likeCount || post.repostCount
|
||||
|
||||
const rootUri = record.reply?.root?.uri || post.uri
|
||||
const postHref = React.useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
@@ -247,7 +246,6 @@ let PostThreadItemLoaded = ({
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
<View style={[styles.layout]}>
|
||||
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
|
||||
<PreviewableUserAvatar
|
||||
@@ -305,10 +303,8 @@ let PostThreadItemLoaded = ({
|
||||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
richText={richText}
|
||||
lineHeight={1.3}
|
||||
style={s.flex1}
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_xl]}
|
||||
selectable
|
||||
/>
|
||||
</View>
|
||||
@@ -322,9 +318,16 @@ let PostThreadItemLoaded = ({
|
||||
translatorUrl={translatorUrl}
|
||||
needsTranslation={needsTranslation}
|
||||
/>
|
||||
{hasEngagement ? (
|
||||
{post.repostCount !== 0 || post.likeCount !== 0 ? (
|
||||
// Show this section unless we're *sure* it has no engagement.
|
||||
<View style={[styles.expandedInfo, pal.border]}>
|
||||
{post.repostCount ? (
|
||||
{post.repostCount == null && post.likeCount == null && (
|
||||
// If we're still loading and not sure, assume this post has engagement.
|
||||
// This lets us avoid a layout shift for the common case (embedded post with likes/reposts).
|
||||
// TODO: embeds should include metrics to avoid us having to guess.
|
||||
<LoadingPlaceholder width={50} height={20} />
|
||||
)}
|
||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={repostsHref}
|
||||
@@ -339,10 +342,8 @@ let PostThreadItemLoaded = ({
|
||||
{pluralize(post.repostCount, 'repost')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{post.likeCount ? (
|
||||
) : null}
|
||||
{post.likeCount != null && post.likeCount !== 0 ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={likesHref}
|
||||
@@ -357,13 +358,9 @@ let PostThreadItemLoaded = ({
|
||||
{pluralize(post.likeCount, 'like')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
) : null}
|
||||
<View style={[s.pl10, s.pr10, s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
@@ -403,8 +400,6 @@ let PostThreadItemLoaded = ({
|
||||
? {marginRight: 4}
|
||||
: {marginLeft: 2, marginRight: 2}
|
||||
}>
|
||||
<PostSandboxWarning />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@@ -419,7 +414,7 @@ let PostThreadItemLoaded = ({
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.border,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
@@ -440,6 +435,7 @@ let PostThreadItemLoaded = ({
|
||||
: 8,
|
||||
},
|
||||
]}>
|
||||
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
|
||||
{!isThreadedChild && (
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
@@ -456,7 +452,7 @@ let PostThreadItemLoaded = ({
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.border,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: 4,
|
||||
},
|
||||
]}
|
||||
@@ -465,7 +461,12 @@ let PostThreadItemLoaded = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.layoutContent}>
|
||||
<View
|
||||
style={
|
||||
isThreadedChild
|
||||
? styles.layoutContentThreaded
|
||||
: styles.layoutContent
|
||||
}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
@@ -486,10 +487,8 @@ let PostThreadItemLoaded = ({
|
||||
{richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
richText={richText}
|
||||
style={[pal.text, s.flex1]}
|
||||
lineHeight={1.3}
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
/>
|
||||
</View>
|
||||
@@ -664,6 +663,10 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginLeft: 10,
|
||||
},
|
||||
layoutContentThreaded: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 2,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../util/moderation/LabelsOnMe'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -30,6 +30,7 @@ import {useComposerControls} from '#/state/shell/composer'
|
||||
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
@@ -189,11 +190,9 @@ function PostInner({
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
testID="postText"
|
||||
type="post-text"
|
||||
richText={richText}
|
||||
lineHeight={1.3}
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={s.flex1}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -21,8 +21,7 @@ import {PostEmbeds} from '../util/post-embeds'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../util/moderation/LabelsOnMe'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {PostSandboxWarning} from '../util/PostSandboxWarning'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -37,6 +36,7 @@ import {FeedNameText} from '../util/FeedInfoText'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function FeedItem({
|
||||
post,
|
||||
@@ -163,8 +163,6 @@ let FeedItemInner = ({
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 52}}>
|
||||
{isThreadChild && (
|
||||
@@ -349,11 +347,9 @@ let PostContent = ({
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
testID="postText"
|
||||
type="post-text"
|
||||
richText={richText}
|
||||
lineHeight={1.3}
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={s.flex1}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -23,7 +23,7 @@ import * as Toast from '../util/Toast'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ThemedText} from '../util/text/ThemedText'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
|
||||
@@ -59,6 +59,7 @@ import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useOpenGlobalDialog} from '#/components/dialogs'
|
||||
import {ReportDialog} from '#/components/dialogs/ReportDialog'
|
||||
import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
@@ -617,12 +618,12 @@ let ProfileHeader = ({
|
||||
</Text>
|
||||
</View>
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<View pointerEvents="auto" style={[styles.description]}>
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[styles.description, pal.text]}
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
richText={descriptionRT}
|
||||
value={descriptionRT}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {View, ViewStyle} from 'react-native'
|
||||
|
||||
/**
|
||||
* This utility function captures events and stops
|
||||
* them from propagating upwards.
|
||||
*/
|
||||
export function EventStopper({children}: React.PropsWithChildren<{}>) {
|
||||
export function EventStopper({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) {
|
||||
const stop = (e: any) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
@@ -15,7 +18,8 @@ export function EventStopper({children}: React.PropsWithChildren<{}>) {
|
||||
onTouchEnd={stop}
|
||||
// @ts-ignore web only -prf
|
||||
onClick={stop}
|
||||
onKeyDown={stop}>
|
||||
onKeyDown={stop}
|
||||
style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function PostSandboxWarning() {
|
||||
const {isSandbox} = useSession()
|
||||
const pal = usePalette('default')
|
||||
if (isSandbox) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text
|
||||
type="title-2xl"
|
||||
style={[pal.text, styles.text]}
|
||||
accessible={false}>
|
||||
SANDBOX
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 10,
|
||||
},
|
||||
text: {
|
||||
fontWeight: 'bold',
|
||||
opacity: 0.07,
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import React, {memo} from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
@@ -19,6 +20,8 @@ import * as Toast from '../Toast'
|
||||
import {EventStopper} from '../EventStopper'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {usePostDeleteMutation} from '#/state/queries/post'
|
||||
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
||||
@@ -65,6 +68,7 @@ let PostDropdownBtn = ({
|
||||
const {hidePost} = useHiddenPostsApi()
|
||||
const openLink = useOpenLink()
|
||||
const openDialog = useOpenGlobalDialog()
|
||||
const navigation = useNavigation()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
@@ -84,13 +88,38 @@ let PostDropdownBtn = ({
|
||||
postDeleteMutation.mutateAsync({uri: postUri}).then(
|
||||
() => {
|
||||
Toast.show(_(msg`Post deleted`))
|
||||
|
||||
const route = getCurrentRoute(navigation.getState())
|
||||
if (route.name === 'PostThread') {
|
||||
const params = route.params as CommonNavigatorParams['PostThread']
|
||||
if (
|
||||
currentAccount &&
|
||||
isAuthor &&
|
||||
(params.name === currentAccount.handle ||
|
||||
params.name === currentAccount.did)
|
||||
) {
|
||||
const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
|
||||
if (currentHref === href && navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
e => {
|
||||
logger.error('Failed to delete post', {message: e})
|
||||
Toast.show(_(msg`Failed to delete post, please try again`))
|
||||
},
|
||||
)
|
||||
}, [postUri, postDeleteMutation, _])
|
||||
}, [
|
||||
navigation,
|
||||
postUri,
|
||||
postDeleteMutation,
|
||||
postAuthor,
|
||||
currentAccount,
|
||||
isAuthor,
|
||||
href,
|
||||
_,
|
||||
])
|
||||
|
||||
const onToggleThreadMute = React.useCallback(() => {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -206,7 +206,7 @@ let PostCtrls = ({
|
||||
{big && (
|
||||
<View style={styles.ctrlBig}>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
testID="shareBtn"
|
||||
style={[styles.btn]}
|
||||
onPress={onShare}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
|
||||
import {EmbedPlayerParams, getPlayerAspect} from 'lib/strings/embed-player'
|
||||
import {EventStopper} from '../EventStopper'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
@@ -67,14 +67,12 @@ function PlaceholderOverlay({
|
||||
|
||||
// This renders the webview/youtube player as a separate layer
|
||||
function Player({
|
||||
height,
|
||||
params,
|
||||
onLoad,
|
||||
isPlayerActive,
|
||||
}: {
|
||||
isPlayerActive: boolean
|
||||
params: EmbedPlayerParams
|
||||
height: number
|
||||
onLoad: () => void
|
||||
}) {
|
||||
// ensures we only load what's requested
|
||||
@@ -91,25 +89,21 @@ function Player({
|
||||
if (!isPlayerActive) return null
|
||||
|
||||
return (
|
||||
<View style={[styles.layer, styles.playerLayer]}>
|
||||
<EventStopper>
|
||||
<View style={{height, width: '100%'}}>
|
||||
<WebView
|
||||
javaScriptEnabled={true}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsInlineMediaPlayback
|
||||
bounces={false}
|
||||
allowsFullscreenVideo
|
||||
nestedScrollEnabled
|
||||
source={{uri: params.playerUri}}
|
||||
onLoad={onLoad}
|
||||
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
|
||||
style={[styles.webview, styles.topRadius]}
|
||||
/>
|
||||
</View>
|
||||
</EventStopper>
|
||||
</View>
|
||||
<EventStopper style={[styles.layer, styles.playerLayer]}>
|
||||
<WebView
|
||||
javaScriptEnabled={true}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsInlineMediaPlayback
|
||||
bounces={false}
|
||||
allowsFullscreenVideo
|
||||
nestedScrollEnabled
|
||||
source={{uri: params.playerUri}}
|
||||
onLoad={onLoad}
|
||||
style={styles.webview}
|
||||
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
|
||||
/>
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,13 +123,16 @@ export function ExternalPlayer({
|
||||
|
||||
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [dim, setDim] = React.useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
const aspect = React.useMemo(() => {
|
||||
return getPlayerAspect({
|
||||
type: params.type,
|
||||
width: windowDims.width,
|
||||
hasThumb: !!link.thumb,
|
||||
})
|
||||
}, [params.type, windowDims.width, link.thumb])
|
||||
|
||||
const viewRef = useAnimatedRef()
|
||||
|
||||
const frameCallback = useFrameCallback(() => {
|
||||
const measurement = measure(viewRef)
|
||||
if (!measurement) return
|
||||
@@ -180,17 +177,6 @@ export function ExternalPlayer({
|
||||
}
|
||||
}, [navigation, isPlayerActive, frameCallback])
|
||||
|
||||
// calculate height for the player and the screen size
|
||||
const height = React.useMemo(
|
||||
() =>
|
||||
getPlayerHeight({
|
||||
type: params.type,
|
||||
width: dim.width,
|
||||
hasThumb: !!link.thumb,
|
||||
}),
|
||||
[params.type, dim.width, link.thumb],
|
||||
)
|
||||
|
||||
const onLoad = React.useCallback(() => {
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
@@ -216,32 +202,11 @@ export function ExternalPlayer({
|
||||
[externalEmbedsPrefs, openModal, params.source],
|
||||
)
|
||||
|
||||
// measure the layout to set sizing
|
||||
const onLayout = React.useCallback(
|
||||
(event: {nativeEvent: {layout: {width: any; height: any}}}) => {
|
||||
setDim({
|
||||
width: event.nativeEvent.layout.width,
|
||||
height: event.nativeEvent.layout.height,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
ref={viewRef}
|
||||
style={{height}}
|
||||
collapsable={false}
|
||||
onLayout={onLayout}>
|
||||
<Animated.View ref={viewRef} collapsable={false} style={[aspect]}>
|
||||
{link.thumb && (!isPlayerActive || isLoading) && (
|
||||
<Image
|
||||
style={[
|
||||
{
|
||||
width: dim.width,
|
||||
height,
|
||||
},
|
||||
styles.topRadius,
|
||||
]}
|
||||
style={[{flex: 1}, styles.topRadius]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
@@ -251,12 +216,7 @@ export function ExternalPlayer({
|
||||
isPlayerActive={isPlayerActive}
|
||||
onPress={onPlayPress}
|
||||
/>
|
||||
<Player
|
||||
isPlayerActive={isPlayerActive}
|
||||
params={params}
|
||||
height={height}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
<Player isPlayerActive={isPlayerActive} params={params} onLoad={onLoad} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ import {PostAlerts} from '../moderation/PostAlerts'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {InfoCircleIcon} from 'lib/icons'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {RichText} from 'view/com/util/text/RichText'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {ContentHider} from '../moderation/ContentHider'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function MaybeQuoteEmbed({
|
||||
embed,
|
||||
@@ -157,11 +158,10 @@ export function QuoteEmbed({
|
||||
) : null}
|
||||
{richText ? (
|
||||
<RichText
|
||||
richText={richText}
|
||||
type="post-text"
|
||||
style={pal.text}
|
||||
value={richText}
|
||||
style={[a.text_md]}
|
||||
numberOfLines={20}
|
||||
noLinks
|
||||
disableLinks
|
||||
/>
|
||||
) : null}
|
||||
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
|
||||
|
||||
@@ -10,6 +10,9 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
/**
|
||||
* @deprecated use `#/components/RichText`
|
||||
*/
|
||||
export function RichText({
|
||||
testID,
|
||||
type = 'md',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+164
-86
@@ -1,5 +1,11 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View, type FlatList} from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
View,
|
||||
type FlatList,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
@@ -8,9 +14,10 @@ import {Link} from 'view/com/util/Link'
|
||||
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {ComposeIcon2, CogIcon} from 'lib/icons'
|
||||
import {ComposeIcon2, CogIcon, MagnifyingGlassIcon2} from 'lib/icons'
|
||||
import {s} from 'lib/styles'
|
||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {SearchInput, SearchInputRef} from 'view/com/util/forms/SearchInput'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {
|
||||
LoadingPlaceholder,
|
||||
@@ -35,7 +42,11 @@ import {
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
||||
|
||||
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
|
||||
|
||||
@@ -121,6 +132,7 @@ export function FeedsScreen(_props: Props) {
|
||||
} = useSearchPopularFeedsMutation()
|
||||
const {hasSession} = useSession()
|
||||
const listRef = React.useRef<FlatList>(null)
|
||||
const searchInputRef = React.useRef<SearchInputRef>(null)
|
||||
|
||||
/**
|
||||
* A search query is present. We may not have search results yet.
|
||||
@@ -207,12 +219,7 @@ export function FeedsScreen(_props: Props) {
|
||||
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
||||
})
|
||||
} else {
|
||||
if (preferences?.feeds?.saved.length === 0) {
|
||||
slices.push({
|
||||
key: 'savedFeedNoResults',
|
||||
type: 'savedFeedNoResults',
|
||||
})
|
||||
} else {
|
||||
if (preferences?.feeds?.saved.length !== 0) {
|
||||
const {saved, pinned} = preferences.feeds
|
||||
|
||||
slices = slices.concat(
|
||||
@@ -330,14 +337,26 @@ export function FeedsScreen(_props: Props) {
|
||||
|
||||
const renderHeaderBtn = React.useCallback(() => {
|
||||
return (
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
<View style={styles.headerBtnGroup}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={searchInputRef.current?.focus}>
|
||||
<MagnifyingGlassIcon2
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
style={pal.textLight}
|
||||
/>
|
||||
</Pressable>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}, [pal, _])
|
||||
|
||||
@@ -380,34 +399,48 @@ export function FeedsScreen(_props: Props) {
|
||||
) {
|
||||
return (
|
||||
<View style={s.p10}>
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'savedFeedsHeader') {
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>My Feeds</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
accessibilityLabel={_(msg`Edit My Feeds`)}
|
||||
accessibilityHint="">
|
||||
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <View />
|
||||
return (
|
||||
<>
|
||||
{!isMobile && (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>Feeds</Trans>
|
||||
</Text>
|
||||
<View style={styles.headerBtnGroup}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={searchInputRef.current?.focus}>
|
||||
<MagnifyingGlassIcon2
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
style={pal.icon}
|
||||
/>
|
||||
</Pressable>
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
accessibilityLabel={_(msg`Edit My Feeds`)}
|
||||
accessibilityHint="">
|
||||
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />}
|
||||
</>
|
||||
)
|
||||
} else if (item.type === 'savedFeedNoResults') {
|
||||
return (
|
||||
<View
|
||||
@@ -425,45 +458,17 @@ export function FeedsScreen(_props: Props) {
|
||||
} else if (item.type === 'popularFeedsHeader') {
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.header,
|
||||
{
|
||||
// This is first in the flatlist without a session -esb
|
||||
marginTop: hasSession ? 16 : 0,
|
||||
paddingLeft: isMobile ? 12 : undefined,
|
||||
paddingRight: 10,
|
||||
paddingBottom: isMobile ? 6 : undefined,
|
||||
},
|
||||
]}>
|
||||
<Text type="title-lg" style={[pal.text, s.bold]}>
|
||||
<Trans>Discover new feeds</Trans>
|
||||
</Text>
|
||||
|
||||
{!isMobile && (
|
||||
<SearchInput
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
style={{flex: 1, maxWidth: 250}}
|
||||
/>
|
||||
)}
|
||||
<FeedsAboutHeader />
|
||||
<View style={{paddingHorizontal: 12, paddingBottom: 12}}>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isMobile && (
|
||||
<View style={{paddingHorizontal: 8, paddingBottom: 10}}>
|
||||
<SearchInput
|
||||
query={query}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onPressCancelSearch={onPressCancelSearch}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
setIsInputFocused={onChangeSearchFocus}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (item.type === 'popularFeedsLoading') {
|
||||
@@ -495,15 +500,20 @@ export function FeedsScreen(_props: Props) {
|
||||
return null
|
||||
},
|
||||
[
|
||||
_,
|
||||
hasSession,
|
||||
isMobile,
|
||||
pal,
|
||||
pal.view,
|
||||
pal.border,
|
||||
pal.text,
|
||||
pal.icon,
|
||||
pal.textLight,
|
||||
_,
|
||||
preferences?.feeds?.saved?.length,
|
||||
query,
|
||||
onChangeQuery,
|
||||
onPressCancelSearch,
|
||||
onSubmitQuery,
|
||||
onChangeSearchFocus,
|
||||
hasSession,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -518,8 +528,6 @@ export function FeedsScreen(_props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{preferences ? <View /> : <ActivityIndicator />}
|
||||
|
||||
<List
|
||||
ref={listRef}
|
||||
style={[!isTabletOrDesktop && s.flex1, styles.list]}
|
||||
@@ -626,6 +634,71 @@ function SavedFeedLoadingPlaceholder() {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsSavedHeader() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
isWeb
|
||||
? [
|
||||
a.flex_row,
|
||||
a.px_md,
|
||||
a.py_lg,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]
|
||||
: [
|
||||
{flexDirection: 'row-reverse'},
|
||||
a.p_lg,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]
|
||||
}>
|
||||
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>My Feeds</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>All the feeds you've saved, right in one place.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsAboutHeader() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
isWeb
|
||||
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
|
||||
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
|
||||
}>
|
||||
<IconCircle
|
||||
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
|
||||
size="lg"
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>Discover New Feeds</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
Custom feeds built by the community bring you new experiences and
|
||||
help you find the content you love.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -663,4 +736,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
headerBtnGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 15,
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
|
||||
import {HomeHeader} from '../com/home/HomeHeader'
|
||||
import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager'
|
||||
import {FeedPage} from 'view/com/feeds/FeedPage'
|
||||
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
||||
@@ -118,7 +118,7 @@ function HomeScreenReady({
|
||||
const renderTabBar = React.useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<FeedsTabBar
|
||||
<HomeHeader
|
||||
key="FEEDS_TAB_BAR"
|
||||
selectedPage={props.selectedPage}
|
||||
onSelect={props.onSelect}
|
||||
|
||||
+5
-5
@@ -78,9 +78,9 @@ function RepliesThresholdInput({
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'PreferencesHomeFeed'
|
||||
'PreferencesFollowingFeed'
|
||||
>
|
||||
export function PreferencesHomeFeed({navigation}: Props) {
|
||||
export function PreferencesFollowingFeed({navigation}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
@@ -101,14 +101,14 @@ export function PreferencesHomeFeed({navigation}: Props) {
|
||||
styles.container,
|
||||
isTabletOrDesktop && styles.desktopContainer,
|
||||
]}>
|
||||
<ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop />
|
||||
<ViewHeader title={_(msg`Following Feed Preferences`)} showOnDesktop />
|
||||
<View
|
||||
style={[
|
||||
styles.titleSection,
|
||||
isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
|
||||
]}>
|
||||
<Text type="xl" style={[pal.textLight, styles.description]}>
|
||||
<Trans>Fine-tune the content you see on your home screen.</Trans>
|
||||
<Trans>Fine-tune the content you see on your Following feed.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -260,7 +260,7 @@ export function PreferencesHomeFeed({navigation}: Props) {
|
||||
<Text style={[pal.text, s.pb10]}>
|
||||
<Trans>
|
||||
Set this setting to "Yes" to show samples of your saved feeds in
|
||||
your following feed. This is an experimental feature.
|
||||
your Following feed. This is an experimental feature.
|
||||
</Trans>
|
||||
</Text>
|
||||
<ToggleButton
|
||||
@@ -17,7 +17,7 @@ import {TextLink} from 'view/com/util/Link'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {RichText} from 'view/com/util/text/RichText'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {FAB} from 'view/com/util/fab/FAB'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
@@ -59,6 +59,7 @@ import {useComposerControls} from '#/state/shell/composer'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
const SECTION_TITLES = ['Posts', 'About']
|
||||
|
||||
@@ -575,9 +576,8 @@ function AboutSection({
|
||||
{feedInfo.description ? (
|
||||
<RichText
|
||||
testID="listDescription"
|
||||
type="lg"
|
||||
style={pal.text}
|
||||
richText={feedInfo.description}
|
||||
style={[a.text_md]}
|
||||
value={feedInfo.description}
|
||||
/>
|
||||
) : (
|
||||
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {LoadingScreen} from 'view/com/util/LoadingScreen'
|
||||
import {RichText} from 'view/com/util/text/RichText'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import {logger} from '#/logger'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
const SECTION_TITLES_CURATE = ['Posts', 'About']
|
||||
const SECTION_TITLES_MOD = ['About']
|
||||
@@ -742,9 +743,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
|
||||
{descriptionRT ? (
|
||||
<RichText
|
||||
testID="listDescription"
|
||||
type="lg"
|
||||
style={pal.text}
|
||||
richText={descriptionRT}
|
||||
style={[a.text_md]}
|
||||
value={descriptionRT}
|
||||
/>
|
||||
) : (
|
||||
<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://docs.bsky.app/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')
|
||||
@@ -243,8 +241,8 @@ export function SettingsScreen({}: Props) {
|
||||
Toast.show(_(msg`Copied build version to clipboard`))
|
||||
}, [_])
|
||||
|
||||
const openHomeFeedPreferences = React.useCallback(() => {
|
||||
navigation.navigate('PreferencesHomeFeed')
|
||||
const openFollowingFeedPreferences = React.useCallback(() => {
|
||||
navigation.navigate('PreferencesFollowingFeed')
|
||||
}, [navigation])
|
||||
|
||||
const openThreadsPreferences = React.useCallback(() => {
|
||||
@@ -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>
|
||||
@@ -523,7 +529,7 @@ export function SettingsScreen({}: Props) {
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={openHomeFeedPreferences}
|
||||
onPress={openFollowingFeedPreferences}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Opens the home feed preferences`)}>
|
||||
@@ -534,7 +540,7 @@ export function SettingsScreen({}: Props) {
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Home Feed Preferences</Trans>
|
||||
<Trans>Following Feed Preferences</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -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
|
||||
@@ -7,14 +7,12 @@ import {H3, P} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {useOpenGlobalDialog} from '#/components/dialogs'
|
||||
import {ReportDialog} from '#/components/dialogs/ReportDialog'
|
||||
|
||||
export function Dialogs() {
|
||||
const control = Dialog.useDialogControl()
|
||||
const scrollable = Dialog.useDialogControl()
|
||||
const basic = Dialog.useDialogControl()
|
||||
const prompt = Prompt.usePromptControl()
|
||||
const {closeAllDialogs} = useDialogStateControlContext()
|
||||
const openDialog = useOpenGlobalDialog()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
@@ -23,9 +21,9 @@ export function Dialogs() {
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
control.open()
|
||||
scrollable.open()
|
||||
prompt.open()
|
||||
openDialog(ReportDialog, {type: 'user', did: ''})
|
||||
basic.open()
|
||||
}}
|
||||
label="Open basic dialog">
|
||||
Open all dialogs
|
||||
@@ -36,7 +34,18 @@ export function Dialogs() {
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
control.open()
|
||||
scrollable.open()
|
||||
}}
|
||||
label="Open basic dialog">
|
||||
Open scrollable dialog
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
basic.open()
|
||||
}}
|
||||
label="Open basic dialog">
|
||||
Open basic dialog
|
||||
@@ -51,17 +60,6 @@ export function Dialogs() {
|
||||
Open prompt
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
openDialog(ReportDialog, {type: 'user', did: ''})
|
||||
}}
|
||||
label="Open prompt">
|
||||
Open report dialog
|
||||
</Button>
|
||||
|
||||
<Prompt.Outer control={prompt}>
|
||||
<Prompt.Title>This is a prompt</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
@@ -74,9 +72,18 @@ export function Dialogs() {
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
|
||||
<Dialog.Outer control={basic}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.Inner label="test">
|
||||
<H3 nativeID="dialog-title">Dialog</H3>
|
||||
<P nativeID="dialog-description">A basic dialog</P>
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{sheet: {snapPoints: ['90%']}}}>
|
||||
control={scrollable}
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
@@ -103,9 +110,13 @@ export function Dialogs() {
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="small"
|
||||
onPress={() => control.close()}
|
||||
onPress={() =>
|
||||
scrollable.close(() => {
|
||||
console.log('CLOSED')
|
||||
})
|
||||
}
|
||||
label="Open basic dialog">
|
||||
Close basic dialog
|
||||
Close dialog
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {H1} from '#/components/Typography'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
||||
export function Icons() {
|
||||
const t = useTheme()
|
||||
@@ -36,6 +37,14 @@ export function Icons() {
|
||||
<CalendarDays size="lg" fill={t.atoms.text.color} />
|
||||
<CalendarDays size="xl" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.gap_xl]}>
|
||||
<Loader size="xs" fill={t.atoms.text.color} />
|
||||
<Loader size="sm" fill={t.atoms.text.color} />
|
||||
<Loader size="md" fill={t.atoms.text.color} />
|
||||
<Loader size="lg" fill={t.atoms.text.color} />
|
||||
<Loader size="xl" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ export function Links() {
|
||||
style={[a.text_md]}>
|
||||
External
|
||||
</InlineLink>
|
||||
<InlineLink to="https://bsky.social" style={[a.text_md]}>
|
||||
<InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text]}>
|
||||
<H3>External with custom children</H3>
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://bsky.social"
|
||||
style={[a.text_md, t.atoms.text_contrast_low]}>
|
||||
External with custom children
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://bsky.social"
|
||||
warnOnMismatchingTextChild
|
||||
|
||||
@@ -2,99 +2,26 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
export function Palette() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<View style={[a.flex_row, a.gap_md]}>
|
||||
<View
|
||||
style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_25},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_50},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_100},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_200},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_300},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_400},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_500},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_600},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_700},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_800},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_900},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_950},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_975},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
{height: 60, backgroundColor: tokens.color.gray_1000},
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_25, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_50, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_100, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_200, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_300, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_400, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_500, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_600, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_700, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_800, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_900, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_950, {height: 60}]} />
|
||||
<View style={[a.flex_1, t.atoms.bg_contrast_975, {height: 60}]} />
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.gap_md]}>
|
||||
|
||||
@@ -8,7 +8,9 @@ import {RichText} from '#/components/RichText'
|
||||
export function Typography() {
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[a.text_5xl]}>atoms.text_5xl</Text>
|
||||
<Text selectable style={[a.text_5xl]}>
|
||||
atoms.text_5xl
|
||||
</Text>
|
||||
<Text style={[a.text_4xl]}>atoms.text_4xl</Text>
|
||||
<Text style={[a.text_3xl]}>atoms.text_3xl</Text>
|
||||
<Text style={[a.text_2xl]}>atoms.text_2xl</Text>
|
||||
@@ -24,6 +26,7 @@ export function Typography() {
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
/>
|
||||
<RichText
|
||||
selectable
|
||||
resolveFacets
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
style={[a.text_xl]}
|
||||
|
||||
@@ -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
|
||||
@@ -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,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,18 +8,14 @@ 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 {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')
|
||||
const palError = usePalette('error')
|
||||
const {_} = useLingui()
|
||||
const {isSandbox, hasSession, currentAccount} = useSession()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
|
||||
const {isTablet} = useWebMediaQueries()
|
||||
if (isTablet) {
|
||||
@@ -53,13 +48,6 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
paddingTop: hasSession ? 0 : 18,
|
||||
},
|
||||
]}>
|
||||
{isSandbox ? (
|
||||
<View style={[palError.view, styles.messageLine, s.p10]}>
|
||||
<Text type="md" style={[palError.text, s.bold]}>
|
||||
<Trans>SANDBOX. Posts and accounts are not permanent.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[{flexWrap: 'wrap'}, s.flexRow]}>
|
||||
{hasSession && (
|
||||
<>
|
||||
@@ -103,78 +91,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 +114,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,
|
||||
|
||||
Reference in New Issue
Block a user