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
parent
8900c67df2
commit
cf8b03801f
|
@ -180,6 +180,7 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/", server.WebHome)
|
e.GET("/", server.WebHome)
|
||||||
|
|
||||||
// generic routes
|
// generic routes
|
||||||
|
e.GET("/hashtag/:tag", server.WebGeneric)
|
||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGeneric)
|
||||||
e.GET("/feeds", server.WebGeneric)
|
e.GET("/feeds", server.WebGeneric)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGeneric)
|
||||||
|
|
|
@ -77,6 +77,7 @@ import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbed
|
||||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||||
|
import HashtagScreen from '#/screens/Hashtag'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
|
||||||
|
@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Hashtag"
|
||||||
|
getComponent={() => HashtagScreen}
|
||||||
|
options={{title: title(msg`Hashtag`)}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -120,6 +120,7 @@ export function RichText({
|
||||||
<RichTextTag
|
<RichTextTag
|
||||||
key={key}
|
key={key}
|
||||||
text={segment.text}
|
text={segment.text}
|
||||||
|
tag={tag.tag}
|
||||||
style={styles}
|
style={styles}
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
authorHandle={authorHandle}
|
authorHandle={authorHandle}
|
||||||
|
@ -145,12 +146,14 @@ export function RichText({
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichTextTag({
|
function RichTextTag({
|
||||||
text: tag,
|
text,
|
||||||
|
tag,
|
||||||
style,
|
style,
|
||||||
selectable,
|
selectable,
|
||||||
authorHandle,
|
authorHandle,
|
||||||
}: {
|
}: {
|
||||||
text: string
|
text: string
|
||||||
|
tag: string
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
authorHandle?: string
|
authorHandle?: string
|
||||||
} & TextStyleProp) {
|
} & TextStyleProp) {
|
||||||
|
@ -184,8 +187,8 @@ function RichTextTag({
|
||||||
<Text
|
<Text
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
{...native({
|
{...native({
|
||||||
accessibilityLabel: _(msg`Hashtag: ${tag}`),
|
accessibilityLabel: _(msg`Hashtag: #${tag}`),
|
||||||
accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
|
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
|
||||||
accessibilityRole: isNative ? 'button' : undefined,
|
accessibilityRole: isNative ? 'button' : undefined,
|
||||||
onPress: open,
|
onPress: open,
|
||||||
onPressIn: onPressIn,
|
onPressIn: onPressIn,
|
||||||
|
@ -213,7 +216,7 @@ function RichTextTag({
|
||||||
textDecorationColor: t.palette.primary_500,
|
textDecorationColor: t.palette.primary_500,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
{tag}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</TagMenu>
|
</TagMenu>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
@ -34,6 +34,10 @@ export function TagMenu({
|
||||||
authorHandle,
|
authorHandle,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
/**
|
||||||
|
* This should be the sanitized tag value from the facet itself, not the
|
||||||
|
* "display" value with a leading `#`.
|
||||||
|
*/
|
||||||
tag: string
|
tag: string
|
||||||
authorHandle?: string
|
authorHandle?: string
|
||||||
}>) {
|
}>) {
|
||||||
|
@ -52,16 +56,16 @@ export function TagMenu({
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordMutation()
|
||||||
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const sanitizedTag = tag.replace(/^#/, '')
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.mutedWords?.find(
|
(preferences?.mutedWords?.find(
|
||||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
) ??
|
) ??
|
||||||
optimisticUpsert?.find(
|
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 (
|
return (
|
||||||
|
@ -71,7 +75,7 @@ export function TagMenu({
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer control={control}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
|
|
||||||
<Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
|
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
|
||||||
{isPreferencesLoading ? (
|
{isPreferencesLoading ? (
|
||||||
<View style={[a.w_full, a.align_center]}>
|
<View style={[a.w_full, a.align_center]}>
|
||||||
<Loader size="lg" />
|
<Loader size="lg" />
|
||||||
|
@ -87,18 +91,14 @@ export function TagMenu({
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}>
|
]}>
|
||||||
<Link
|
<Link
|
||||||
label={_(msg`Search for all posts with tag ${tag}`)}
|
label={_(msg`Search for all posts with tag ${displayTag}`)}
|
||||||
to={makeSearchLink({query: tag})}
|
to={makeSearchLink({query: displayTag})}
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
// @ts-ignore :ron_swanson: "I know more than you"
|
navigation.push('Hashtag', {
|
||||||
navigation.navigate('SearchTab', {
|
tag: tag.replaceAll('#', '%23'),
|
||||||
screen: 'Search',
|
|
||||||
params: {
|
|
||||||
q: tag,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ export function TagMenu({
|
||||||
<Trans>
|
<Trans>
|
||||||
See{' '}
|
See{' '}
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
{tag}
|
{displayTag}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
posts
|
posts
|
||||||
</Trans>
|
</Trans>
|
||||||
|
@ -142,21 +142,19 @@ export function TagMenu({
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
label={_(
|
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 => {
|
onPress={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
// @ts-ignore :ron_swanson: "I know more than you"
|
navigation.push('Hashtag', {
|
||||||
navigation.navigate('SearchTab', {
|
tag: tag.replaceAll('#', '%23'),
|
||||||
screen: 'Search',
|
author: authorHandle,
|
||||||
params: {
|
|
||||||
q:
|
|
||||||
tag +
|
|
||||||
(authorHandle ? ` from:${authorHandle}` : ''),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -190,7 +188,7 @@ export function TagMenu({
|
||||||
See{' '}
|
See{' '}
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, t.atoms.text]}>
|
style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
{tag}
|
{displayTag}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
posts by this user
|
posts by this user
|
||||||
</Trans>
|
</Trans>
|
||||||
|
@ -207,8 +205,8 @@ export function TagMenu({
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
isMuted
|
isMuted
|
||||||
? _(msg`Unmute all ${tag} posts`)
|
? _(msg`Unmute all ${displayTag} posts`)
|
||||||
: _(msg`Mute all ${tag} posts`)
|
: _(msg`Mute all ${displayTag} posts`)
|
||||||
}
|
}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
|
@ -250,7 +248,7 @@ export function TagMenu({
|
||||||
]}>
|
]}>
|
||||||
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
{tag}
|
{displayTag}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
<Trans>posts</Trans>
|
<Trans>posts</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -35,10 +35,13 @@ export function TagMenu({
|
||||||
tag,
|
tag,
|
||||||
authorHandle,
|
authorHandle,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
|
/**
|
||||||
|
* This should be the sanitized tag value from the facet itself, not the
|
||||||
|
* "display" value with a leading `#`.
|
||||||
|
*/
|
||||||
tag: string
|
tag: string
|
||||||
authorHandle?: string
|
authorHandle?: string
|
||||||
}>) {
|
}>) {
|
||||||
const sanitizedTag = tag.replace(/^#/, '')
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
@ -48,22 +51,22 @@ export function TagMenu({
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.mutedWords?.find(
|
(preferences?.mutedWords?.find(
|
||||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
) ??
|
) ??
|
||||||
optimisticUpsert?.find(
|
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(() => {
|
const dropdownItems = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: _(msg`See ${truncatedTag} posts`),
|
label: _(msg`See ${truncatedTag} posts`),
|
||||||
onPress() {
|
onPress() {
|
||||||
navigation.navigate('Search', {
|
navigation.push('Hashtag', {
|
||||||
q: tag,
|
tag: tag.replaceAll('#', '%23'),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
testID: 'tagMenuSearch',
|
testID: 'tagMenuSearch',
|
||||||
|
@ -79,11 +82,9 @@ export function TagMenu({
|
||||||
!isInvalidHandle(authorHandle) && {
|
!isInvalidHandle(authorHandle) && {
|
||||||
label: _(msg`See ${truncatedTag} posts by user`),
|
label: _(msg`See ${truncatedTag} posts by user`),
|
||||||
onPress() {
|
onPress() {
|
||||||
navigation.navigate({
|
navigation.push('Hashtag', {
|
||||||
name: 'Search',
|
tag: tag.replaceAll('#', '%23'),
|
||||||
params: {
|
author: authorHandle,
|
||||||
q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
testID: 'tagMenuSeachByUser',
|
testID: 'tagMenuSeachByUser',
|
||||||
|
|
|
@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
|
||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
|
Hashtag: {tag: string; author?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
@ -69,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
|
Hashtag: {tag: string; author?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
|
@ -81,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
|
Hashtag: {tag: string; author?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
|
|
|
@ -33,4 +33,5 @@ export const router = new Router({
|
||||||
TermsOfService: '/support/tos',
|
TermsOfService: '/support/tos',
|
||||||
CommunityGuidelines: '/support/community-guidelines',
|
CommunityGuidelines: '/support/community-guidelines',
|
||||||
CopyrightPolicy: '/support/copyright',
|
CopyrightPolicy: '/support/copyright',
|
||||||
|
Hashtag: '/hashtag/:tag',
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated'
|
||||||
import {useSetDrawerOpen} from '#/state/shell'
|
import {useSetDrawerOpen} from '#/state/shell'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
|
||||||
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
||||||
|
|
||||||
export function ViewHeader({
|
export function ViewHeader({
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
hideOnScroll,
|
hideOnScroll,
|
||||||
|
@ -26,6 +28,7 @@ export function ViewHeader({
|
||||||
renderButton,
|
renderButton,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
subtitle?: string
|
||||||
canGoBack?: boolean
|
canGoBack?: boolean
|
||||||
showBackButton?: boolean
|
showBackButton?: boolean
|
||||||
hideOnScroll?: boolean
|
hideOnScroll?: boolean
|
||||||
|
@ -39,6 +42,7 @@ export function ViewHeader({
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const onPressBack = React.useCallback(() => {
|
const onPressBack = React.useCallback(() => {
|
||||||
if (navigation.canGoBack()) {
|
if (navigation.canGoBack()) {
|
||||||
|
@ -71,42 +75,60 @@ export function ViewHeader({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
|
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
|
||||||
{showBackButton ? (
|
<View style={{flex: 1}}>
|
||||||
<TouchableOpacity
|
<View style={{flexDirection: 'row', alignItems: 'center'}}>
|
||||||
testID="viewHeaderDrawerBtn"
|
{showBackButton ? (
|
||||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
<TouchableOpacity
|
||||||
hitSlop={BACK_HITSLOP}
|
testID="viewHeaderDrawerBtn"
|
||||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||||
accessibilityRole="button"
|
hitSlop={BACK_HITSLOP}
|
||||||
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
|
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||||
accessibilityHint={
|
accessibilityRole="button"
|
||||||
canGoBack ? '' : _(msg`Access navigation links and settings`)
|
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
|
||||||
}>
|
accessibilityHint={
|
||||||
{canGoBack ? (
|
canGoBack ? '' : _(msg`Access navigation links and settings`)
|
||||||
<FontAwesomeIcon
|
}>
|
||||||
size={18}
|
{canGoBack ? (
|
||||||
icon="angle-left"
|
<FontAwesomeIcon
|
||||||
style={[styles.backIcon, pal.text]}
|
size={18}
|
||||||
/>
|
icon="angle-left"
|
||||||
) : !isTablet ? (
|
style={[styles.backIcon, pal.text]}
|
||||||
<FontAwesomeIcon
|
/>
|
||||||
size={18}
|
) : !isTablet ? (
|
||||||
icon="bars"
|
<FontAwesomeIcon
|
||||||
style={[styles.backIcon, pal.textLight]}
|
size={18}
|
||||||
/>
|
icon="bars"
|
||||||
|
style={[styles.backIcon, pal.textLight]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
</TouchableOpacity>
|
<View style={styles.titleContainer} pointerEvents="none">
|
||||||
) : null}
|
<Text type="title" style={[pal.text, styles.title]}>
|
||||||
<View style={styles.titleContainer} pointerEvents="none">
|
{title}
|
||||||
<Text type="title" style={[pal.text, styles.title]}>
|
</Text>
|
||||||
{title}
|
</View>
|
||||||
</Text>
|
{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>
|
</View>
|
||||||
{renderButton ? (
|
|
||||||
renderButton()
|
|
||||||
) : showBackButton ? (
|
|
||||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
|
||||||
) : null}
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -185,7 +207,6 @@ function Container({
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
@ -207,12 +228,14 @@ const styles = StyleSheet.create({
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
paddingRight: 10,
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
backBtn: {
|
backBtn: {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
|
|
Loading…
Reference in New Issue