From cecb6e4e69eaccedf56f503589a59044fd7c6d19 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Thu, 29 Feb 2024 19:30:30 -0600
Subject: Bump API SDK, add validation to MutedWords (#3055)

* Bump API SDK, add validation to MutedWords

* Tweaks to error state

* Comment

* Early return
---
 src/components/TagMenu/index.tsx | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

(limited to 'src/components/TagMenu/index.tsx')

diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 2fec7a188..c18c0d6a2 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -215,14 +215,12 @@ export function TagMenu({
                           if (isMuted) {
                             resetUpsert()
                             removeMutedWord({
-                              value: sanitizedTag,
+                              value: tag,
                               targets: ['tag'],
                             })
                           } else {
                             resetRemove()
-                            upsertMutedWord([
-                              {value: sanitizedTag, targets: ['tag']},
-                            ])
+                            upsertMutedWord([{value: tag, targets: ['tag']}])
                           }
                         })
                       }}>
-- 
cgit 1.4.1


From cf8b03801fd8e98bcd2da4d099a9dfbf5876de7d Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Thu, 29 Feb 2024 17:56:29 -0800
Subject: 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>
---
 bskyweb/cmd/bskyweb/server.go        |   1 +
 src/Navigation.tsx                   |   6 +
 src/components/Lists.tsx             | 228 +++++++++++++++++++++++++++++++++++
 src/components/RichText.tsx          |  11 +-
 src/components/TagMenu/index.tsx     |  54 ++++-----
 src/components/TagMenu/index.web.tsx |  25 ++--
 src/lib/routes/types.ts              |   3 +
 src/routes.ts                        |   1 +
 src/screens/Hashtag.tsx              | 157 ++++++++++++++++++++++++
 src/view/com/util/ViewHeader.tsx     |  97 +++++++++------
 10 files changed, 502 insertions(+), 81 deletions(-)
 create mode 100644 src/components/Lists.tsx
 create mode 100644 src/screens/Hashtag.tsx

(limited to 'src/components/TagMenu/index.tsx')

diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index f13d568b7..6b76acc94 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -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)
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,
-- 
cgit 1.4.1

