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>
---
 src/screens/Hashtag.tsx | 157 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 157 insertions(+)
 create mode 100644 src/screens/Hashtag.tsx

(limited to 'src/screens/Hashtag.tsx')

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>
+  )
+}
-- 
cgit 1.4.1


From 82655f2ee3c208c2a0ab148dba1bccf884213375 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Fri, 1 Mar 2024 15:47:59 -0800
Subject: Few list tweaks on web (#3062)

* share button only on native

* update gttablet to be 1300px

* improve web layout

* change re-layout to mobile breakpoint

* adjustable not found reason

* don't show the borders on mobile web

* slight padding for the spinner
---
 src/alf/index.tsx        |  2 +-
 src/components/Lists.tsx | 28 +++++++++++++++++++++++-----
 src/screens/Hashtag.tsx  | 35 +++++++++++++++++++++--------------
 3 files changed, 45 insertions(+), 20 deletions(-)

(limited to 'src/screens/Hashtag.tsx')

diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 06d6ebf01..27738e91d 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -17,7 +17,7 @@ const breakpoints: {
   [key: string]: number
 } = {
   gtMobile: 800,
-  gtTablet: 1200,
+  gtTablet: 1300,
 }
 function getActiveBreakpoints({width}: {width: number}) {
   const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index cf00734f0..12a935807 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -9,6 +9,7 @@ import {Text} from '#/components/Typography'
 import {StackActions} from '@react-navigation/native'
 import {useNavigation} from '@react-navigation/core'
 import {NavigationProp} from 'lib/routes/types'
+import {router} from '#/routes'
 
 export function ListFooter({
   isFetching,
@@ -30,6 +31,7 @@ export function ListFooter({
         a.align_center,
         a.justify_center,
         a.border_t,
+        a.pb_lg,
         t.atoms.border_contrast_low,
         {height: 100},
       ]}>
@@ -128,6 +130,7 @@ export function ListMaybePlaceholder({
   isError,
   empty,
   error,
+  notFoundType = 'page',
   onRetry,
 }: {
   isLoading: boolean
@@ -135,10 +138,12 @@ export function ListMaybePlaceholder({
   isError: boolean
   empty?: string
   error?: string
+  notFoundType?: 'page' | 'results'
   onRetry?: () => Promise<unknown>
 }) {
   const navigation = useNavigation<NavigationProp>()
   const t = useTheme()
+  const {gtMobile} = useBreakpoints()
 
   const canGoBack = navigation.canGoBack()
   const onGoBack = React.useCallback(() => {
@@ -146,7 +151,14 @@ export function ListMaybePlaceholder({
       navigation.goBack()
     } else {
       navigation.navigate('HomeTab')
-      navigation.dispatch(StackActions.popToTop())
+
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
     }
   }, [navigation, canGoBack])
 
@@ -157,8 +169,7 @@ export function ListMaybePlaceholder({
       style={[
         a.flex_1,
         a.align_center,
-        a.border_t,
-        a.justify_between,
+        !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl,
         t.atoms.border_contrast_low,
         {paddingTop: 175, paddingBottom: 110},
       ]}>
@@ -173,7 +184,13 @@ export function ListMaybePlaceholder({
               {isError ? (
                 <Trans>Oops!</Trans>
               ) : isEmpty ? (
-                <Trans>Page not found</Trans>
+                <>
+                  {notFoundType === 'results' ? (
+                    <Trans>No results found</Trans>
+                  ) : (
+                    <Trans>Page not found</Trans>
+                  )}
+                </>
               ) : undefined}
             </Text>
 
@@ -195,7 +212,8 @@ export function ListMaybePlaceholder({
               </Text>
             ) : undefined}
           </View>
-          <View style={[a.w_full, a.px_lg, a.gap_md]}>
+          <View
+            style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
             {isError && onRetry && (
               <Button
                 variant="solid"
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
index 794753ea3..09a1f2824 100644
--- a/src/screens/Hashtag.tsx
+++ b/src/screens/Hashtag.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {ListRenderItemInfo, Pressable} from 'react-native'
-import {atoms as a} from '#/alf'
+import {atoms as a, useBreakpoints} from '#/alf'
 import {useFocusEffect} from '@react-navigation/native'
 import {useSetMinimalShellMode} from 'state/shell'
 import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -23,6 +23,7 @@ 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'
+import {isNative} from 'platform/detection'
 
 const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
   return <Post post={item} />
@@ -37,6 +38,7 @@ export default function HashtagScreen({
 }: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
   const {tag, author} = route.params
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {gtMobile} = useBreakpoints()
   const {_} = useLingui()
   const [isPTR, setIsPTR] = React.useState(false)
 
@@ -101,28 +103,33 @@ export default function HashtagScreen({
   }, [isFetching, hasNextPage, error, fetchNextPage])
 
   return (
-    <CenteredView style={a.flex_1}>
+    <CenteredView style={a.flex_1} sideBorders={gtMobile}>
       <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>
-        )}
+        canGoBack
+        renderButton={
+          isNative
+            ? () => (
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={onShare}
+                  hitSlop={HITSLOP_10}>
+                  <ArrowOutOfBox_Stroke2_Corner0_Rounded
+                    size="lg"
+                    onPress={onShare}
+                  />
+                </Pressable>
+              )
+            : undefined
+        }
       />
       <ListMaybePlaceholder
         isLoading={isLoading || isRefetching}
         isError={isError}
         isEmpty={posts.length < 1}
         onRetry={refetch}
+        notFoundType="results"
         empty={_(msg`We couldn't find any results for that hashtag.`)}
       />
       {!isLoading && posts.length > 0 && (
-- 
cgit 1.4.1

