Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Dedicated screen for hashtags, POC ALF list (#3047)

* create dedicated hashtag "search" screen

clarify loading component name

more adjustments

rework `ViewHeader` to keep chevron centered w/ first line

adjustments

adjustments

use `author` instead of `handle` in route

add web route for url

add web route for url

Add desktop list header

support web

keep header lowercase

add optional subtitle to view header

correct isFetching logic

oops

use `isFetching` for clarity in footer

combine logic

update bskyweb

finish screen

style, add footer, add spinner, etc

add list

add header, params

create a screen

* add variable to server path

* localize `By`

* add empty state

* more adjustments

* sanitize author

* fix web

* add custom message for hashtag not found error

* ellipsis in middle

* fix

* fix trans

* account for multiple #

* encode #

* replaceall

* Use sanitized tag

* don't call function in lingui

* add share button

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by

Hailey
Eric Bailey
and committed by
GitHub
cf8b0380 8900c67d

+502 -81
+1
bskyweb/cmd/bskyweb/server.go
··· 180 180 e.GET("/", server.WebHome) 181 181 182 182 // generic routes 183 + e.GET("/hashtag/:tag", server.WebGeneric) 183 184 e.GET("/search", server.WebGeneric) 184 185 e.GET("/feeds", server.WebGeneric) 185 186 e.GET("/notifications", server.WebGeneric)
+6
src/Navigation.tsx
··· 77 77 import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' 78 78 import {msg} from '@lingui/macro' 79 79 import {i18n, MessageDescriptor} from '@lingui/core' 80 + import HashtagScreen from '#/screens/Hashtag' 80 81 81 82 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 82 83 ··· 261 262 title: title(msg`External Media Preferences`), 262 263 requireAuth: true, 263 264 }} 265 + /> 266 + <Stack.Screen 267 + name="Hashtag" 268 + getComponent={() => HashtagScreen} 269 + options={{title: title(msg`Hashtag`)}} 264 270 /> 265 271 </> 266 272 )
+228
src/components/Lists.tsx
··· 1 + import React from 'react' 2 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 3 + import {View} from 'react-native' 4 + import {Loader} from '#/components/Loader' 5 + import {Trans} from '@lingui/macro' 6 + import {cleanError} from 'lib/strings/errors' 7 + import {Button} from '#/components/Button' 8 + import {Text} from '#/components/Typography' 9 + import {StackActions} from '@react-navigation/native' 10 + import {useNavigation} from '@react-navigation/core' 11 + import {NavigationProp} from 'lib/routes/types' 12 + 13 + export function ListFooter({ 14 + isFetching, 15 + isError, 16 + error, 17 + onRetry, 18 + }: { 19 + isFetching: boolean 20 + isError: boolean 21 + error?: string 22 + onRetry?: () => Promise<unknown> 23 + }) { 24 + const t = useTheme() 25 + 26 + return ( 27 + <View 28 + style={[ 29 + a.w_full, 30 + a.align_center, 31 + a.justify_center, 32 + a.border_t, 33 + t.atoms.border_contrast_low, 34 + {height: 100}, 35 + ]}> 36 + {isFetching ? ( 37 + <Loader size="xl" /> 38 + ) : ( 39 + <ListFooterMaybeError 40 + isError={isError} 41 + error={error} 42 + onRetry={onRetry} 43 + /> 44 + )} 45 + </View> 46 + ) 47 + } 48 + 49 + function ListFooterMaybeError({ 50 + isError, 51 + error, 52 + onRetry, 53 + }: { 54 + isError: boolean 55 + error?: string 56 + onRetry?: () => Promise<unknown> 57 + }) { 58 + const t = useTheme() 59 + 60 + if (!isError) return null 61 + 62 + return ( 63 + <View style={[a.w_full, a.px_lg]}> 64 + <View 65 + style={[ 66 + a.flex_row, 67 + a.gap_md, 68 + a.p_md, 69 + a.rounded_sm, 70 + a.align_center, 71 + t.atoms.bg_contrast_25, 72 + ]}> 73 + <Text 74 + style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]} 75 + numberOfLines={2}> 76 + {error ? ( 77 + cleanError(error) 78 + ) : ( 79 + <Trans>Oops, something went wrong!</Trans> 80 + )} 81 + </Text> 82 + <Button 83 + variant="gradient" 84 + label="Press to retry" 85 + style={[ 86 + a.align_center, 87 + a.justify_center, 88 + a.rounded_sm, 89 + a.overflow_hidden, 90 + a.px_md, 91 + a.py_sm, 92 + ]} 93 + onPress={onRetry}> 94 + Retry 95 + </Button> 96 + </View> 97 + </View> 98 + ) 99 + } 100 + 101 + export function ListHeaderDesktop({ 102 + title, 103 + subtitle, 104 + }: { 105 + title: string 106 + subtitle?: string 107 + }) { 108 + const {gtTablet} = useBreakpoints() 109 + const t = useTheme() 110 + 111 + if (!gtTablet) return null 112 + 113 + return ( 114 + <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}> 115 + <Text style={[a.text_3xl, a.font_bold]}>{title}</Text> 116 + {subtitle ? ( 117 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 118 + {subtitle} 119 + </Text> 120 + ) : undefined} 121 + </View> 122 + ) 123 + } 124 + 125 + export function ListMaybePlaceholder({ 126 + isLoading, 127 + isEmpty, 128 + isError, 129 + empty, 130 + error, 131 + onRetry, 132 + }: { 133 + isLoading: boolean 134 + isEmpty: boolean 135 + isError: boolean 136 + empty?: string 137 + error?: string 138 + onRetry?: () => Promise<unknown> 139 + }) { 140 + const navigation = useNavigation<NavigationProp>() 141 + const t = useTheme() 142 + 143 + const canGoBack = navigation.canGoBack() 144 + const onGoBack = React.useCallback(() => { 145 + if (canGoBack) { 146 + navigation.goBack() 147 + } else { 148 + navigation.navigate('HomeTab') 149 + navigation.dispatch(StackActions.popToTop()) 150 + } 151 + }, [navigation, canGoBack]) 152 + 153 + if (!isEmpty) return null 154 + 155 + return ( 156 + <View 157 + style={[ 158 + a.flex_1, 159 + a.align_center, 160 + a.border_t, 161 + a.justify_between, 162 + t.atoms.border_contrast_low, 163 + {paddingTop: 175, paddingBottom: 110}, 164 + ]}> 165 + {isLoading ? ( 166 + <View style={[a.w_full, a.align_center, {top: 100}]}> 167 + <Loader size="xl" /> 168 + </View> 169 + ) : ( 170 + <> 171 + <View style={[a.w_full, a.align_center, a.gap_lg]}> 172 + <Text style={[a.font_bold, a.text_3xl]}> 173 + {isError ? ( 174 + <Trans>Oops!</Trans> 175 + ) : isEmpty ? ( 176 + <Trans>Page not found</Trans> 177 + ) : undefined} 178 + </Text> 179 + 180 + {isError ? ( 181 + <Text 182 + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> 183 + {error ? error : <Trans>Something went wrong!</Trans>} 184 + </Text> 185 + ) : isEmpty ? ( 186 + <Text 187 + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> 188 + {empty ? ( 189 + empty 190 + ) : ( 191 + <Trans> 192 + We're sorry! We can't find the page you were looking for. 193 + </Trans> 194 + )} 195 + </Text> 196 + ) : undefined} 197 + </View> 198 + <View style={[a.w_full, a.px_lg, a.gap_md]}> 199 + {isError && onRetry && ( 200 + <Button 201 + variant="solid" 202 + color="primary" 203 + label="Click here" 204 + onPress={onRetry} 205 + size="large" 206 + style={[ 207 + a.rounded_sm, 208 + a.overflow_hidden, 209 + {paddingVertical: 10}, 210 + ]}> 211 + Retry 212 + </Button> 213 + )} 214 + <Button 215 + variant="solid" 216 + color={isError && onRetry ? 'secondary' : 'primary'} 217 + label="Click here" 218 + onPress={onGoBack} 219 + size="large" 220 + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> 221 + Go Back 222 + </Button> 223 + </View> 224 + </> 225 + )} 226 + </View> 227 + ) 228 + }
+7 -4
src/components/RichText.tsx
··· 120 120 <RichTextTag 121 121 key={key} 122 122 text={segment.text} 123 + tag={tag.tag} 123 124 style={styles} 124 125 selectable={selectable} 125 126 authorHandle={authorHandle} ··· 145 146 } 146 147 147 148 function RichTextTag({ 148 - text: tag, 149 + text, 150 + tag, 149 151 style, 150 152 selectable, 151 153 authorHandle, 152 154 }: { 153 155 text: string 156 + tag: string 154 157 selectable?: boolean 155 158 authorHandle?: string 156 159 } & TextStyleProp) { ··· 184 187 <Text 185 188 selectable={selectable} 186 189 {...native({ 187 - accessibilityLabel: _(msg`Hashtag: ${tag}`), 188 - accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), 190 + accessibilityLabel: _(msg`Hashtag: #${tag}`), 191 + accessibilityHint: _(msg`Click here to open tag menu for #${tag}`), 189 192 accessibilityRole: isNative ? 'button' : undefined, 190 193 onPress: open, 191 194 onPressIn: onPressIn, ··· 213 216 textDecorationColor: t.palette.primary_500, 214 217 }, 215 218 ]}> 216 - {tag} 219 + {text} 217 220 </Text> 218 221 </TagMenu> 219 222 </React.Fragment>
+26 -28
src/components/TagMenu/index.tsx
··· 34 34 authorHandle, 35 35 }: React.PropsWithChildren<{ 36 36 control: Dialog.DialogOuterProps['control'] 37 + /** 38 + * This should be the sanitized tag value from the facet itself, not the 39 + * "display" value with a leading `#`. 40 + */ 37 41 tag: string 38 42 authorHandle?: string 39 43 }>) { ··· 52 56 variables: optimisticRemove, 53 57 reset: resetRemove, 54 58 } = useRemoveMutedWordMutation() 59 + const displayTag = '#' + tag 55 60 56 - const sanitizedTag = tag.replace(/^#/, '') 57 61 const isMuted = Boolean( 58 62 (preferences?.mutedWords?.find( 59 - m => m.value === sanitizedTag && m.targets.includes('tag'), 63 + m => m.value === tag && m.targets.includes('tag'), 60 64 ) ?? 61 65 optimisticUpsert?.find( 62 - m => m.value === sanitizedTag && m.targets.includes('tag'), 66 + m => m.value === tag && m.targets.includes('tag'), 63 67 )) && 64 - !(optimisticRemove?.value === sanitizedTag), 68 + !(optimisticRemove?.value === tag), 65 69 ) 66 70 67 71 return ( ··· 71 75 <Dialog.Outer control={control}> 72 76 <Dialog.Handle /> 73 77 74 - <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> 78 + <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> 75 79 {isPreferencesLoading ? ( 76 80 <View style={[a.w_full, a.align_center]}> 77 81 <Loader size="lg" /> ··· 87 91 t.atoms.bg_contrast_25, 88 92 ]}> 89 93 <Link 90 - label={_(msg`Search for all posts with tag ${tag}`)} 91 - to={makeSearchLink({query: tag})} 94 + label={_(msg`Search for all posts with tag ${displayTag}`)} 95 + to={makeSearchLink({query: displayTag})} 92 96 onPress={e => { 93 97 e.preventDefault() 94 98 95 99 control.close(() => { 96 - // @ts-ignore :ron_swanson: "I know more than you" 97 - navigation.navigate('SearchTab', { 98 - screen: 'Search', 99 - params: { 100 - q: tag, 101 - }, 100 + navigation.push('Hashtag', { 101 + tag: tag.replaceAll('#', '%23'), 102 102 }) 103 103 }) 104 104 ··· 128 128 <Trans> 129 129 See{' '} 130 130 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 131 - {tag} 131 + {displayTag} 132 132 </Text>{' '} 133 133 posts 134 134 </Trans> ··· 142 142 143 143 <Link 144 144 label={_( 145 - msg`Search for all posts by @${authorHandle} with tag ${tag}`, 145 + msg`Search for all posts by @${authorHandle} with tag ${displayTag}`, 146 146 )} 147 - to={makeSearchLink({query: tag, from: authorHandle})} 147 + to={makeSearchLink({ 148 + query: displayTag, 149 + from: authorHandle, 150 + })} 148 151 onPress={e => { 149 152 e.preventDefault() 150 153 151 154 control.close(() => { 152 - // @ts-ignore :ron_swanson: "I know more than you" 153 - navigation.navigate('SearchTab', { 154 - screen: 'Search', 155 - params: { 156 - q: 157 - tag + 158 - (authorHandle ? ` from:${authorHandle}` : ''), 159 - }, 155 + navigation.push('Hashtag', { 156 + tag: tag.replaceAll('#', '%23'), 157 + author: authorHandle, 160 158 }) 161 159 }) 162 160 ··· 190 188 See{' '} 191 189 <Text 192 190 style={[a.text_md, a.font_bold, t.atoms.text]}> 193 - {tag} 191 + {displayTag} 194 192 </Text>{' '} 195 193 posts by this user 196 194 </Trans> ··· 207 205 <Button 208 206 label={ 209 207 isMuted 210 - ? _(msg`Unmute all ${tag} posts`) 211 - : _(msg`Mute all ${tag} posts`) 208 + ? _(msg`Unmute all ${displayTag} posts`) 209 + : _(msg`Mute all ${displayTag} posts`) 212 210 } 213 211 onPress={() => { 214 212 control.close(() => { ··· 250 248 ]}> 251 249 {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} 252 250 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 253 - {tag} 251 + {displayTag} 254 252 </Text>{' '} 255 253 <Trans>posts</Trans> 256 254 </Text>
+13 -12
src/components/TagMenu/index.web.tsx
··· 35 35 tag, 36 36 authorHandle, 37 37 }: React.PropsWithChildren<{ 38 + /** 39 + * This should be the sanitized tag value from the facet itself, not the 40 + * "display" value with a leading `#`. 41 + */ 38 42 tag: string 39 43 authorHandle?: string 40 44 }>) { 41 - const sanitizedTag = tag.replace(/^#/, '') 42 45 const {_} = useLingui() 43 46 const navigation = useNavigation<NavigationProp>() 44 47 const {data: preferences} = usePreferencesQuery() ··· 48 51 useRemoveMutedWordMutation() 49 52 const isMuted = Boolean( 50 53 (preferences?.mutedWords?.find( 51 - m => m.value === sanitizedTag && m.targets.includes('tag'), 54 + m => m.value === tag && m.targets.includes('tag'), 52 55 ) ?? 53 56 optimisticUpsert?.find( 54 - m => m.value === sanitizedTag && m.targets.includes('tag'), 57 + m => m.value === tag && m.targets.includes('tag'), 55 58 )) && 56 - !(optimisticRemove?.value === sanitizedTag), 59 + !(optimisticRemove?.value === tag), 57 60 ) 58 - const truncatedTag = enforceLen(tag, 15, true, 'middle') 61 + const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') 59 62 60 63 const dropdownItems = React.useMemo(() => { 61 64 return [ 62 65 { 63 66 label: _(msg`See ${truncatedTag} posts`), 64 67 onPress() { 65 - navigation.navigate('Search', { 66 - q: tag, 68 + navigation.push('Hashtag', { 69 + tag: tag.replaceAll('#', '%23'), 67 70 }) 68 71 }, 69 72 testID: 'tagMenuSearch', ··· 79 82 !isInvalidHandle(authorHandle) && { 80 83 label: _(msg`See ${truncatedTag} posts by user`), 81 84 onPress() { 82 - navigation.navigate({ 83 - name: 'Search', 84 - params: { 85 - q: tag + (authorHandle ? ` from:${authorHandle}` : ''), 86 - }, 85 + navigation.push('Hashtag', { 86 + tag: tag.replaceAll('#', '%23'), 87 + author: authorHandle, 87 88 }) 88 89 }, 89 90 testID: 'tagMenuSeachByUser',
+3
src/lib/routes/types.ts
··· 34 34 PreferencesThreads: undefined 35 35 PreferencesExternalEmbeds: undefined 36 36 Search: {q?: string} 37 + Hashtag: {tag: string; author?: string} 37 38 } 38 39 39 40 export type BottomTabNavigatorParams = CommonNavigatorParams & { ··· 69 70 Search: {q?: string} 70 71 Feeds: undefined 71 72 Notifications: undefined 73 + Hashtag: {tag: string; author?: string} 72 74 } 73 75 74 76 export type AllNavigatorParams = CommonNavigatorParams & { ··· 81 83 NotificationsTab: undefined 82 84 Notifications: undefined 83 85 MyProfileTab: undefined 86 + Hashtag: {tag: string; author?: string} 84 87 } 85 88 86 89 // NOTE
+1
src/routes.ts
··· 33 33 TermsOfService: '/support/tos', 34 34 CommunityGuidelines: '/support/community-guidelines', 35 35 CopyrightPolicy: '/support/copyright', 36 + Hashtag: '/hashtag/:tag', 36 37 })
+157
src/screens/Hashtag.tsx
··· 1 + import React from 'react' 2 + import {ListRenderItemInfo, Pressable} from 'react-native' 3 + import {atoms as a} from '#/alf' 4 + import {useFocusEffect} from '@react-navigation/native' 5 + import {useSetMinimalShellMode} from 'state/shell' 6 + import {ViewHeader} from 'view/com/util/ViewHeader' 7 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 + import {CommonNavigatorParams} from 'lib/routes/types' 9 + import {useSearchPostsQuery} from 'state/queries/search-posts' 10 + import {Post} from 'view/com/post/Post' 11 + import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 12 + import {enforceLen} from 'lib/strings/helpers' 13 + import { 14 + ListFooter, 15 + ListHeaderDesktop, 16 + ListMaybePlaceholder, 17 + } from '#/components/Lists' 18 + import {List} from 'view/com/util/List' 19 + import {msg} from '@lingui/macro' 20 + import {useLingui} from '@lingui/react' 21 + import {sanitizeHandle} from 'lib/strings/handles' 22 + import {CenteredView} from 'view/com/util/Views' 23 + import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox' 24 + import {shareUrl} from 'lib/sharing' 25 + import {HITSLOP_10} from 'lib/constants' 26 + 27 + const renderItem = ({item}: ListRenderItemInfo<PostView>) => { 28 + return <Post post={item} /> 29 + } 30 + 31 + const keyExtractor = (item: PostView, index: number) => { 32 + return `${item.uri}-${index}` 33 + } 34 + 35 + export default function HashtagScreen({ 36 + route, 37 + }: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { 38 + const {tag, author} = route.params 39 + const setMinimalShellMode = useSetMinimalShellMode() 40 + const {_} = useLingui() 41 + const [isPTR, setIsPTR] = React.useState(false) 42 + 43 + const fullTag = React.useMemo(() => { 44 + return `#${tag.replaceAll('%23', '#')}` 45 + }, [tag]) 46 + 47 + const queryParam = React.useMemo(() => { 48 + if (!author) return fullTag 49 + return `${fullTag} from:${sanitizeHandle(author)}` 50 + }, [fullTag, author]) 51 + 52 + const headerTitle = React.useMemo(() => { 53 + return enforceLen(fullTag.toLowerCase(), 24, true, 'middle') 54 + }, [fullTag]) 55 + 56 + const sanitizedAuthor = React.useMemo(() => { 57 + if (!author) return 58 + return sanitizeHandle(author) 59 + }, [author]) 60 + 61 + const { 62 + data, 63 + isFetching, 64 + isLoading, 65 + isRefetching, 66 + isError, 67 + error, 68 + refetch, 69 + fetchNextPage, 70 + hasNextPage, 71 + } = useSearchPostsQuery({query: queryParam}) 72 + 73 + const posts = React.useMemo(() => { 74 + return data?.pages.flatMap(page => page.posts) || [] 75 + }, [data]) 76 + 77 + useFocusEffect( 78 + React.useCallback(() => { 79 + setMinimalShellMode(false) 80 + }, [setMinimalShellMode]), 81 + ) 82 + 83 + const onShare = React.useCallback(() => { 84 + const url = new URL('https://bsky.app') 85 + url.pathname = `/hashtag/${tag}` 86 + if (author) { 87 + url.searchParams.set('author', author) 88 + } 89 + shareUrl(url.toString()) 90 + }, [tag, author]) 91 + 92 + const onRefresh = React.useCallback(async () => { 93 + setIsPTR(true) 94 + await refetch() 95 + setIsPTR(false) 96 + }, [refetch]) 97 + 98 + const onEndReached = React.useCallback(() => { 99 + if (isFetching || !hasNextPage || error) return 100 + fetchNextPage() 101 + }, [isFetching, hasNextPage, error, fetchNextPage]) 102 + 103 + return ( 104 + <CenteredView style={a.flex_1}> 105 + <ViewHeader 106 + title={headerTitle} 107 + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} 108 + canGoBack={true} 109 + renderButton={() => ( 110 + <Pressable 111 + accessibilityRole="button" 112 + onPress={onShare} 113 + hitSlop={HITSLOP_10}> 114 + <ArrowOutOfBox_Stroke2_Corner0_Rounded 115 + size="lg" 116 + onPress={onShare} 117 + /> 118 + </Pressable> 119 + )} 120 + /> 121 + <ListMaybePlaceholder 122 + isLoading={isLoading || isRefetching} 123 + isError={isError} 124 + isEmpty={posts.length < 1} 125 + onRetry={refetch} 126 + empty={_(msg`We couldn't find any results for that hashtag.`)} 127 + /> 128 + {!isLoading && posts.length > 0 && ( 129 + <List<PostView> 130 + data={posts} 131 + renderItem={renderItem} 132 + keyExtractor={keyExtractor} 133 + refreshing={isPTR} 134 + onRefresh={onRefresh} 135 + onEndReached={onEndReached} 136 + onEndReachedThreshold={4} 137 + // @ts-ignore web only -prf 138 + desktopFixedHeight 139 + ListHeaderComponent={ 140 + <ListHeaderDesktop 141 + title={headerTitle} 142 + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} 143 + /> 144 + } 145 + ListFooterComponent={ 146 + <ListFooter 147 + isFetching={isFetching && !isRefetching} 148 + isError={isError} 149 + error={error?.name} 150 + onRetry={fetchNextPage} 151 + /> 152 + } 153 + /> 154 + )} 155 + </CenteredView> 156 + ) 157 + }
+60 -37
src/view/com/util/ViewHeader.tsx
··· 13 13 import {useSetDrawerOpen} from '#/state/shell' 14 14 import {msg} from '@lingui/macro' 15 15 import {useLingui} from '@lingui/react' 16 + import {useTheme} from '#/alf' 16 17 17 18 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} 18 19 19 20 export function ViewHeader({ 20 21 title, 22 + subtitle, 21 23 canGoBack, 22 24 showBackButton = true, 23 25 hideOnScroll, ··· 26 28 renderButton, 27 29 }: { 28 30 title: string 31 + subtitle?: string 29 32 canGoBack?: boolean 30 33 showBackButton?: boolean 31 34 hideOnScroll?: boolean ··· 39 42 const navigation = useNavigation<NavigationProp>() 40 43 const {track} = useAnalytics() 41 44 const {isDesktop, isTablet} = useWebMediaQueries() 45 + const t = useTheme() 42 46 43 47 const onPressBack = React.useCallback(() => { 44 48 if (navigation.canGoBack()) { ··· 71 75 72 76 return ( 73 77 <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}> 74 - {showBackButton ? ( 75 - <TouchableOpacity 76 - testID="viewHeaderDrawerBtn" 77 - onPress={canGoBack ? onPressBack : onPressMenu} 78 - hitSlop={BACK_HITSLOP} 79 - style={canGoBack ? styles.backBtn : styles.backBtnWide} 80 - accessibilityRole="button" 81 - accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} 82 - accessibilityHint={ 83 - canGoBack ? '' : _(msg`Access navigation links and settings`) 84 - }> 85 - {canGoBack ? ( 86 - <FontAwesomeIcon 87 - size={18} 88 - icon="angle-left" 89 - style={[styles.backIcon, pal.text]} 90 - /> 91 - ) : !isTablet ? ( 92 - <FontAwesomeIcon 93 - size={18} 94 - icon="bars" 95 - style={[styles.backIcon, pal.textLight]} 96 - /> 78 + <View style={{flex: 1}}> 79 + <View style={{flexDirection: 'row', alignItems: 'center'}}> 80 + {showBackButton ? ( 81 + <TouchableOpacity 82 + testID="viewHeaderDrawerBtn" 83 + onPress={canGoBack ? onPressBack : onPressMenu} 84 + hitSlop={BACK_HITSLOP} 85 + style={canGoBack ? styles.backBtn : styles.backBtnWide} 86 + accessibilityRole="button" 87 + accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} 88 + accessibilityHint={ 89 + canGoBack ? '' : _(msg`Access navigation links and settings`) 90 + }> 91 + {canGoBack ? ( 92 + <FontAwesomeIcon 93 + size={18} 94 + icon="angle-left" 95 + style={[styles.backIcon, pal.text]} 96 + /> 97 + ) : !isTablet ? ( 98 + <FontAwesomeIcon 99 + size={18} 100 + icon="bars" 101 + style={[styles.backIcon, pal.textLight]} 102 + /> 103 + ) : null} 104 + </TouchableOpacity> 105 + ) : null} 106 + <View style={styles.titleContainer} pointerEvents="none"> 107 + <Text type="title" style={[pal.text, styles.title]}> 108 + {title} 109 + </Text> 110 + </View> 111 + {renderButton ? ( 112 + renderButton() 113 + ) : showBackButton ? ( 114 + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> 97 115 ) : null} 98 - </TouchableOpacity> 99 - ) : null} 100 - <View style={styles.titleContainer} pointerEvents="none"> 101 - <Text type="title" style={[pal.text, styles.title]}> 102 - {title} 103 - </Text> 116 + </View> 117 + {subtitle ? ( 118 + <View 119 + style={[styles.titleContainer, {marginTop: -3}]} 120 + pointerEvents="none"> 121 + <Text 122 + style={[ 123 + pal.text, 124 + styles.subtitle, 125 + t.atoms.text_contrast_medium, 126 + ]}> 127 + {subtitle} 128 + </Text> 129 + </View> 130 + ) : undefined} 104 131 </View> 105 - {renderButton ? ( 106 - renderButton() 107 - ) : showBackButton ? ( 108 - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> 109 - ) : null} 110 132 </Container> 111 133 ) 112 134 } ··· 185 207 const styles = StyleSheet.create({ 186 208 header: { 187 209 flexDirection: 'row', 188 - alignItems: 'center', 189 210 paddingHorizontal: 12, 190 211 paddingVertical: 6, 191 212 width: '100%', ··· 207 228 titleContainer: { 208 229 marginLeft: 'auto', 209 230 marginRight: 'auto', 210 - paddingRight: 10, 231 + alignItems: 'center', 211 232 }, 212 233 title: { 213 234 fontWeight: 'bold', 214 235 }, 215 - 236 + subtitle: { 237 + fontSize: 13, 238 + }, 216 239 backBtn: { 217 240 width: 30, 218 241 height: 30,