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:
Eric Bailey
2024-02-26 14:50:43 -06:00
143 changed files with 16079 additions and 9867 deletions
+78 -1
View File
@@ -1,5 +1,6 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles'
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({
onPressSignin,
@@ -20,6 +29,22 @@ export const SplashScreen = ({
const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const insets = useSafeAreaInsets()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return (
<CenteredView style={[styles.container, pal.view]}>
<ErrorBoundary>
@@ -58,6 +83,51 @@ export const SplashScreen = ({
</Text>
</TouchableOpacity>
</View>
<View style={styles.footer}>
<View style={{position: 'relative'}}>
<RNPickerSelect
placeholder={{}}
value={sanitizedLang}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
useNativeAndroidPickerStyle={false}
style={{
inputAndroid: {
color: pal.textLight.color,
fontSize: 16,
paddingRight: 10 + 4,
},
inputIOS: {
color: pal.text.color,
fontSize: 16,
paddingRight: 10 + 4,
},
}}
/>
<View
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
size={10}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View style={{height: insets.bottom}} />
</ErrorBoundary>
</CenteredView>
)
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
btns: {
paddingBottom: 40,
paddingBottom: 0,
},
title: {
textAlign: 'center',
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 21,
},
footer: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 24,
justifyContent: 'center',
alignItems: 'center',
},
})
+72 -7
View File
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {useLingui} from '@lingui/react'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({
onDismiss,
@@ -98,24 +102,84 @@ export const SplashScreen = ({
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(ev: React.ChangeEvent<HTMLSelectElement>) => {
const value = ev.target.value
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return (
<View style={[styles.footer, pal.view, pal.border]}>
<TextLink
href="https://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',
},
})
+43 -32
View File
@@ -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
+6 -5
View File
@@ -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
View File
@@ -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}} />
}
+86 -34
View File
@@ -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',
},
})
+1 -1
View File
@@ -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
View File
@@ -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)
}
+3 -3
View File
@@ -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
+4 -13
View File
@@ -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}>
+5 -3
View File
@@ -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}
+71
View File
@@ -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>
)
}
+1
View File
@@ -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,
},
})
@@ -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>
))}
+4 -3
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from './FeedsTabBarMobile'
-155
View File
@@ -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,
},
})
+14 -21
View File
@@ -233,36 +233,29 @@ let PagerTabBar = ({
},
],
}))
const pendingHeaderHeight = React.useRef<null | number>(null)
const headerRef = React.useRef(null)
return (
<Animated.View
pointerEvents="box-none"
style={[styles.tabBarMobile, headerTransform]}>
<View
pointerEvents="box-none"
collapsable={false}
onLayout={e => {
if (isHeaderReady) {
onHeaderOnlyLayout(e.nativeEvent.layout.height)
pendingHeaderHeight.current = null
} else {
// Stash it away for when `isHeaderReady` turns `true` later.
pendingHeaderHeight.current = e.nativeEvent.layout.height
}
}}>
<View ref={headerRef} pointerEvents="box-none" collapsable={false}>
{renderHeader?.()}
{
// When `isHeaderReady` turns `true`, we want to send the parent layout.
// However, if that didn't lead to a layout change, parent `onLayout` wouldn't get called again.
// We're conditionally rendering an empty view so that we can send the last measurement.
// It wouldn't be enough to place `onLayout` on the parent node because
// this would risk measuring before `isHeaderReady` has turned `true`.
// Instead, we'll render a brand node conditionally and get fresh layout.
isHeaderReady && (
<View
// It wouldn't be enough to do this in a `ref` of an effect because,
// even if `isHeaderReady` might have turned `true`, the associated
// layout might not have been performed yet on the native side.
onLayout={() => {
// We're assuming the parent `onLayout` already ran (parent -> child ordering).
if (pendingHeaderHeight.current !== null) {
onHeaderOnlyLayout(pendingHeaderHeight.current)
pendingHeaderHeight.current = null
}
// @ts-ignore
headerRef.current?.measure(
(_x: number, _y: number, _width: number, height: number) => {
onHeaderOnlyLayout(height)
},
)
}}
/>
)
+187 -102
View File
@@ -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
}
}
+33 -30
View File
@@ -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,
+4 -5
View File
@@ -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}
+4 -8
View File
@@ -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}
+5 -4
View File
@@ -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}
+7 -3
View File
@@ -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>
)
-35
View File
@@ -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,
},
})
+30 -1
View File
@@ -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 {
+71 -56
View File
@@ -26,65 +26,80 @@ interface Props {
onSubmitQuery: () => void
style?: StyleProp<ViewStyle>
}
export function SearchInput({
query,
setIsInputFocused,
onChangeQuery,
onPressCancelSearch,
onSubmitQuery,
style,
}: Props) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const textInput = React.useRef<TextInput>(null)
const onPressCancelSearchInner = React.useCallback(() => {
onPressCancelSearch()
textInput.current?.blur()
}, [onPressCancelSearch, textInput])
return (
<View style={[pal.viewLight, styles.container, style]}>
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder={_(msg`Search`)}
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.input]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setIsInputFocused?.(true)}
onBlur={() => setIsInputFocused?.(false)}
onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoCapitalize="none"
/>
{query ? (
<TouchableOpacity
onPress={onPressCancelSearchInner}
accessibilityRole="button"
accessibilityLabel={_(msg`Clear search query`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>
)
export interface SearchInputRef {
focus?: () => void
}
export const SearchInput = React.forwardRef<SearchInputRef, Props>(
function SearchInput(
{
query,
setIsInputFocused,
onChangeQuery,
onPressCancelSearch,
onSubmitQuery,
style,
},
ref,
) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const textInput = React.useRef<TextInput>(null)
const onPressCancelSearchInner = React.useCallback(() => {
onPressCancelSearch()
textInput.current?.blur()
}, [onPressCancelSearch, textInput])
React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(),
blur: () => textInput.current?.blur(),
}))
return (
<View style={[pal.viewLight, styles.container, style]}>
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder={_(msg`Search`)}
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.input]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setIsInputFocused?.(true)}
onBlur={() => setIsInputFocused?.(false)}
onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoCapitalize="none"
/>
{query ? (
<TouchableOpacity
onPress={onPressCancelSearchInner}
accessibilityRole="button"
accessibilityLabel={_(msg`Clear search query`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>
)
},
)
const styles = StyleSheet.create({
container: {
flex: 1,
+1 -1
View File
@@ -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>
)
}
+5 -5
View File
@@ -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} />}
+3
View File
@@ -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',