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>
zio/stable
Hailey 2024-02-29 17:56:29 -08:00 committed by GitHub
parent 8900c67df2
commit cf8b03801f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 502 additions and 81 deletions

View File

@ -180,6 +180,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)

View File

@ -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`)}}
/>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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

View File

@ -33,4 +33,5 @@ export const router = new Router({
TermsOfService: '/support/tos',
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
})

View File

@ -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>
)
}

View File

@ -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,