Merge remote-tracking branch 'origin/main' into 3p-moderators
* origin/main: (33 commits) Improved server selector during account creation and signin (#2840) remove line height from text input for composer on ios (#2844) Add follow button to highlighted post (#2828) Cleaning up old codes pt_BR (#2809) Fix wrong translations in zh-CN localization (#2812) Adjust Japanese translation (#2814) Update ja messages.po (#2821) change follow to follow all when !== 20 (#2831) ios adult content link fix 🤦 (#2845) improves build.md completeness (#2835) increase stroke width for active hashtag icon (#2829) Update `blueskyweb.xyz` links to `bsky.social` (#2830) eas nightlies (#2826) Always show post dropdown button at the bottom of the post, add share button to highlighted post (#2646) Measure header layout reliably (#2817) Round line height (#2824) Design system tweaks (#2822) Fix layout calculations (#2816) Fix flashes and jumps when opening profile (#2815) Show text cursor on web bio (#2813) ...
This commit is contained in:
@@ -83,19 +83,19 @@ export function HomeLoggedOutCTA() {
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz"
|
||||
href="https://bsky.social"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz/blog"
|
||||
href="https://bsky.social/about/blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href="https://blueskyweb.xyz/join"
|
||||
href="https://bsky.social/about/join"
|
||||
text={_(msg`Jobs`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
|
||||
@@ -102,17 +102,17 @@ function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
return (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz"
|
||||
href="https://bsky.social"
|
||||
text="Business"
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz/blog"
|
||||
href="https://bsky.social/about/blog"
|
||||
text="Blog"
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://blueskyweb.xyz/join"
|
||||
href="https://bsky.social/about/join"
|
||||
text="Jobs"
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
@@ -13,7 +14,6 @@ import {StepHeader} from './StepHeader'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TextInput} from '../util/TextInput'
|
||||
import {Button} from '../../util/forms/Button'
|
||||
import {Policies} from './Policies'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {isWeb} from 'platform/detection'
|
||||
@@ -21,7 +21,14 @@ import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {logger} from '#/logger'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
|
||||
function sanitizeDate(date: Date): Date {
|
||||
if (!date || date.toString() === 'Invalid Date') {
|
||||
@@ -43,16 +50,12 @@ export function Step1({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: uiState.serviceUrl,
|
||||
onSelect: (url: string) =>
|
||||
uiDispatch({type: 'set-service-url', value: url}),
|
||||
})
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
}, [uiDispatch, uiState.serviceUrl, openModal])
|
||||
}, [serverInputControl])
|
||||
|
||||
const onPressWaitlist = React.useCallback(() => {
|
||||
openModal({name: 'waitlist'})
|
||||
@@ -64,23 +67,72 @@ export function Step1({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)}>
|
||||
<View>
|
||||
<Button
|
||||
testID="selectServiceButton"
|
||||
type="default"
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}
|
||||
onPress={onPressSelectService}>
|
||||
<FontAwesomeIcon icon="server" size={21} color={pal.colors.text} />
|
||||
</Button>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
|
||||
/>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</Text>
|
||||
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
|
||||
<View
|
||||
style={[
|
||||
pal.borderDark,
|
||||
{flexDirection: 'row', alignItems: 'center'},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, {marginLeft: 14}]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="loginSelectServiceButton"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={onPressSelectService}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.text,
|
||||
{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingRight: 12,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
]}>
|
||||
{toNiceDomain(uiState.serviceUrl)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
pal.btn,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 8,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</StepHeader>
|
||||
</View>
|
||||
|
||||
{!uiState.serviceDescription ? (
|
||||
<ActivityIndicator />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
@@ -24,7 +25,9 @@ import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {styles} from './styles'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
|
||||
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -51,19 +54,16 @@ export const ForgotPasswordForm = ({
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const {screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
useEffect(() => {
|
||||
screen('Signin:ForgotPassword')
|
||||
}, [screen])
|
||||
|
||||
const onPressSelectService = () => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
}
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
}, [serverInputControl])
|
||||
|
||||
const onPressNext = async () => {
|
||||
if (!EmailValidator.validate(email)) {
|
||||
@@ -96,6 +96,10 @@ export const ForgotPasswordForm = ({
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
<Trans>Reset password</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -25,7 +25,9 @@ import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {styles} from './styles'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {ServerInputDialog} from '../server-input'
|
||||
|
||||
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -58,15 +60,11 @@ export const LoginForm = ({
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const passwordInputRef = useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const {login} = useSessionApi()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
const onPressSelectService = () => {
|
||||
openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
serverInputControl.open()
|
||||
Keyboard.dismiss()
|
||||
track('Signin:PressedSelectService')
|
||||
}
|
||||
@@ -130,6 +128,11 @@ export const LoginForm = ({
|
||||
const isReady = !!serviceDescription && !!identifier && !!password
|
||||
return (
|
||||
<View testID="loginForm">
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
|
||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||
<Trans>Sign into</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {PROD_SERVICE} from 'lib/constants'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text, P} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
|
||||
export function ServerInputDialog({
|
||||
control,
|
||||
onSelect,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onSelect: (url: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
|
||||
persisted.get('pdsAddressHistory') || [],
|
||||
)
|
||||
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
|
||||
const [customAddress, setCustomAddress] = React.useState('')
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
let url
|
||||
if (fixedOption[0] === 'custom') {
|
||||
url = customAddress.trim().toLowerCase()
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
url = fixedOption[0]
|
||||
}
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
if (url === 'localhost' || url.startsWith('localhost:')) {
|
||||
url = `http://${url}`
|
||||
} else {
|
||||
url = `https://${url}`
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedOption[0] === 'custom') {
|
||||
if (!pdsAddressHistory.includes(url)) {
|
||||
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
|
||||
setPdsAddressHistory(newHistory)
|
||||
persisted.write('pdsAddressHistory', newHistory)
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(url)
|
||||
}, [
|
||||
fixedOption,
|
||||
customAddress,
|
||||
onSelect,
|
||||
pdsAddressHistory,
|
||||
setPdsAddressHistory,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
accessibilityLabelledBy="dialog-title">
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose Service</Trans>
|
||||
</Text>
|
||||
<P nativeID="dialog-description" style={[a.text_sm]}>
|
||||
<Trans>Select the service that hosts your data.</Trans>
|
||||
</P>
|
||||
|
||||
<ToggleButton.Group
|
||||
label="Preferences"
|
||||
values={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
|
||||
{_(msg`Bluesky`)}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
testID="customSelectBtn"
|
||||
name="custom"
|
||||
label={_(msg`Custom`)}>
|
||||
{_(msg`Custom`)}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
|
||||
{fixedOption[0] === 'custom' && (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
]}>
|
||||
<TextField.Label nativeID="address-input-label">
|
||||
<Trans>Server address</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Globe} />
|
||||
<Dialog.Input
|
||||
testID="customServerTextInput"
|
||||
value={customAddress}
|
||||
onChangeText={setCustomAddress}
|
||||
label={_(msg`my-server.com`)}
|
||||
accessibilityLabelledBy="address-input-label"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</TextField.Root>
|
||||
{pdsAddressHistory.length > 0 && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
|
||||
{pdsAddressHistory.map(uri => (
|
||||
<Button
|
||||
key={uri}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
label={uri}
|
||||
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
|
||||
onPress={() => setCustomAddress(uri)}>
|
||||
<ButtonText>{uri}</ButtonText>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<P
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your hosting
|
||||
provider. Custom hosting is now available in beta for
|
||||
developers.
|
||||
</Trans>
|
||||
</P>
|
||||
</View>
|
||||
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
testID="doneBtn"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="small"
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Done`)}>
|
||||
{_(msg`Done`)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {useTheme} from 'lib/ThemeContext'
|
||||
import {isUriImage} from 'lib/media/util'
|
||||
import {downloadAndResize} from 'lib/media/manip'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -252,6 +253,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
lineHeight: 23.4, // 1.3*16
|
||||
// This is broken on ios right now, so don't set it there.
|
||||
lineHeight: isIOS ? undefined : 23.4, // 1.3*16
|
||||
},
|
||||
})
|
||||
|
||||
@@ -228,7 +228,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<EditorContent editor={editor} />
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
style={{color: pal.text.color as string}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isDropping && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
import {StyleSheet, Pressable, View} from 'react-native'
|
||||
import {StyleSheet, Pressable, View, Linking} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {ScrollView} from './util'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
@@ -129,6 +129,10 @@ function AdultContentEnabledPref() {
|
||||
}
|
||||
}, [variables, preferences, mutate, _])
|
||||
|
||||
const onAdultContentLinkPress = React.useCallback(() => {
|
||||
Linking.openURL('https://bsky.app/')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={s.mb10}>
|
||||
{isIOS ? (
|
||||
@@ -138,8 +142,9 @@ function AdultContentEnabledPref() {
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://bsky.app"
|
||||
href=""
|
||||
text="bsky.app"
|
||||
onPress={onAdultContentLinkPress}
|
||||
/>
|
||||
.
|
||||
</Trans>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import React, {useRef, useEffect} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {SafeAreaView} from 'react-native-safe-area-context'
|
||||
import BottomSheet from '@gorhom/bottom-sheet'
|
||||
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {navigate} from '../../../Navigation'
|
||||
import once from 'lodash.once'
|
||||
|
||||
import {useModals, useModalControls} from '#/state/modals'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ProfilePreviewModal from './ProfilePreview'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
import * as ThreadgateModal from './Threadgate'
|
||||
@@ -50,34 +44,14 @@ export function ModalsContainer() {
|
||||
const {closeModal} = useModalControls()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const pal = usePalette('default')
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
|
||||
const activeModal = activeModals[activeModals.length - 1]
|
||||
|
||||
const navigateOnce = once(navigate)
|
||||
|
||||
// It seems like the bottom sheet bugs out when this callback changes.
|
||||
const onBottomSheetAnimate = useNonReactiveCallback(
|
||||
(_fromIndex: number, toIndex: number) => {
|
||||
if (activeModal?.name === 'profile-preview' && toIndex === 1) {
|
||||
// begin loading the profile screen behind the scenes
|
||||
navigateOnce('Profile', {name: activeModal.did})
|
||||
}
|
||||
},
|
||||
)
|
||||
const onBottomSheetChange = async (snapPoint: number) => {
|
||||
if (snapPoint === -1) {
|
||||
closeModal()
|
||||
} else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
|
||||
await navigateOnce('Profile', {name: activeModal.did})
|
||||
// There is no particular callback for when the view has actually been presented.
|
||||
// This delay gives us a decent chance the navigation has flushed *and* images have loaded.
|
||||
// It's acceptable because the data is already being fetched + it usually takes longer anyway.
|
||||
// TODO: Figure out why avatar/cover don't always show instantly from cache.
|
||||
await timeout(200)
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
bottomSheetRef.current?.close()
|
||||
closeModal()
|
||||
@@ -91,7 +65,6 @@ export function ModalsContainer() {
|
||||
}
|
||||
}, [isModalActive, bottomSheetRef, activeModal?.name])
|
||||
|
||||
let needsSafeTopInset = false
|
||||
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
||||
let element
|
||||
if (activeModal?.name === 'confirm') {
|
||||
@@ -100,13 +73,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = <EditProfileModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'profile-preview') {
|
||||
snapPoints = ProfilePreviewModal.snapPoints
|
||||
element = <ProfilePreviewModal.Component {...activeModal} />
|
||||
needsSafeTopInset = true // Need to align with the target profile screen.
|
||||
} else if (activeModal?.name === 'server-input') {
|
||||
snapPoints = ServerInputModal.snapPoints
|
||||
element = <ServerInputModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'report') {
|
||||
snapPoints = ReportModal.snapPoints
|
||||
element = <ReportModal.Component {...activeModal} />
|
||||
@@ -200,12 +166,10 @@ export function ModalsContainer() {
|
||||
)
|
||||
}
|
||||
|
||||
const topInset = needsSafeTopInset ? safeAreaInsets.top - HANDLE_HEIGHT : 0
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
topInset={topInset}
|
||||
handleHeight={HANDLE_HEIGHT}
|
||||
index={isModalActive ? 0 : -1}
|
||||
enablePanDownToClose
|
||||
@@ -216,7 +180,6 @@ export function ModalsContainer() {
|
||||
}
|
||||
handleIndicatorStyle={{backgroundColor: pal.text.color}}
|
||||
handleStyle={[styles.handle, pal.view]}
|
||||
onAnimate={onBottomSheetAnimate}
|
||||
onChange={onBottomSheetChange}>
|
||||
{element}
|
||||
</BottomSheet>
|
||||
|
||||
@@ -9,8 +9,6 @@ import {useModals, useModalControls} from '#/state/modals'
|
||||
import type {Modal as ModalIface} from '#/state/modals'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ProfilePreviewModal from './ProfilePreview'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportModal from './report/Modal'
|
||||
import * as AppealLabelModal from './AppealLabel'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
@@ -85,10 +83,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ConfirmModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-profile') {
|
||||
element = <EditProfileModal.Component {...modal} />
|
||||
} else if (modal.name === 'profile-preview') {
|
||||
element = <ProfilePreviewModal.Component {...modal} />
|
||||
} else if (modal.name === 'server-input') {
|
||||
element = <ServerInputModal.Component {...modal} />
|
||||
} else if (modal.name === 'report') {
|
||||
element = <ReportModal.Component {...modal} />
|
||||
} else if (modal.name === 'appeal-label') {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationOpts, moderateProfile} from '@atproto/api'
|
||||
import {ThemedText} from '../util/text/ThemedText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {ProfileHeader} from '../profile/ProfileHeader'
|
||||
import {InfoCircleIcon} from 'lib/icons'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
import {s} from 'lib/styles'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {ErrorScreen} from '../util/error/ErrorScreen'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
export const snapPoints = [520, '100%']
|
||||
|
||||
export function Component({did}: {did: string}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {
|
||||
data: profile,
|
||||
error: profileError,
|
||||
refetch: refetchProfile,
|
||||
isLoading: isLoadingProfile,
|
||||
} = useProfileQuery({
|
||||
did: did,
|
||||
})
|
||||
|
||||
if (isLoadingProfile || !moderationOpts) {
|
||||
return (
|
||||
<CenteredView style={[pal.view, s.flex1]}>
|
||||
<ProfileHeader
|
||||
profile={null}
|
||||
moderation={null}
|
||||
isProfilePreview={true}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
if (profileError) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={_(msg`Oops!`)}
|
||||
message={cleanError(profileError)}
|
||||
onPressTryAgain={refetchProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (profile && moderationOpts) {
|
||||
return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} />
|
||||
}
|
||||
// should never happen
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={_(msg`Oops!`)}
|
||||
message={_(msg`Something went wrong and we're not sure what.`)}
|
||||
onPressTryAgain={refetchProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentLoaded({
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {screen} = useAnalytics()
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
|
||||
// track the navigator state to detect if a page-load occurred
|
||||
const navState = useNavigationState(state => state)
|
||||
const [initNavState] = useState(navState)
|
||||
const isLoading = initNavState !== navState
|
||||
|
||||
useEffect(() => {
|
||||
screen('Profile:Preview')
|
||||
}, [screen])
|
||||
|
||||
return (
|
||||
<View testID="profilePreview" style={[pal.view, s.flex1]}>
|
||||
<View style={[styles.headerWrapper]}>
|
||||
<ProfileHeader
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton
|
||||
isProfilePreview
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.hintWrapper, pal.view]}>
|
||||
<View style={styles.hint}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<>
|
||||
<InfoCircleIcon size={21} style={pal.textLight} />
|
||||
<ThemedText type="xl" fg="light">
|
||||
<Trans>Swipe up to see more</Trans>
|
||||
</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerWrapper: {
|
||||
height: 440,
|
||||
},
|
||||
hintWrapper: {
|
||||
height: 80,
|
||||
},
|
||||
hint: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 6,
|
||||
},
|
||||
})
|
||||
@@ -1,189 +0,0 @@
|
||||
import React, {useState} from 'react'
|
||||
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
|
||||
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
export function Component({onSelect}: {onSelect: (url: string) => void}) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const [customUrl, setCustomUrl] = useState<string>('')
|
||||
const {_} = useLingui()
|
||||
const {closeModal} = useModalControls()
|
||||
|
||||
const doSelect = (url: string) => {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
closeModal()
|
||||
onSelect(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[pal.view, s.flex1]} testID="serverInputModal">
|
||||
<Text type="2xl-bold" style={[pal.text, s.textCenter]}>
|
||||
<Trans>Choose Service</Trans>
|
||||
</Text>
|
||||
<ScrollView style={styles.inner}>
|
||||
<View style={styles.group}>
|
||||
{LOGIN_INCLUDE_DEV_SERVERS ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="localDevServerButton"
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(LOCAL_DEV_SERVICE)}
|
||||
accessibilityRole="button">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Local dev server</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(STAGING_SERVICE)}
|
||||
accessibilityRole="button">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Staging</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : undefined}
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(PROD_SERVICE)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select Bluesky Social`)}
|
||||
accessibilityHint="Sets Bluesky Social as your service provider">
|
||||
<Text style={styles.btnText}>
|
||||
<Trans>Bluesky.Social</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="arrow-right"
|
||||
style={s.white as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.group}>
|
||||
<Text style={[pal.text, styles.label]}>
|
||||
<Trans>Other service</Trans>
|
||||
</Text>
|
||||
<View style={s.flexRow}>
|
||||
<TextInput
|
||||
testID="customServerTextInput"
|
||||
style={[pal.borderDark, pal.text, styles.textInput]}
|
||||
placeholder="e.g. https://bsky.app"
|
||||
placeholderTextColor={colors.gray4}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
value={customUrl}
|
||||
onChangeText={setCustomUrl}
|
||||
accessibilityLabel={_(msg`Custom domain`)}
|
||||
// TODO: Simplify this wording further to be understandable by everyone
|
||||
accessibilityHint={_(
|
||||
msg`Use your domain as your Bluesky client service provider`,
|
||||
)}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="customServerSelectBtn"
|
||||
style={[pal.borderDark, pal.text, styles.textInputBtn]}
|
||||
onPress={() => doSelect(customUrl)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Confirm service. ${
|
||||
customUrl === ''
|
||||
? _(msg`Button disabled. Input custom domain to proceed.`)
|
||||
: ''
|
||||
}`}
|
||||
accessibilityHint=""
|
||||
// TODO - accessibility: Need to inform state change on failure
|
||||
disabled={customUrl === ''}>
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]}
|
||||
size={18}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inner: {
|
||||
padding: 14,
|
||||
},
|
||||
group: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderTopLeftRadius: 6,
|
||||
borderBottomLeftRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textInputBtn: {
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 0,
|
||||
borderTopRightRadius: 6,
|
||||
borderBottomRightRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.blue3,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 6,
|
||||
},
|
||||
btnText: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: colors.white,
|
||||
},
|
||||
checkIcon: {
|
||||
position: 'relative',
|
||||
...Platform.select({
|
||||
android: {
|
||||
top: 8,
|
||||
},
|
||||
ios: {
|
||||
top: 2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
|
||||
const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
|
||||
export const snapPoints = [575]
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Pager = React.forwardRef(function PagerImpl(
|
||||
const anchorRef = React.useRef(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage: (index: number) => setSelectedPage(index),
|
||||
setPage: (index: number) => onTabBarSelect(index),
|
||||
}))
|
||||
|
||||
const onTabBarSelect = React.useCallback(
|
||||
|
||||
@@ -61,26 +61,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
const headerHeight = headerOnlyHeight + tabBarHeight
|
||||
|
||||
// capture the header bar sizing
|
||||
const onTabBarLayout = React.useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
const height = evt.nativeEvent.layout.height
|
||||
if (height > 0) {
|
||||
// The rounding is necessary to prevent jumps on iOS
|
||||
setTabBarHeight(Math.round(height))
|
||||
}
|
||||
},
|
||||
[setTabBarHeight],
|
||||
)
|
||||
const onHeaderOnlyLayout = React.useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
const height = evt.nativeEvent.layout.height
|
||||
if (height > 0) {
|
||||
// The rounding is necessary to prevent jumps on iOS
|
||||
setHeaderOnlyHeight(Math.round(height))
|
||||
}
|
||||
},
|
||||
[setHeaderOnlyHeight],
|
||||
)
|
||||
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
|
||||
const height = evt.nativeEvent.layout.height
|
||||
if (height > 0) {
|
||||
// The rounding is necessary to prevent jumps on iOS
|
||||
setTabBarHeight(Math.round(height))
|
||||
}
|
||||
})
|
||||
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
|
||||
if (height > 0) {
|
||||
// The rounding is necessary to prevent jumps on iOS
|
||||
setHeaderOnlyHeight(Math.round(height))
|
||||
}
|
||||
})
|
||||
|
||||
const renderTabBar = React.useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
@@ -228,7 +221,7 @@ let PagerTabBar = ({
|
||||
testID?: string
|
||||
scrollY: SharedValue<number>
|
||||
renderHeader?: () => JSX.Element
|
||||
onHeaderOnlyLayout: (e: LayoutChangeEvent) => void
|
||||
onHeaderOnlyLayout: (height: number) => void
|
||||
onTabBarLayout: (e: LayoutChangeEvent) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
onSelect?: (index: number) => void
|
||||
@@ -240,12 +233,40 @@ let PagerTabBar = ({
|
||||
},
|
||||
],
|
||||
}))
|
||||
const pendingHeaderHeight = React.useRef<null | number>(null)
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.tabBarMobile, headerTransform]}>
|
||||
<View onLayout={onHeaderOnlyLayout} pointerEvents="box-none">
|
||||
<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
|
||||
}
|
||||
}}>
|
||||
{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.
|
||||
isHeaderReady && (
|
||||
<View
|
||||
onLayout={() => {
|
||||
// We're assuming the parent `onLayout` already ran (parent -> child ordering).
|
||||
if (pendingHeaderHeight.current !== null) {
|
||||
onHeaderOnlyLayout(pendingHeaderHeight.current)
|
||||
pendingHeaderHeight.current = null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<View
|
||||
onLayout={onTabBarLayout}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
children,
|
||||
testID,
|
||||
items,
|
||||
isHeaderReady,
|
||||
renderHeader,
|
||||
initialPage,
|
||||
onPageSelected,
|
||||
@@ -46,6 +47,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
<PagerTabBar
|
||||
items={items}
|
||||
renderHeader={renderHeader}
|
||||
isHeaderReady={isHeaderReady}
|
||||
currentPage={currentPage}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
onSelect={props.onSelect}
|
||||
@@ -54,7 +56,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
/>
|
||||
)
|
||||
},
|
||||
[items, renderHeader, currentPage, onCurrentPageSelected, testID],
|
||||
[
|
||||
items,
|
||||
isHeaderReady,
|
||||
renderHeader,
|
||||
currentPage,
|
||||
onCurrentPageSelected,
|
||||
testID,
|
||||
],
|
||||
)
|
||||
|
||||
const onPageSelectedInner = React.useCallback(
|
||||
@@ -80,8 +89,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
{toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, i) => {
|
||||
const isReady = isHeaderReady
|
||||
return (
|
||||
<View key={i} collapsable={false}>
|
||||
<View
|
||||
key={i}
|
||||
collapsable={false}
|
||||
style={{
|
||||
display: isReady ? undefined : 'none',
|
||||
}}>
|
||||
<PagerItem isFocused={i === currentPage} renderTab={child} />
|
||||
</View>
|
||||
)
|
||||
@@ -94,6 +109,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
let PagerTabBar = ({
|
||||
currentPage,
|
||||
items,
|
||||
isHeaderReady,
|
||||
testID,
|
||||
renderHeader,
|
||||
onCurrentPageSelected,
|
||||
@@ -104,6 +120,7 @@ let PagerTabBar = ({
|
||||
items: string[]
|
||||
testID?: string
|
||||
renderHeader?: () => JSX.Element
|
||||
isHeaderReady: boolean
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
onSelect?: (index: number) => void
|
||||
tabBarAnchor?: JSX.Element | null | undefined
|
||||
@@ -112,7 +129,12 @@ let PagerTabBar = ({
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
return (
|
||||
<>
|
||||
<View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
|
||||
<View
|
||||
style={[
|
||||
!isMobile && styles.headerContainerDesktop,
|
||||
pal.border,
|
||||
!isHeaderReady && styles.loadingHeader,
|
||||
]}>
|
||||
{renderHeader?.()}
|
||||
</View>
|
||||
{tabBarAnchor}
|
||||
@@ -123,6 +145,9 @@ let PagerTabBar = ({
|
||||
? styles.tabBarContainerMobile
|
||||
: styles.tabBarContainerDesktop,
|
||||
pal.border,
|
||||
{
|
||||
display: isHeaderReady ? undefined : 'none',
|
||||
},
|
||||
]}>
|
||||
<TabBar
|
||||
testID={testID}
|
||||
@@ -183,6 +208,9 @@ const styles = StyleSheet.create({
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
},
|
||||
loadingHeader: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
})
|
||||
|
||||
function toArray<T>(v: T | T[]): T[] {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Shadow, useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {
|
||||
useProfileFollowMutationQueue,
|
||||
useProfileQuery,
|
||||
} from 'state/queries/profile'
|
||||
import {useRequireAuth} from 'state/session'
|
||||
|
||||
export function PostThreadFollowBtn({did}: {did: string}) {
|
||||
const {data: profile, isLoading} = useProfileQuery({did})
|
||||
|
||||
// We will never hit this - the profile will always be cached or loaded above
|
||||
// but it keeps the typechecker happy
|
||||
if (isLoading || !profile) return null
|
||||
|
||||
return <PostThreadFollowBtnLoaded profile={profile} />
|
||||
}
|
||||
|
||||
function PostThreadFollowBtnLoaded({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const navigation = useNavigation()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const requireAuth = useRequireAuth()
|
||||
|
||||
const isFollowing = !!profile.viewer?.following
|
||||
const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
|
||||
|
||||
// This prevents the button from disappearing as soon as we follow.
|
||||
const showFollowBtn = React.useMemo(
|
||||
() => !isFollowing || !wasFollowing,
|
||||
[isFollowing, wasFollowing],
|
||||
)
|
||||
|
||||
/**
|
||||
* We want this button to stay visible even after following, so that the user can unfollow if they want.
|
||||
* However, we need it to disappear after we push to a screen and then come back. We also need it to
|
||||
* show up if we view the post while following, go to the profile and unfollow, then come back to the
|
||||
* post.
|
||||
*
|
||||
* We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
|
||||
* we could do this only on focus because the transition animation gives us time to not notice the
|
||||
* sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
|
||||
* button renders. So, we update the state in both cases.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const updateWasFollowing = () => {
|
||||
if (wasFollowing !== isFollowing) {
|
||||
setWasFollowing(isFollowing)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
|
||||
const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus()
|
||||
unsubscribeBlur()
|
||||
}
|
||||
}, [isFollowing, wasFollowing, navigation])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
if (!isFollowing) {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:FollowButtonClicked')
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to follow', {message: String(e)})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:UnfollowButtonClicked')
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unfollow', {message: String(e)})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
|
||||
|
||||
if (!showFollowBtn) return null
|
||||
|
||||
return (
|
||||
<View style={{width: isTabletOrDesktop ? 130 : 120}}>
|
||||
<View style={styles.btnOuter}>
|
||||
<TouchableOpacity
|
||||
testID="followBtn"
|
||||
onPress={onPress}
|
||||
style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
|
||||
accessibilityHint={_(
|
||||
msg`Shows posts from ${profile.handle} in your feed`,
|
||||
)}>
|
||||
{isTabletOrDesktop && (
|
||||
<FontAwesomeIcon
|
||||
icon={!isFollowing ? 'plus' : 'check'}
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
type="button"
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
||||
numberOfLines={1}>
|
||||
{!isFollowing ? <Trans>Follow</Trans> : <Trans>Following</Trans>}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btnOuter: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 50,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -23,7 +24,6 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
|
||||
import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
@@ -31,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
@@ -43,7 +42,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||
import {ThreadPost} from '#/state/queries/post-thread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSession} from 'state/session'
|
||||
import {WhoCanReply} from '../threadgate/WhoCanReply'
|
||||
|
||||
export function PostThreadItem({
|
||||
@@ -115,7 +114,6 @@ export function PostThreadItem({
|
||||
}
|
||||
|
||||
function PostThreadItemDeleted() {
|
||||
const styles = useStyles()
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
|
||||
@@ -162,11 +160,10 @@ let PostThreadItemLoaded = ({
|
||||
const {_} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {currentAccount} = useSession()
|
||||
const [limitLines, setLimitLines] = React.useState(
|
||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const styles = useStyles()
|
||||
const {currentAccount} = useSession()
|
||||
const hasEngagement = post.likeCount || post.repostCount
|
||||
|
||||
const rootUri = record.reply?.root?.uri || post.uri
|
||||
@@ -188,9 +185,6 @@ let PostThreadItemLoaded = ({
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
|
||||
}, [post.uri, post.author])
|
||||
const repostsTitle = _(msg`Reposts of this post`)
|
||||
const isModeratedPost =
|
||||
moderation.decisions.post.cause?.type === 'label' &&
|
||||
moderation.decisions.post.cause.label.src !== currentAccount?.did
|
||||
|
||||
const translatorUrl = getTranslatorLink(
|
||||
record?.text || '',
|
||||
@@ -255,7 +249,7 @@ let PostThreadItemLoaded = ({
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
<View style={styles.layout}>
|
||||
<View style={[styles.layout]}>
|
||||
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
@@ -268,33 +262,18 @@ let PostThreadItemLoaded = ({
|
||||
<View style={styles.layoutContent}>
|
||||
<View
|
||||
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
|
||||
<View style={[s.flexRow]}>
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
<TimeElapsed timestamp={post.indexedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
title={niceDate(post.indexedAt)}>
|
||||
· {timeElapsed}
|
||||
</Text>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
{isAuthorMuted && (
|
||||
@@ -321,33 +300,16 @@ let PostThreadItemLoaded = ({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
<PostDropdownBtn
|
||||
testID="postDropdownBtn"
|
||||
postAuthor={post.author}
|
||||
postCid={post.cid}
|
||||
postUri={post.uri}
|
||||
record={record}
|
||||
richText={richText}
|
||||
showAppealLabelItem={
|
||||
post.author.did === currentAccount?.did && isModeratedPost
|
||||
}
|
||||
style={{
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
marginLeft: 'auto',
|
||||
width: 40,
|
||||
}}
|
||||
/>
|
||||
{currentAccount?.did !== post.author.did && (
|
||||
<PostThreadFollowBtn did={post.author.did} />
|
||||
)}
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
<ContentHider
|
||||
@@ -437,7 +399,7 @@ let PostThreadItemLoaded = ({
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<View style={[s.pl10, s.pb5]}>
|
||||
<View style={[s.pl10, s.pr10, s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
post={post}
|
||||
@@ -649,7 +611,6 @@ function PostOuterWrapper({
|
||||
}>) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
const styles = useStyles()
|
||||
if (treeView && depth > 0) {
|
||||
return (
|
||||
<View
|
||||
@@ -726,94 +687,84 @@ function ExpandedPostDetails({
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = () => {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
return StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
outerHighlighted: {
|
||||
paddingTop: 16,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
noTopBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
layoutAvi: {},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
metaExpandedLine1: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
metaItem: {
|
||||
paddingRight: 5,
|
||||
maxWidth: isDesktop ? 380 : 220,
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
postTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
translateLink: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHider: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
},
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginTop: 5,
|
||||
marginBottom: 15,
|
||||
},
|
||||
expandedInfoItem: {
|
||||
marginRight: 10,
|
||||
},
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
replyLine: {
|
||||
width: 2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
cursor: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
outerHighlighted: {
|
||||
paddingTop: 16,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
noTopBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
layoutAvi: {},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
marginLeft: 10,
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 2,
|
||||
},
|
||||
metaExpandedLine1: {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
postTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
translateLink: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHider: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
},
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginTop: 5,
|
||||
marginBottom: 15,
|
||||
},
|
||||
expandedInfoItem: {
|
||||
marginRight: 10,
|
||||
},
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
replyLine: {
|
||||
width: 2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
cursor: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {memo} from 'react'
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
@@ -10,7 +10,8 @@ import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ProfileModeration,
|
||||
ModerationOpts,
|
||||
moderateProfile,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
@@ -43,79 +44,57 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {LabelInfo} from '../util/moderation/LabelInfo'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
|
||||
interface Props {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null
|
||||
moderation: ProfileModeration | null
|
||||
hideBackButton?: boolean
|
||||
isProfilePreview?: boolean
|
||||
}
|
||||
|
||||
export function ProfileHeader({
|
||||
profile,
|
||||
moderation,
|
||||
hideBackButton = false,
|
||||
isProfilePreview,
|
||||
}: Props) {
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
// loading
|
||||
// =
|
||||
if (!profile || !moderation) {
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<LoadingPlaceholder width="100%" height={153} />
|
||||
<View
|
||||
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
|
||||
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.buttonsLine]}>
|
||||
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
|
||||
</View>
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
|
||||
<View
|
||||
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
|
||||
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.buttonsLine]}>
|
||||
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// loaded
|
||||
// =
|
||||
return (
|
||||
<ProfileHeaderLoaded
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isProfilePreview={isProfilePreview}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLoading = memo(ProfileHeaderLoading)
|
||||
export {ProfileHeaderLoading}
|
||||
|
||||
interface LoadedProps {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ProfileModeration
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isProfilePreview?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderLoaded = ({
|
||||
profile,
|
||||
moderation,
|
||||
let ProfileHeader = ({
|
||||
profile: profileUnshadowed,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isProfilePreview,
|
||||
}: LoadedProps): React.ReactNode => {
|
||||
isPlaceholderProfile,
|
||||
}: Props): React.ReactNode => {
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
@@ -132,37 +111,10 @@ let ProfileHeaderLoaded = ({
|
||||
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
|
||||
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/*
|
||||
* BEGIN handle bio facet resolution
|
||||
*/
|
||||
// should be undefined on first render to trigger a resolution
|
||||
const prevProfileDescription = React.useRef<string | undefined>()
|
||||
const [descriptionRT, setDescriptionRT] = React.useState<
|
||||
RichTextAPI | undefined
|
||||
>(
|
||||
profile.description
|
||||
? new RichTextAPI({text: profile.description})
|
||||
: undefined,
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const rt = new RichTextAPI({text: profile.description || ''})
|
||||
await rt.detectFacets(getAgent())
|
||||
// replace existing RT instance
|
||||
setDescriptionRT(rt)
|
||||
}
|
||||
|
||||
if (profile.description !== prevProfileDescription.current) {
|
||||
// update prev immediately
|
||||
prevProfileDescription.current = profile.description
|
||||
resolveRTFacets()
|
||||
}
|
||||
}, [profile.description, setDescriptionRT])
|
||||
/*
|
||||
* END handle bio facet resolution
|
||||
*/
|
||||
|
||||
const invalidateProfileQuery = React.useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -443,9 +395,17 @@ let ProfileHeaderLoaded = ({
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
|
||||
return (
|
||||
<View style={pal.view} pointerEvents="box-none">
|
||||
<View style={[pal.view]} pointerEvents="box-none">
|
||||
<View pointerEvents="none">
|
||||
<UserBanner banner={profile.banner} moderation={moderation.avatar} />
|
||||
{isPlaceholderProfile ? (
|
||||
<LoadingPlaceholder
|
||||
width="100%"
|
||||
height={150}
|
||||
style={{borderRadius: 0}}
|
||||
/>
|
||||
) : (
|
||||
<UserBanner banner={profile.banner} moderation={moderation.avatar} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.content} pointerEvents="box-none">
|
||||
<View style={[styles.buttonsLine]} pointerEvents="box-none">
|
||||
@@ -479,7 +439,7 @@ let ProfileHeaderLoaded = ({
|
||||
)
|
||||
) : !profile.viewer?.blockedBy ? (
|
||||
<>
|
||||
{!isProfilePreview && hasSession && (
|
||||
{hasSession && (
|
||||
<TouchableOpacity
|
||||
testID="suggestedFollowsBtn"
|
||||
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
|
||||
@@ -598,7 +558,7 @@ let ProfileHeaderLoaded = ({
|
||||
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{!blockHide && (
|
||||
{!isPlaceholderProfile && !blockHide && (
|
||||
<>
|
||||
<View style={styles.metricsLine} pointerEvents="box-none">
|
||||
<Link
|
||||
@@ -649,7 +609,7 @@ let ProfileHeaderLoaded = ({
|
||||
</Text>
|
||||
</View>
|
||||
{descriptionRT && !moderation.profile.blur ? (
|
||||
<View pointerEvents={isNative ? 'auto' : 'none'}>
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[styles.description, pal.text]}
|
||||
@@ -667,7 +627,7 @@ let ProfileHeaderLoaded = ({
|
||||
<ProfileHeaderModCard />
|
||||
</View>
|
||||
|
||||
{!isProfilePreview && showSuggestedFollows && (
|
||||
{showSuggestedFollows && (
|
||||
<ProfileHeaderSuggestedFollows
|
||||
actorDid={profile.did}
|
||||
requestDismiss={() => {
|
||||
@@ -714,7 +674,8 @@ let ProfileHeaderLoaded = ({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLoaded = memo(ProfileHeaderLoaded)
|
||||
ProfileHeader = memo(ProfileHeader)
|
||||
export {ProfileHeader}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
|
||||
@@ -123,6 +123,7 @@ let UserAvatar = ({
|
||||
usePlainRNImage = false,
|
||||
}: UserAvatarProps): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (type === 'algo' || type === 'list') {
|
||||
@@ -130,14 +131,16 @@ let UserAvatar = ({
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size > 32 ? 8 : 3,
|
||||
backgroundColor,
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.floor(size / 2),
|
||||
backgroundColor,
|
||||
}
|
||||
}, [type, size])
|
||||
}, [type, size, backgroundColor])
|
||||
|
||||
const alert = useMemo(() => {
|
||||
if (!moderation?.alert) {
|
||||
|
||||
@@ -3,7 +3,10 @@ import {StyleSheet, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Image} from 'expo-image'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||
import {
|
||||
usePhotoLibraryPermission,
|
||||
@@ -13,8 +16,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
@@ -26,6 +27,7 @@ export function UserBanner({
|
||||
onSelectNewBanner?: (img: RNImage | null) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
@@ -142,7 +144,10 @@ export function UserBanner({
|
||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
style={[
|
||||
styles.bannerImage,
|
||||
{backgroundColor: theme.palette.default.backgroundLight},
|
||||
]}
|
||||
resizeMode="cover"
|
||||
source={{uri: banner}}
|
||||
blurRadius={moderation?.blur ? 100 : 0}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleProp, ViewStyle} from 'react-native'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {Link} from './Link'
|
||||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {usePrefetchProfileQuery} from '#/state/queries/profile'
|
||||
|
||||
interface UserPreviewLinkProps {
|
||||
@@ -14,38 +13,19 @@ interface UserPreviewLinkProps {
|
||||
export function UserPreviewLink(
|
||||
props: React.PropsWithChildren<UserPreviewLinkProps>,
|
||||
) {
|
||||
const {openModal} = useModalControls()
|
||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||
|
||||
if (isWeb || isAndroid) {
|
||||
return (
|
||||
<Link
|
||||
onPointerEnter={() => {
|
||||
if (isWeb) {
|
||||
prefetchProfileQuery(props.did)
|
||||
}
|
||||
}}
|
||||
href={makeProfileLink(props)}
|
||||
title={props.handle}
|
||||
asAnchor
|
||||
style={props.style}>
|
||||
{props.children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
openModal({
|
||||
name: 'profile-preview',
|
||||
did: props.did,
|
||||
})
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={props.handle}
|
||||
accessibilityHint=""
|
||||
<Link
|
||||
onPointerEnter={() => {
|
||||
if (isWeb) {
|
||||
prefetchProfileQuery(props.did)
|
||||
}
|
||||
}}
|
||||
href={makeProfileLink(props)}
|
||||
title={props.handle}
|
||||
asAnchor
|
||||
style={props.style}>
|
||||
{props.children}
|
||||
</Pressable>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {Button} from '../forms/Button'
|
||||
import {CenteredView} from '../Views'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
|
||||
export function ErrorScreen({
|
||||
title,
|
||||
@@ -18,66 +20,72 @@ export function ErrorScreen({
|
||||
details,
|
||||
onPressTryAgain,
|
||||
testID,
|
||||
showHeader,
|
||||
}: {
|
||||
title: string
|
||||
message: string
|
||||
details?: string
|
||||
onPressTryAgain?: () => void
|
||||
testID?: string
|
||||
showHeader?: boolean
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<CenteredView testID={testID} style={[styles.outer, pal.view]}>
|
||||
<View style={styles.errorIconContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.errorIcon,
|
||||
{backgroundColor: theme.palette.inverted.background},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={pal.textInverted as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text type="title-lg" style={[styles.title, pal.text]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.message, pal.text]}>{message}</Text>
|
||||
{details && (
|
||||
<Text
|
||||
testID={`${testID}-details`}
|
||||
style={[styles.details, pal.text, pal.viewLight]}>
|
||||
{details}
|
||||
</Text>
|
||||
)}
|
||||
{onPressTryAgain && (
|
||||
<View style={styles.btnContainer}>
|
||||
<Button
|
||||
testID="errorScreenTryAgainButton"
|
||||
type="default"
|
||||
style={[styles.btn]}
|
||||
onPress={onPressTryAgain}
|
||||
accessibilityLabel={_(msg`Retry`)}
|
||||
accessibilityHint={_(
|
||||
msg`Retries the last action, which errored out`,
|
||||
)}>
|
||||
<>
|
||||
{showHeader && isMobile && <ViewHeader title="Error" showBorder />}
|
||||
<CenteredView testID={testID} style={[styles.outer, pal.view]}>
|
||||
<View style={styles.errorIconContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.errorIcon,
|
||||
{backgroundColor: theme.palette.inverted.background},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="arrows-rotate"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={16}
|
||||
icon="exclamation"
|
||||
style={pal.textInverted as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
<Text type="button" style={[styles.btnText, pal.link]}>
|
||||
<Trans context="action">Try again</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</CenteredView>
|
||||
<Text type="title-lg" style={[styles.title, pal.text]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.message, pal.text]}>{message}</Text>
|
||||
{details && (
|
||||
<Text
|
||||
testID={`${testID}-details`}
|
||||
style={[styles.details, pal.text, pal.viewLight]}>
|
||||
{details}
|
||||
</Text>
|
||||
)}
|
||||
{onPressTryAgain && (
|
||||
<View style={styles.btnContainer}>
|
||||
<Button
|
||||
testID="errorScreenTryAgainButton"
|
||||
type="default"
|
||||
style={[styles.btn]}
|
||||
onPress={onPressTryAgain}
|
||||
accessibilityLabel={_(msg`Retry`)}
|
||||
accessibilityHint={_(
|
||||
msg`Retries the last action, which errored out`,
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon="arrows-rotate"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={16}
|
||||
/>
|
||||
<Text type="button" style={[styles.btnText, pal.link]}>
|
||||
<Trans context="action">Try again</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</CenteredView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Text} from '../text/Text'
|
||||
@@ -30,6 +31,10 @@ import {Shadow} from '#/state/cache/types'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
|
||||
let PostCtrls = ({
|
||||
big,
|
||||
@@ -116,11 +121,18 @@ let PostCtrls = ({
|
||||
closeModal,
|
||||
])
|
||||
|
||||
const onShare = useCallback(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
const href = makeProfileLink(post.author, 'post', urip.rkey)
|
||||
const url = toShareUrl(href)
|
||||
shareUrl(url)
|
||||
}, [post.uri, post.author])
|
||||
|
||||
return (
|
||||
<View style={[styles.ctrls, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.ctrl,
|
||||
big ? styles.ctrlBig : styles.ctrl,
|
||||
post.viewer?.replyDisabled ? {opacity: 0.5} : undefined,
|
||||
]}>
|
||||
<TouchableOpacity
|
||||
@@ -149,7 +161,7 @@ let PostCtrls = ({
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.ctrl}>
|
||||
<View style={big ? styles.ctrlBig : styles.ctrl}>
|
||||
<RepostButton
|
||||
big={big}
|
||||
isReposted={!!post.viewer?.repost}
|
||||
@@ -158,7 +170,7 @@ let PostCtrls = ({
|
||||
onQuote={onQuote}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.ctrl}>
|
||||
<View style={big ? styles.ctrlBig : styles.ctrl}>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
style={[styles.btn, !big && styles.btnPad]}
|
||||
@@ -193,20 +205,34 @@ let PostCtrls = ({
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{big ? undefined : (
|
||||
<View style={styles.ctrl}>
|
||||
<PostDropdownBtn
|
||||
testID="postDropdownBtn"
|
||||
postAuthor={post.author}
|
||||
postCid={post.cid}
|
||||
postUri={post.uri}
|
||||
record={record}
|
||||
richText={richText}
|
||||
showAppealLabelItem={showAppealLabelItem}
|
||||
style={styles.btnPad}
|
||||
/>
|
||||
{big && (
|
||||
<View style={styles.ctrlBig}>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
style={[styles.btn]}
|
||||
onPress={onShare}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${
|
||||
post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
|
||||
} (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
|
||||
accessibilityHint=""
|
||||
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
|
||||
<ArrowOutOfBox style={[defaultCtrlColor, styles.mt1]} width={22} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<View style={big ? styles.ctrlBig : styles.ctrl}>
|
||||
<PostDropdownBtn
|
||||
testID="postDropdownBtn"
|
||||
postAuthor={post.author}
|
||||
postCid={post.cid}
|
||||
postUri={post.uri}
|
||||
record={record}
|
||||
richText={richText}
|
||||
showAppealLabelItem={showAppealLabelItem}
|
||||
style={styles.btnPad}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -223,6 +249,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
ctrlBig: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -37,8 +37,8 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
|
||||
The Community Guidelines have been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/community-guidelines"
|
||||
text="blueskyweb.xyz/support/community-guidelines"
|
||||
href="https://bsky.social/about/support/community-guidelines"
|
||||
text="bsky.social/about/support/community-guidelines"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -34,8 +34,8 @@ export const CopyrightPolicyScreen = (_props: Props) => {
|
||||
The Copyright Policy has been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/community-guidelines"
|
||||
text="blueskyweb.xyz/support/community-guidelines"
|
||||
href="https://bsky.social/about/support/copyright"
|
||||
text="bsky.social/about/support/copyright"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
+18
-13
@@ -7,7 +7,7 @@ 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 {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager'
|
||||
import {FeedPage} from 'view/com/feeds/FeedPage'
|
||||
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
||||
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
|
||||
@@ -16,25 +16,19 @@ import {usePinnedFeedsInfos, FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||
export function HomeScreen(props: Props) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
|
||||
usePinnedFeedsInfos()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const [rawInitialFeed] = React.useState<string>(
|
||||
() => persisted.get('lastSelectedHomeFeed') ?? 'home',
|
||||
)
|
||||
if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) {
|
||||
return (
|
||||
<HomeScreenReady
|
||||
{...props}
|
||||
preferences={preferences}
|
||||
pinnedFeedInfos={pinnedFeedInfos}
|
||||
rawInitialFeed={isDesktop ? 'home' : rawInitialFeed}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
@@ -49,11 +43,9 @@ export function HomeScreen(props: Props) {
|
||||
function HomeScreenReady({
|
||||
preferences,
|
||||
pinnedFeedInfos,
|
||||
rawInitialFeed,
|
||||
}: Props & {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
pinnedFeedInfos: FeedSourceInfo[]
|
||||
rawInitialFeed: string
|
||||
}) {
|
||||
const allFeeds = React.useMemo(() => {
|
||||
const feeds: FeedDescriptor[] = []
|
||||
@@ -68,12 +60,24 @@ function HomeScreenReady({
|
||||
return feeds
|
||||
}, [pinnedFeedInfos])
|
||||
|
||||
const [rawSelectedFeed, setSelectedFeed] =
|
||||
React.useState<string>(rawInitialFeed)
|
||||
const rawSelectedFeed = useSelectedFeed()
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
|
||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
|
||||
const pagerRef = React.useRef<PagerRef>(null)
|
||||
const lastPagerReportedIndexRef = React.useRef(selectedIndex)
|
||||
React.useLayoutEffect(() => {
|
||||
// Since the pager is not a controlled component, adjust it imperatively
|
||||
// if the selected index gets out of sync with what it last reported.
|
||||
// This is supposed to only happen on the web when you use the right nav.
|
||||
if (selectedIndex !== lastPagerReportedIndexRef.current) {
|
||||
lastPagerReportedIndexRef.current = selectedIndex
|
||||
pagerRef.current?.setPage(selectedIndex)
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
const {hasSession} = useSession()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
@@ -93,7 +97,7 @@ function HomeScreenReady({
|
||||
setDrawerSwipeDisabled(index > 0)
|
||||
const feed = allFeeds[index]
|
||||
setSelectedFeed(feed)
|
||||
persisted.write('lastSelectedHomeFeed', feed)
|
||||
lastPagerReportedIndexRef.current = index
|
||||
},
|
||||
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
|
||||
)
|
||||
@@ -147,6 +151,7 @@ function HomeScreenReady({
|
||||
return hasSession ? (
|
||||
<Pager
|
||||
key={allFeeds.join(',')}
|
||||
ref={pagerRef}
|
||||
testID="homeScreen"
|
||||
initialPage={selectedIndex}
|
||||
onPageSelected={onPageSelected}
|
||||
|
||||
@@ -29,6 +29,9 @@ import {listenSoftReset, emitSoftReset} from '#/state/events'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {FAB} from '../com/util/fab/FAB'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
NotificationsTabNavigatorParams,
|
||||
@@ -47,6 +50,7 @@ export function NotificationsScreen({}: Props) {
|
||||
const unreadApi = useUnreadNotificationsApi()
|
||||
const hasNew = !!unreadNotifs
|
||||
const isScreenFocused = useIsFocused()
|
||||
const {openComposer} = useComposerControls()
|
||||
|
||||
// event handlers
|
||||
// =
|
||||
@@ -156,6 +160,14 @@ export function NotificationsScreen({}: Props) {
|
||||
showIndicator={hasNew}
|
||||
/>
|
||||
)}
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({})}
|
||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export const PrivacyPolicyScreen = (_props: Props) => {
|
||||
The Privacy Policy has been moved to{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
text="blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text="bsky.social/about/support/privacy-policy"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
@@ -11,7 +16,7 @@ import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {ProfileLists} from '../com/lists/ProfileLists'
|
||||
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
|
||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||
import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
import {EmptyState} from '../com/util/EmptyState'
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
@@ -50,6 +55,7 @@ interface SectionRef {
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
export function ProfileScreen({route}: Props) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const name =
|
||||
route.params.name === 'me' ? currentAccount?.did : route.params.name
|
||||
@@ -65,6 +71,7 @@ export function ProfileScreen({route}: Props) {
|
||||
error: profileError,
|
||||
refetch: refetchProfile,
|
||||
isLoading: isLoadingProfile,
|
||||
isPlaceholderData: isPlaceholderProfile,
|
||||
} = useProfileQuery({
|
||||
did: resolvedDid,
|
||||
})
|
||||
@@ -84,27 +91,23 @@ export function ProfileScreen({route}: Props) {
|
||||
}
|
||||
}, [profile?.viewer?.blockedBy, resolvedDid])
|
||||
|
||||
if (isLoadingDid || isLoadingProfile || !moderationOpts) {
|
||||
// Most pushes will happen here, since we will have only placeholder data
|
||||
if (isLoadingDid || isLoadingProfile) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ProfileHeader
|
||||
profile={null}
|
||||
moderation={null}
|
||||
isProfilePreview={true}
|
||||
/>
|
||||
<ProfileHeaderLoading />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
if (resolveError || profileError) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorScreen
|
||||
testID="profileErrorScreen"
|
||||
title="Oops!"
|
||||
message={cleanError(resolveError || profileError)}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</CenteredView>
|
||||
<ErrorScreen
|
||||
testID="profileErrorScreen"
|
||||
title={profileError ? _(msg`Not Found`) : _(msg`Oops!`)}
|
||||
message={cleanError(resolveError || profileError)}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
showHeader
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (profile && moderationOpts) {
|
||||
@@ -112,31 +115,33 @@ export function ProfileScreen({route}: Props) {
|
||||
<ProfileScreenLoaded
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
isPlaceholderProfile={isPlaceholderProfile}
|
||||
hideBackButton={!!route.params.hideBackButton}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// should never happen
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorScreen
|
||||
testID="profileErrorScreen"
|
||||
title="Oops!"
|
||||
message="Something went wrong and we're not sure what."
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</CenteredView>
|
||||
<ErrorScreen
|
||||
testID="profileErrorScreen"
|
||||
title="Oops!"
|
||||
message="Something went wrong and we're not sure what."
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
showHeader
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileScreenLoaded({
|
||||
profile: profileUnshadowed,
|
||||
isPlaceholderProfile,
|
||||
moderationOpts,
|
||||
hideBackButton,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton: boolean
|
||||
isPlaceholderProfile: boolean
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
@@ -156,6 +161,10 @@ function ProfileScreenLoaded({
|
||||
|
||||
useSetTitle(combinedDisplayName(profile))
|
||||
|
||||
const description = profile.description ?? ''
|
||||
const hasDescription = description !== ''
|
||||
const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
|
||||
const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
@@ -269,11 +278,20 @@ function ProfileScreenLoaded({
|
||||
return (
|
||||
<ProfileHeader
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
descriptionRT={hasDescription ? descriptionRT : null}
|
||||
moderationOpts={moderationOpts}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={showPlaceholder}
|
||||
/>
|
||||
)
|
||||
}, [profile, moderation, hideBackButton])
|
||||
}, [
|
||||
profile,
|
||||
descriptionRT,
|
||||
hasDescription,
|
||||
moderationOpts,
|
||||
hideBackButton,
|
||||
showPlaceholder,
|
||||
])
|
||||
|
||||
return (
|
||||
<ScreenHider
|
||||
@@ -283,7 +301,7 @@ function ProfileScreenLoaded({
|
||||
moderation={moderation.account}>
|
||||
<PagerWithHeader
|
||||
testID="profilePager"
|
||||
isHeaderReady={true}
|
||||
isHeaderReady={!showPlaceholder}
|
||||
items={sectionTitles}
|
||||
onPageSelected={onPageSelected}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
@@ -440,6 +458,35 @@ function ProfileEndOfFeed() {
|
||||
)
|
||||
}
|
||||
|
||||
function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
setResolvedRT(null)
|
||||
// This will queue an immediate re-render
|
||||
}
|
||||
React.useEffect(() => {
|
||||
let ignore = false
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const resolvedRT = new RichTextAPI({text})
|
||||
await resolvedRT.detectFacets(getAgent())
|
||||
if (!ignore) {
|
||||
setResolvedRT(resolvedRT)
|
||||
}
|
||||
}
|
||||
resolveRTFacets()
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -863,13 +863,13 @@ export function SettingsScreen({}: Props) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms of Service`)}
|
||||
/>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -14,19 +14,19 @@ export function Links() {
|
||||
|
||||
<View style={[a.gap_md, a.align_start]}>
|
||||
<InlineLink
|
||||
to="https://blueskyweb.xyz"
|
||||
to="https://bsky.social"
|
||||
warnOnMismatchingTextChild
|
||||
style={[a.text_md]}>
|
||||
External
|
||||
</InlineLink>
|
||||
<InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}>
|
||||
<InlineLink to="https://bsky.social" style={[a.text_md]}>
|
||||
<H3>External with custom children</H3>
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://blueskyweb.xyz"
|
||||
to="https://bsky.social"
|
||||
warnOnMismatchingTextChild
|
||||
style={[a.text_lg]}>
|
||||
https://blueskyweb.xyz
|
||||
https://bsky.social
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
to="https://bsky.app/profile/bsky.app"
|
||||
|
||||
@@ -14,22 +14,22 @@ export function Theming() {
|
||||
|
||||
<Text style={[a.font_bold, a.pt_xl, a.px_md]}>theme.atoms.text</Text>
|
||||
|
||||
<View style={[a.flex_1, t.atoms.border, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_600, a.px_md]}>
|
||||
theme.atoms.text_contrast_600
|
||||
<View style={[a.flex_1, t.atoms.border_contrast_high, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_high, a.px_md]}>
|
||||
theme.atoms.text_contrast_high
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_1, t.atoms.border, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_500, a.px_md]}>
|
||||
theme.atoms.text_contrast_500
|
||||
<View style={[a.flex_1, t.atoms.border_contrast_medium, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium, a.px_md]}>
|
||||
theme.atoms.text_contrast_medium
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_1, t.atoms.border, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_400, a.px_md]}>
|
||||
theme.atoms.text_contrast_400
|
||||
<View style={[a.flex_1, t.atoms.border_contrast_low, a.border_t]} />
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_low, a.px_md]}>
|
||||
theme.atoms.text_contrast_low
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_1, t.atoms.border_contrast, a.border_t]} />
|
||||
<View style={[a.flex_1, t.atoms.border_contrast_low, a.border_t]} />
|
||||
|
||||
<View style={[a.w_full, a.gap_md]}>
|
||||
<View style={[t.atoms.bg, a.justify_center, a.p_md]}>
|
||||
|
||||
@@ -2,20 +2,12 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {RichText} from '#/components/RichText'
|
||||
|
||||
export function Typography() {
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<H1>H1 Heading</H1>
|
||||
<H2>H2 Heading</H2>
|
||||
<H3>H3 Heading</H3>
|
||||
<H4>H4 Heading</H4>
|
||||
<H5>H5 Heading</H5>
|
||||
<H6>H6 Heading</H6>
|
||||
<P>P Paragraph</P>
|
||||
|
||||
<Text 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>
|
||||
@@ -29,11 +21,11 @@ export function Typography() {
|
||||
|
||||
<RichText
|
||||
resolveFacets
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
/>
|
||||
<RichText
|
||||
resolveFacets
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
style={[a.text_xl]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -33,8 +33,8 @@ export const TermsOfServiceScreen = (_props: Props) => {
|
||||
<Trans>The Terms of Service have been moved to</Trans>{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
text="blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text="bsky.social/about/support/tos"
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -255,13 +255,13 @@ let DrawerContent = ({}: {}): React.ReactNode => {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms of Service`)}
|
||||
/>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet} from 'react-native'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
import {useNavigationState, useNavigation} from '@react-navigation/native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
|
||||
export function DesktopFeeds() {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {feeds} = usePinnedFeedsInfos()
|
||||
|
||||
const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos()
|
||||
const selectedFeed = useSelectedFeed()
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const route = useNavigationState(state => {
|
||||
if (!state) {
|
||||
return {name: 'Home'}
|
||||
@@ -22,30 +28,34 @@ export function DesktopFeeds() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
<FeedItem href="/" title="Following" current={route.name === 'Home'} />
|
||||
{feeds
|
||||
.filter(f => f.displayName !== 'Following')
|
||||
.map(feed => {
|
||||
try {
|
||||
const params = route.params as Record<string, string>
|
||||
const routeName =
|
||||
feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList'
|
||||
return (
|
||||
<FeedItem
|
||||
key={feed.uri}
|
||||
href={feed.route.href}
|
||||
title={feed.displayName}
|
||||
current={
|
||||
route.name === routeName &&
|
||||
params.name === feed.route.params.name &&
|
||||
params.rkey === feed.route.params.rkey
|
||||
}
|
||||
/>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})}
|
||||
{pinnedFeedInfos.map(feedInfo => {
|
||||
const uri = feedInfo.uri
|
||||
let feed: FeedDescriptor
|
||||
if (!uri) {
|
||||
feed = 'home'
|
||||
} else if (uri.includes('app.bsky.feed.generator')) {
|
||||
feed = `feedgen|${uri}`
|
||||
} else if (uri.includes('app.bsky.graph.list')) {
|
||||
feed = `list|${uri}`
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<FeedItem
|
||||
key={feed}
|
||||
href={'/?' + new URLSearchParams([['feed', feed]])}
|
||||
title={feedInfo.displayName}
|
||||
current={route.name === 'Home' && feed === selectedFeed}
|
||||
onPress={() => {
|
||||
setSelectedFeed(feed)
|
||||
navigation.navigate('Home')
|
||||
if (feed === selectedFeed) {
|
||||
emitSoftReset()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<View style={{paddingTop: 8, paddingBottom: 6}}>
|
||||
<TextLink
|
||||
type="lg"
|
||||
@@ -62,10 +72,12 @@ function FeedItem({
|
||||
title,
|
||||
href,
|
||||
current,
|
||||
onPress,
|
||||
}: {
|
||||
title: string
|
||||
href: string
|
||||
current: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
@@ -74,6 +86,7 @@ function FeedItem({
|
||||
type="xl"
|
||||
href={href}
|
||||
text={title}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
current ? pal.text : pal.textLight,
|
||||
{letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},
|
||||
|
||||
@@ -334,7 +334,7 @@ export function DesktopLeftNav() {
|
||||
}
|
||||
iconFilled={
|
||||
<HashtagIcon
|
||||
strokeWidth={2.5}
|
||||
strokeWidth={4}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={isDesktop ? 24 : 28}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/privacy-policy"
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy`)}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
@@ -89,7 +89,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.xyz/support/tos"
|
||||
href="https://bsky.social/about/support/tos"
|
||||
text={_(msg`Terms`)}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
|
||||
Reference in New Issue
Block a user