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:
Eric Bailey
2024-02-12 15:43:33 -06:00
90 changed files with 1781 additions and 1707 deletions
+3 -3
View File
@@ -83,19 +83,19 @@ export function HomeLoggedOutCTA() {
<View style={[styles.footer, pal.view, pal.border]}>
<TextLink
type="2xl"
href="https://blueskyweb.xyz"
href="https://bsky.social"
text={_(msg`Business`)}
style={[styles.footerLink, pal.link]}
/>
<TextLink
type="2xl"
href="https://blueskyweb.xyz/blog"
href="https://bsky.social/about/blog"
text={_(msg`Blog`)}
style={[styles.footerLink, pal.link]}
/>
<TextLink
type="2xl"
href="https://blueskyweb.xyz/join"
href="https://bsky.social/about/join"
text={_(msg`Jobs`)}
style={[styles.footerLink, pal.link]}
/>
+3 -3
View File
@@ -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]}
/>
+77 -25
View File
@@ -3,6 +3,7 @@ import {
ActivityIndicator,
Keyboard,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
@@ -13,7 +14,6 @@ import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button'
import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
@@ -21,7 +21,14 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
import {toNiceDomain} from '#/lib/strings/url-helpers'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
@@ -43,16 +50,12 @@ export function Step1({
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const serverInputControl = useDialogControl()
const onPressSelectService = React.useCallback(() => {
openModal({
name: 'server-input',
initialService: uiState.serviceUrl,
onSelect: (url: string) =>
uiDispatch({type: 'set-service-url', value: url}),
})
serverInputControl.open()
Keyboard.dismiss()
}, [uiDispatch, uiState.serviceUrl, openModal])
}, [serverInputControl])
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
@@ -64,23 +67,72 @@ export function Step1({
return (
<View>
<StepHeader uiState={uiState} title={_(msg`Your account`)}>
<View>
<Button
testID="selectServiceButton"
type="default"
style={{
aspectRatio: 1,
justifyContent: 'center',
alignItems: 'center',
}}
accessibilityLabel={_(msg`Select service`)}
accessibilityHint={_(msg`Sets server for the Bluesky client`)}
onPress={onPressSelectService}>
<FontAwesomeIcon icon="server" size={21} color={pal.colors.text} />
</Button>
<ServerInputDialog
control={serverInputControl}
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
/>
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans>
</Text>
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
<View
style={[
pal.borderDark,
{flexDirection: 'row', alignItems: 'center'},
]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, {marginLeft: 14}]}
/>
<TouchableOpacity
testID="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 />
+13 -9
View File
@@ -1,6 +1,7 @@
import React, {useState, useEffect} from 'react'
import {
ActivityIndicator,
Keyboard,
TextInput,
TouchableOpacity,
View,
@@ -24,7 +25,9 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {styles} from './styles'
import {useModalControls} from '#/state/modals'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -51,19 +54,16 @@ export const ForgotPasswordForm = ({
const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics()
const {_} = useLingui()
const {openModal} = useModalControls()
const serverInputControl = useDialogControl()
useEffect(() => {
screen('Signin:ForgotPassword')
}, [screen])
const onPressSelectService = () => {
openModal({
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
}
const onPressSelectService = React.useCallback(() => {
serverInputControl.open()
Keyboard.dismiss()
}, [serverInputControl])
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
@@ -96,6 +96,10 @@ export const ForgotPasswordForm = ({
return (
<>
<View>
<ServerInputDialog
control={serverInputControl}
onSelect={setServiceUrl}
/>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
<Trans>Reset password</Trans>
</Text>
+10 -7
View File
@@ -25,7 +25,9 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {styles} from './styles'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -58,15 +60,11 @@ export const LoginForm = ({
const [password, setPassword] = useState<string>('')
const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui()
const {openModal} = useModalControls()
const {login} = useSessionApi()
const serverInputControl = useDialogControl()
const onPressSelectService = () => {
openModal({
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
serverInputControl.open()
Keyboard.dismiss()
track('Signin:PressedSelectService')
}
@@ -130,6 +128,11 @@ export const LoginForm = ({
const isReady = !!serviceDescription && !!identifier && !!password
return (
<View testID="loginForm">
<ServerInputDialog
control={serverInputControl}
onSelect={setServiceUrl}
/>
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
<Trans>Sign into</Trans>
</Text>
+173
View File
@@ -0,0 +1,173 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {PROD_SERVICE} from 'lib/constants'
import * as persisted from '#/state/persisted'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text, P} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import * as ToggleButton from '#/components/forms/ToggleButton'
import * as TextField from '#/components/forms/TextField'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
export function ServerInputDialog({
control,
onSelect,
}: {
control: Dialog.DialogOuterProps['control']
onSelect: (url: string) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
persisted.get('pdsAddressHistory') || [],
)
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
const [customAddress, setCustomAddress] = React.useState('')
const onClose = React.useCallback(() => {
let url
if (fixedOption[0] === 'custom') {
url = customAddress.trim().toLowerCase()
if (!url) {
return
}
} else {
url = fixedOption[0]
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
url = `http://${url}`
} else {
url = `https://${url}`
}
}
if (fixedOption[0] === 'custom') {
if (!pdsAddressHistory.includes(url)) {
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
setPdsAddressHistory(newHistory)
persisted.write('pdsAddressHistory', newHistory)
}
}
onSelect(url)
}, [
fixedOption,
customAddress,
onSelect,
pdsAddressHistory,
setPdsAddressHistory,
])
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose Service</Trans>
</Text>
<P nativeID="dialog-description" style={[a.text_sm]}>
<Trans>Select the service that hosts your data.</Trans>
</P>
<ToggleButton.Group
label="Preferences"
values={fixedOption}
onChange={setFixedOption}>
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
{_(msg`Bluesky`)}
</ToggleButton.Button>
<ToggleButton.Button
testID="customSelectBtn"
name="custom"
label={_(msg`Custom`)}>
{_(msg`Custom`)}
</ToggleButton.Button>
</ToggleButton.Group>
{fixedOption[0] === 'custom' && (
<View
style={[
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.px_md,
a.py_md,
]}>
<TextField.Label nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.Label>
<TextField.Root>
<TextField.Icon icon={Globe} />
<Dialog.Input
testID="customServerTextInput"
value={customAddress}
onChangeText={setCustomAddress}
label={_(msg`my-server.com`)}
accessibilityLabelledBy="address-input-label"
autoCapitalize="none"
keyboardType="url"
/>
</TextField.Root>
{pdsAddressHistory.length > 0 && (
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
{pdsAddressHistory.map(uri => (
<Button
key={uri}
variant="ghost"
color="primary"
label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText>
</Button>
))}
</View>
)}
</View>
)}
<View style={[a.py_xs]}>
<P
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.leading_snug,
a.flex_1,
]}>
<Trans>
Bluesky is an open network where you can choose your hosting
provider. Custom hosting is now available in beta for
developers.
</Trans>
</P>
</View>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size="small"
onPress={() => control.close()}
label={_(msg`Done`)}>
{_(msg`Done`)}
</Button>
</View>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
@@ -28,6 +28,7 @@ import {useTheme} from 'lib/ThemeContext'
import {isUriImage} from 'lib/media/util'
import {downloadAndResize} from 'lib/media/manip'
import {POST_IMG_MAX} from 'lib/constants'
import {isIOS} from 'platform/detection'
export interface TextInputRef {
focus: () => void
@@ -252,6 +253,7 @@ const styles = StyleSheet.create({
fontSize: 18,
letterSpacing: 0.2,
fontWeight: '400',
lineHeight: 23.4, // 1.3*16
// This is broken on ios right now, so don't set it there.
lineHeight: isIOS ? undefined : 23.4, // 1.3*16
},
})
@@ -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>
+2 -39
View File
@@ -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>
-6
View File
@@ -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') {
-134
View File
@@ -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,
},
})
-189
View File
@@ -1,189 +0,0 @@
import React, {useState} from 'react'
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
export const snapPoints = ['80%']
export function Component({onSelect}: {onSelect: (url: string) => void}) {
const theme = useTheme()
const pal = usePalette('default')
const [customUrl, setCustomUrl] = useState<string>('')
const {_} = useLingui()
const {closeModal} = useModalControls()
const doSelect = (url: string) => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `https://${url}`
}
closeModal()
onSelect(url)
}
return (
<View style={[pal.view, s.flex1]} testID="serverInputModal">
<Text type="2xl-bold" style={[pal.text, s.textCenter]}>
<Trans>Choose Service</Trans>
</Text>
<ScrollView style={styles.inner}>
<View style={styles.group}>
{LOGIN_INCLUDE_DEV_SERVERS ? (
<>
<TouchableOpacity
testID="localDevServerButton"
style={styles.btn}
onPress={() => doSelect(LOCAL_DEV_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>
<Trans>Local dev server</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.btn}
onPress={() => doSelect(STAGING_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>
<Trans>Staging</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
</>
) : undefined}
<TouchableOpacity
style={styles.btn}
onPress={() => doSelect(PROD_SERVICE)}
accessibilityRole="button"
accessibilityLabel={_(msg`Select Bluesky Social`)}
accessibilityHint="Sets Bluesky Social as your service provider">
<Text style={styles.btnText}>
<Trans>Bluesky.Social</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
</View>
<View style={styles.group}>
<Text style={[pal.text, styles.label]}>
<Trans>Other service</Trans>
</Text>
<View style={s.flexRow}>
<TextInput
testID="customServerTextInput"
style={[pal.borderDark, pal.text, styles.textInput]}
placeholder="e.g. https://bsky.app"
placeholderTextColor={colors.gray4}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
value={customUrl}
onChangeText={setCustomUrl}
accessibilityLabel={_(msg`Custom domain`)}
// TODO: Simplify this wording further to be understandable by everyone
accessibilityHint={_(
msg`Use your domain as your Bluesky client service provider`,
)}
/>
<TouchableOpacity
testID="customServerSelectBtn"
style={[pal.borderDark, pal.text, styles.textInputBtn]}
onPress={() => doSelect(customUrl)}
accessibilityRole="button"
accessibilityLabel={`Confirm service. ${
customUrl === ''
? _(msg`Button disabled. Input custom domain to proceed.`)
: ''
}`}
accessibilityHint=""
// TODO - accessibility: Need to inform state change on failure
disabled={customUrl === ''}>
<FontAwesomeIcon
icon="check"
style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]}
size={18}
/>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
inner: {
padding: 14,
},
group: {
marginBottom: 20,
},
label: {
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 4,
},
textInput: {
flex: 1,
borderWidth: 1,
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
textInputBtn: {
borderWidth: 1,
borderLeftWidth: 0,
borderTopRightRadius: 6,
borderBottomRightRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.blue3,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
marginBottom: 6,
},
btnText: {
flex: 1,
fontSize: 18,
fontWeight: '500',
color: colors.white,
},
checkIcon: {
position: 'relative',
...Platform.select({
android: {
top: 8,
},
ios: {
top: 2,
},
}),
},
})
+1 -1
View File
@@ -18,7 +18,7 @@ import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {getAgent} from '#/state/session'
const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const snapPoints = [575]
+1 -1
View File
@@ -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(
+43 -22
View File
@@ -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 -3
View File
@@ -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,
},
})
+101 -150
View File
@@ -9,6 +9,7 @@ import {
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text'
@@ -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)}>
&middot;&nbsp;{timeElapsed}
</Text>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
type="xl-bold"
style={[pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
)}
</TimeElapsed>
</View>
</Text>
</Link>
</View>
<View style={styles.meta}>
{isAuthorMuted && (
@@ -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',
},
})
+52 -91
View File
@@ -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: {
+4 -1
View File
@@ -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) {
+8 -3
View File
@@ -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}
+12 -32
View File
@@ -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>
)
}
+53 -45
View File
@@ -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>
</>
)
}
+44 -15
View File
@@ -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',
+2 -2
View File
@@ -37,8 +37,8 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
The Community Guidelines have been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
href="https://bsky.social/about/support/community-guidelines"
text="bsky.social/about/support/community-guidelines"
/>
</Trans>
</Text>
+2 -2
View File
@@ -34,8 +34,8 @@ export const CopyrightPolicyScreen = (_props: Props) => {
The Copyright Policy has been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
href="https://bsky.social/about/support/copyright"
text="bsky.social/about/support/copyright"
/>
</Trans>
</Text>
+18 -13
View File
@@ -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}
+12
View File
@@ -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>
)
}
+2 -2
View File
@@ -34,8 +34,8 @@ export const PrivacyPolicyScreen = (_props: Props) => {
The Privacy Policy has been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/privacy-policy"
text="blueskyweb.xyz/support/privacy-policy"
href="https://bsky.social/about/support/privacy-policy"
text="bsky.social/about/support/privacy-policy"
/>
</Trans>
</Text>
+75 -28
View File
@@ -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',
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -14,19 +14,19 @@ export function Links() {
<View style={[a.gap_md, a.align_start]}>
<InlineLink
to="https://blueskyweb.xyz"
to="https://bsky.social"
warnOnMismatchingTextChild
style={[a.text_md]}>
External
</InlineLink>
<InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}>
<InlineLink to="https://bsky.social" style={[a.text_md]}>
<H3>External with custom children</H3>
</InlineLink>
<InlineLink
to="https://blueskyweb.xyz"
to="https://bsky.social"
warnOnMismatchingTextChild
style={[a.text_lg]}>
https://blueskyweb.xyz
https://bsky.social
</InlineLink>
<InlineLink
to="https://bsky.app/profile/bsky.app"
+10 -10
View File
@@ -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]}>
+3 -11
View File
@@ -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>
+2 -2
View File
@@ -33,8 +33,8 @@ export const TermsOfServiceScreen = (_props: Props) => {
<Trans>The Terms of Service have been moved to</Trans>{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/tos"
text="blueskyweb.xyz/support/tos"
href="https://bsky.social/about/support/tos"
text="bsky.social/about/support/tos"
/>
</Text>
</View>
+2 -2
View File
@@ -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>
+40 -27
View File
@@ -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'},
+1 -1
View File
@@ -334,7 +334,7 @@ export function DesktopLeftNav() {
}
iconFilled={
<HashtagIcon
strokeWidth={2.5}
strokeWidth={4}
style={pal.text as FontAwesomeIconStyle}
size={isDesktop ? 24 : 28}
/>
+2 -2
View File
@@ -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}>