diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Navigation.tsx | 6 | ||||
-rw-r--r-- | src/components/Lists.tsx | 228 | ||||
-rw-r--r-- | src/components/RichText.tsx | 11 | ||||
-rw-r--r-- | src/components/TagMenu/index.tsx | 54 | ||||
-rw-r--r-- | src/components/TagMenu/index.web.tsx | 25 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 3 | ||||
-rw-r--r-- | src/routes.ts | 1 | ||||
-rw-r--r-- | src/screens/Hashtag.tsx | 157 | ||||
-rw-r--r-- | src/view/com/util/ViewHeader.tsx | 97 |
9 files changed, 501 insertions, 81 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0aeeeb6ad..c650c1f40 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -77,6 +77,7 @@ import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbed import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' +import HashtagScreen from '#/screens/Hashtag' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + <Stack.Screen + name="Hashtag" + getComponent={() => HashtagScreen} + options={{title: title(msg`Hashtag`)}} + /> </> ) } diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx new file mode 100644 index 000000000..cf00734f0 --- /dev/null +++ b/src/components/Lists.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {View} from 'react-native' +import {Loader} from '#/components/Loader' +import {Trans} from '@lingui/macro' +import {cleanError} from 'lib/strings/errors' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import {StackActions} from '@react-navigation/native' +import {useNavigation} from '@react-navigation/core' +import {NavigationProp} from 'lib/routes/types' + +export function ListFooter({ + isFetching, + isError, + error, + onRetry, +}: { + isFetching: boolean + isError: boolean + error?: string + onRetry?: () => Promise<unknown> +}) { + const t = useTheme() + + return ( + <View + style={[ + a.w_full, + a.align_center, + a.justify_center, + a.border_t, + t.atoms.border_contrast_low, + {height: 100}, + ]}> + {isFetching ? ( + <Loader size="xl" /> + ) : ( + <ListFooterMaybeError + isError={isError} + error={error} + onRetry={onRetry} + /> + )} + </View> + ) +} + +function ListFooterMaybeError({ + isError, + error, + onRetry, +}: { + isError: boolean + error?: string + onRetry?: () => Promise<unknown> +}) { + const t = useTheme() + + if (!isError) return null + + return ( + <View style={[a.w_full, a.px_lg]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_md, + a.rounded_sm, + a.align_center, + t.atoms.bg_contrast_25, + ]}> + <Text + style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]} + numberOfLines={2}> + {error ? ( + cleanError(error) + ) : ( + <Trans>Oops, something went wrong!</Trans> + )} + </Text> + <Button + variant="gradient" + label="Press to retry" + style={[ + a.align_center, + a.justify_center, + a.rounded_sm, + a.overflow_hidden, + a.px_md, + a.py_sm, + ]} + onPress={onRetry}> + Retry + </Button> + </View> + </View> + ) +} + +export function ListHeaderDesktop({ + title, + subtitle, +}: { + title: string + subtitle?: string +}) { + const {gtTablet} = useBreakpoints() + const t = useTheme() + + if (!gtTablet) return null + + return ( + <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}> + <Text style={[a.text_3xl, a.font_bold]}>{title}</Text> + {subtitle ? ( + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + {subtitle} + </Text> + ) : undefined} + </View> + ) +} + +export function ListMaybePlaceholder({ + isLoading, + isEmpty, + isError, + empty, + error, + onRetry, +}: { + isLoading: boolean + isEmpty: boolean + isError: boolean + empty?: string + error?: string + onRetry?: () => Promise<unknown> +}) { + const navigation = useNavigation<NavigationProp>() + const t = useTheme() + + const canGoBack = navigation.canGoBack() + const onGoBack = React.useCallback(() => { + if (canGoBack) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + }, [navigation, canGoBack]) + + if (!isEmpty) return null + + return ( + <View + style={[ + a.flex_1, + a.align_center, + a.border_t, + a.justify_between, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]}> + {isLoading ? ( + <View style={[a.w_full, a.align_center, {top: 100}]}> + <Loader size="xl" /> + </View> + ) : ( + <> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <Text style={[a.font_bold, a.text_3xl]}> + {isError ? ( + <Trans>Oops!</Trans> + ) : isEmpty ? ( + <Trans>Page not found</Trans> + ) : undefined} + </Text> + + {isError ? ( + <Text + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> + {error ? error : <Trans>Something went wrong!</Trans>} + </Text> + ) : isEmpty ? ( + <Text + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> + {empty ? ( + empty + ) : ( + <Trans> + We're sorry! We can't find the page you were looking for. + </Trans> + )} + </Text> + ) : undefined} + </View> + <View style={[a.w_full, a.px_lg, a.gap_md]}> + {isError && onRetry && ( + <Button + variant="solid" + color="primary" + label="Click here" + onPress={onRetry} + size="large" + style={[ + a.rounded_sm, + a.overflow_hidden, + {paddingVertical: 10}, + ]}> + Retry + </Button> + )} + <Button + variant="solid" + color={isError && onRetry ? 'secondary' : 'primary'} + label="Click here" + onPress={onGoBack} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + Go Back + </Button> + </View> + </> + )} + </View> + ) +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 5d82d7e5e..1a14415cf 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -120,6 +120,7 @@ export function RichText({ <RichTextTag key={key} text={segment.text} + tag={tag.tag} style={styles} selectable={selectable} authorHandle={authorHandle} @@ -145,12 +146,14 @@ export function RichText({ } function RichTextTag({ - text: tag, + text, + tag, style, selectable, authorHandle, }: { text: string + tag: string selectable?: boolean authorHandle?: string } & TextStyleProp) { @@ -184,8 +187,8 @@ function RichTextTag({ <Text selectable={selectable} {...native({ - accessibilityLabel: _(msg`Hashtag: ${tag}`), - accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), + accessibilityLabel: _(msg`Hashtag: #${tag}`), + accessibilityHint: _(msg`Click here to open tag menu for #${tag}`), accessibilityRole: isNative ? 'button' : undefined, onPress: open, onPressIn: onPressIn, @@ -213,7 +216,7 @@ function RichTextTag({ textDecorationColor: t.palette.primary_500, }, ]}> - {tag} + {text} </Text> </TagMenu> </React.Fragment> diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index c18c0d6a2..c9ced9a54 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -34,6 +34,10 @@ export function TagMenu({ authorHandle, }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] + /** + * This should be the sanitized tag value from the facet itself, not the + * "display" value with a leading `#`. + */ tag: string authorHandle?: string }>) { @@ -52,16 +56,16 @@ export function TagMenu({ variables: optimisticRemove, reset: resetRemove, } = useRemoveMutedWordMutation() + const displayTag = '#' + tag - const sanitizedTag = tag.replace(/^#/, '') const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) return ( @@ -71,7 +75,7 @@ export function TagMenu({ <Dialog.Outer control={control}> <Dialog.Handle /> - <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> + <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> {isPreferencesLoading ? ( <View style={[a.w_full, a.align_center]}> <Loader size="lg" /> @@ -87,18 +91,14 @@ export function TagMenu({ t.atoms.bg_contrast_25, ]}> <Link - label={_(msg`Search for all posts with tag ${tag}`)} - to={makeSearchLink({query: tag})} + label={_(msg`Search for all posts with tag ${displayTag}`)} + to={makeSearchLink({query: displayTag})} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: tag, - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }) @@ -128,7 +128,7 @@ export function TagMenu({ <Trans> See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts </Trans> @@ -142,21 +142,19 @@ export function TagMenu({ <Link label={_( - msg`Search for all posts by @${authorHandle} with tag ${tag}`, + msg`Search for all posts by @${authorHandle} with tag ${displayTag}`, )} - to={makeSearchLink({query: tag, from: authorHandle})} + to={makeSearchLink({ + query: displayTag, + from: authorHandle, + })} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: - tag + - (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }) @@ -190,7 +188,7 @@ export function TagMenu({ See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts by this user </Trans> @@ -207,8 +205,8 @@ export function TagMenu({ <Button label={ isMuted - ? _(msg`Unmute all ${tag} posts`) - : _(msg`Mute all ${tag} posts`) + ? _(msg`Unmute all ${displayTag} posts`) + : _(msg`Mute all ${displayTag} posts`) } onPress={() => { control.close(() => { @@ -250,7 +248,7 @@ export function TagMenu({ ]}> {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} <Trans>posts</Trans> </Text> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 4fcb4c812..a0dc2bce6 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -35,10 +35,13 @@ export function TagMenu({ tag, authorHandle, }: React.PropsWithChildren<{ + /** + * This should be the sanitized tag value from the facet itself, not the + * "display" value with a leading `#`. + */ tag: string authorHandle?: string }>) { - const sanitizedTag = tag.replace(/^#/, '') const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {data: preferences} = usePreferencesQuery() @@ -48,22 +51,22 @@ export function TagMenu({ useRemoveMutedWordMutation() const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) - const truncatedTag = enforceLen(tag, 15, true, 'middle') + const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') const dropdownItems = React.useMemo(() => { return [ { label: _(msg`See ${truncatedTag} posts`), onPress() { - navigation.navigate('Search', { - q: tag, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }, testID: 'tagMenuSearch', @@ -79,11 +82,9 @@ export function TagMenu({ !isInvalidHandle(authorHandle) && { label: _(msg`See ${truncatedTag} posts by user`), onPress() { - navigation.navigate({ - name: 'Search', - params: { - q: tag + (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }, testID: 'tagMenuSeachByUser', diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0ec09f610..6756a62a6 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -34,6 +34,7 @@ export type CommonNavigatorParams = { PreferencesThreads: undefined PreferencesExternalEmbeds: undefined Search: {q?: string} + Hashtag: {tag: string; author?: string} } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -69,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Search: {q?: string} Feeds: undefined Notifications: undefined + Hashtag: {tag: string; author?: string} } export type AllNavigatorParams = CommonNavigatorParams & { @@ -81,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined + Hashtag: {tag: string; author?: string} } // NOTE diff --git a/src/routes.ts b/src/routes.ts index d17f15912..3fc908b48 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -33,4 +33,5 @@ export const router = new Router({ TermsOfService: '/support/tos', CommunityGuidelines: '/support/community-guidelines', CopyrightPolicy: '/support/copyright', + Hashtag: '/hashtag/:tag', }) diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx new file mode 100644 index 000000000..794753ea3 --- /dev/null +++ b/src/screens/Hashtag.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import {ListRenderItemInfo, Pressable} from 'react-native' +import {atoms as a} from '#/alf' +import {useFocusEffect} from '@react-navigation/native' +import {useSetMinimalShellMode} from 'state/shell' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {CommonNavigatorParams} from 'lib/routes/types' +import {useSearchPostsQuery} from 'state/queries/search-posts' +import {Post} from 'view/com/post/Post' +import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {enforceLen} from 'lib/strings/helpers' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {List} from 'view/com/util/List' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {sanitizeHandle} from 'lib/strings/handles' +import {CenteredView} from 'view/com/util/Views' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox' +import {shareUrl} from 'lib/sharing' +import {HITSLOP_10} from 'lib/constants' + +const renderItem = ({item}: ListRenderItemInfo<PostView>) => { + return <Post post={item} /> +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function HashtagScreen({ + route, +}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { + const {tag, author} = route.params + const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() + const [isPTR, setIsPTR] = React.useState(false) + + const fullTag = React.useMemo(() => { + return `#${tag.replaceAll('%23', '#')}` + }, [tag]) + + const queryParam = React.useMemo(() => { + if (!author) return fullTag + return `${fullTag} from:${sanitizeHandle(author)}` + }, [fullTag, author]) + + const headerTitle = React.useMemo(() => { + return enforceLen(fullTag.toLowerCase(), 24, true, 'middle') + }, [fullTag]) + + const sanitizedAuthor = React.useMemo(() => { + if (!author) return + return sanitizeHandle(author) + }, [author]) + + const { + data, + isFetching, + isLoading, + isRefetching, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useSearchPostsQuery({query: queryParam}) + + const posts = React.useMemo(() => { + return data?.pages.flatMap(page => page.posts) || [] + }, [data]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onShare = React.useCallback(() => { + const url = new URL('https://bsky.app') + url.pathname = `/hashtag/${tag}` + if (author) { + url.searchParams.set('author', author) + } + shareUrl(url.toString()) + }, [tag, author]) + + const onRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [refetch]) + + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, hasNextPage, error, fetchNextPage]) + + return ( + <CenteredView style={a.flex_1}> + <ViewHeader + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + canGoBack={true} + renderButton={() => ( + <Pressable + accessibilityRole="button" + onPress={onShare} + hitSlop={HITSLOP_10}> + <ArrowOutOfBox_Stroke2_Corner0_Rounded + size="lg" + onPress={onShare} + /> + </Pressable> + )} + /> + <ListMaybePlaceholder + isLoading={isLoading || isRefetching} + isError={isError} + isEmpty={posts.length < 1} + onRetry={refetch} + empty={_(msg`We couldn't find any results for that hashtag.`)} + /> + {!isLoading && posts.length > 0 && ( + <List<PostView> + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTR} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + // @ts-ignore web only -prf + desktopFixedHeight + ListHeaderComponent={ + <ListHeaderDesktop + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + /> + } + ListFooterComponent={ + <ListFooter + isFetching={isFetching && !isRefetching} + isError={isError} + error={error?.name} + onRetry={fetchNextPage} + /> + } + /> + )} + </CenteredView> + ) +} diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 1ccfcf56c..872e10eef 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated' import {useSetDrawerOpen} from '#/state/shell' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useTheme} from '#/alf' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} export function ViewHeader({ title, + subtitle, canGoBack, showBackButton = true, hideOnScroll, @@ -26,6 +28,7 @@ export function ViewHeader({ renderButton, }: { title: string + subtitle?: string canGoBack?: boolean showBackButton?: boolean hideOnScroll?: boolean @@ -39,6 +42,7 @@ export function ViewHeader({ const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() const {isDesktop, isTablet} = useWebMediaQueries() + const t = useTheme() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -71,42 +75,60 @@ export function ViewHeader({ return ( <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}> - {showBackButton ? ( - <TouchableOpacity - testID="viewHeaderDrawerBtn" - onPress={canGoBack ? onPressBack : onPressMenu} - hitSlop={BACK_HITSLOP} - style={canGoBack ? styles.backBtn : styles.backBtnWide} - accessibilityRole="button" - accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} - accessibilityHint={ - canGoBack ? '' : _(msg`Access navigation links and settings`) - }> - {canGoBack ? ( - <FontAwesomeIcon - size={18} - icon="angle-left" - style={[styles.backIcon, pal.text]} - /> - ) : !isTablet ? ( - <FontAwesomeIcon - size={18} - icon="bars" - style={[styles.backIcon, pal.textLight]} - /> + <View style={{flex: 1}}> + <View style={{flexDirection: 'row', alignItems: 'center'}}> + {showBackButton ? ( + <TouchableOpacity + testID="viewHeaderDrawerBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide} + accessibilityRole="button" + accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} + accessibilityHint={ + canGoBack ? '' : _(msg`Access navigation links and settings`) + }> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : !isTablet ? ( + <FontAwesomeIcon + size={18} + icon="bars" + style={[styles.backIcon, pal.textLight]} + /> + ) : null} + </TouchableOpacity> ) : null} - </TouchableOpacity> - ) : null} - <View style={styles.titleContainer} pointerEvents="none"> - <Text type="title" style={[pal.text, styles.title]}> - {title} - </Text> + <View style={styles.titleContainer} pointerEvents="none"> + <Text type="title" style={[pal.text, styles.title]}> + {title} + </Text> + </View> + {renderButton ? ( + renderButton() + ) : showBackButton ? ( + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + ) : null} + </View> + {subtitle ? ( + <View + style={[styles.titleContainer, {marginTop: -3}]} + pointerEvents="none"> + <Text + style={[ + pal.text, + styles.subtitle, + t.atoms.text_contrast_medium, + ]}> + {subtitle} + </Text> + </View> + ) : undefined} </View> - {renderButton ? ( - renderButton() - ) : showBackButton ? ( - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> - ) : null} </Container> ) } @@ -185,7 +207,6 @@ function Container({ const styles = StyleSheet.create({ header: { flexDirection: 'row', - alignItems: 'center', paddingHorizontal: 12, paddingVertical: 6, width: '100%', @@ -207,12 +228,14 @@ const styles = StyleSheet.create({ titleContainer: { marginLeft: 'auto', marginRight: 'auto', - paddingRight: 10, + alignItems: 'center', }, title: { fontWeight: 'bold', }, - + subtitle: { + fontSize: 13, + }, backBtn: { width: 30, height: 30, |