about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/notifications/Feed.tsx5
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx8
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx13
-rw-r--r--src/view/com/post-thread/PostThread.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx9
-rw-r--r--src/view/com/post/Post.tsx22
-rw-r--r--src/view/com/posts/FeedItem.tsx57
-rw-r--r--src/view/com/posts/FeedSlice.tsx2
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx2
-rw-r--r--src/view/com/posts/MultiFeed.tsx256
-rw-r--r--src/view/com/util/Link.tsx15
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx54
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx105
-rw-r--r--src/view/com/util/UserAvatar.tsx2
-rw-r--r--src/view/com/util/forms/SearchInput.tsx104
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx87
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.web.tsx109
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtnMobile.tsx69
18 files changed, 418 insertions, 503 deletions
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index d457d7136..4ca22282d 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -21,11 +21,13 @@ export const Feed = observer(function Feed({
   scrollElRef,
   onPressTryAgain,
   onScroll,
+  ListHeaderComponent,
 }: {
   view: NotificationsFeedModel
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
+  ListHeaderComponent?: () => JSX.Element
 }) {
   const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
@@ -142,6 +144,7 @@ export const Feed = observer(function Feed({
           data={data}
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
+          ListHeaderComponent={ListHeaderComponent}
           ListFooterComponent={FeedFooter}
           refreshControl={
             <RefreshControl
@@ -156,6 +159,8 @@ export const Feed = observer(function Feed({
           onScroll={onScroll}
           scrollEventThrottle={100}
           contentContainerStyle={s.contentContainer}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
         />
       ) : null}
     </View>
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 0083e953b..02aa623cc 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -12,15 +12,17 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
-  const {isMobile} = useWebMediaQueries()
+  const {isMobile, isTablet} = useWebMediaQueries()
   if (isMobile) {
     return <FeedsTabBarMobile {...props} />
+  } else if (isTablet) {
+    return <FeedsTabBarTablet {...props} />
   } else {
-    return <FeedsTabBarDesktop {...props} />
+    return null
   }
 })
 
-const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
+const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const store = useStores()
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 5ce2906b3..30a712541 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -9,8 +9,8 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
-import {CogIcon} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 import {HITSLOP_10} from 'lib/constants'
 
@@ -67,12 +67,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
         </Text>
         <View style={[pal.view]}>
           <Link
-            href="/settings/saved-feeds"
+            href="/settings/home-feed"
             hitSlop={HITSLOP_10}
             accessibilityRole="button"
-            accessibilityLabel="Edit Saved Feeds"
-            accessibilityHint="Opens screen to edit Saved Feeds">
-            <CogIcon size={21} strokeWidth={2} style={pal.textLight} />
+            accessibilityLabel="Home Feed Preferences"
+            accessibilityHint="">
+            <FontAwesomeIcon
+              icon="sliders"
+              style={pal.textLight as FontAwesomeIconStyle}
+            />
           </Link>
         </View>
       </View>
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 7a5a45771..1cc177d17 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -357,6 +357,8 @@ export const PostThread = observer(function PostThread({
       }
       onScrollToIndexFailed={onScrollToIndexFailed}
       style={s.hContentRegion}
+      // @ts-ignore our .web version only -prf
+      desktopFixedHeight
     />
   )
 })
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5b5fee0ca..37c7ece47 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -483,15 +483,6 @@ export const PostThreadItem = observer(function PostThreadItem({
                   />
                 </ContentHider>
               )}
-              {needsTranslation && (
-                <View style={[pal.borderDark, styles.translateLink]}>
-                  <Link href={translatorUrl} title="Translate">
-                    <Text type="sm" style={pal.link}>
-                      Translate this post
-                    </Text>
-                  </Link>
-                </View>
-              )}
               <PostCtrls
                 itemUri={itemUri}
                 itemCid={itemCid}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 0855f25bf..d7559e3c4 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -1,4 +1,4 @@
-import React, {useState, useMemo} from 'react'
+import React, {useState} from 'react'
 import {
   ActivityIndicator,
   Linking,
@@ -28,7 +28,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
+import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 
 export const Post = observer(function PostImpl({
@@ -116,12 +116,6 @@ const PostLoaded = observer(function PostLoadedImpl({
   }
 
   const translatorUrl = getTranslatorLink(record?.text || '')
-  const needsTranslation = useMemo(
-    () =>
-      store.preferences.contentLanguages.length > 0 &&
-      !isPostInLanguage(item.post, store.preferences.contentLanguages),
-    [item.post, store.preferences.contentLanguages],
-  )
 
   const onPressReply = React.useCallback(() => {
     store.shell.openComposer({
@@ -256,15 +250,6 @@ const PostLoaded = observer(function PostLoadedImpl({
                 />
               </ContentHider>
             ) : null}
-            {needsTranslation && (
-              <View style={[pal.borderDark, styles.translateLink]}>
-                <Link href={translatorUrl} title="Translate">
-                  <Text type="sm" style={pal.link}>
-                    Translate this post
-                  </Text>
-                </Link>
-              </View>
-            )}
           </ContentHider>
           <PostCtrls
             itemUri={itemUri}
@@ -322,9 +307,6 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     flexWrap: 'wrap',
   },
-  translateLink: {
-    marginBottom: 12,
-  },
   replyLine: {
     position: 'absolute',
     left: 36,
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index bc7b7a7e6..59ab28d72 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,6 +8,7 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {PostsFeedItemModel} from 'state/models/feeds/post'
+import {FeedSourceInfo} from 'lib/api/feed/types'
 import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
@@ -26,17 +27,19 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
+import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {isEmbedByEmbedder} from 'lib/embeds'
 
 export const FeedItem = observer(function FeedItemImpl({
   item,
+  source,
   isThreadChild,
   isThreadLastChild,
   isThreadParent,
 }: {
   item: PostsFeedItemModel
+  source?: FeedSourceInfo
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
@@ -62,12 +65,6 @@ export const FeedItem = observer(function FeedItemImpl({
     return urip.hostname
   }, [record?.reply])
   const translatorUrl = getTranslatorLink(record?.text || '')
-  const needsTranslation = useMemo(
-    () =>
-      store.preferences.contentLanguages.length > 0 &&
-      !isPostInLanguage(item.post, store.preferences.contentLanguages),
-    [item.post, store.preferences.contentLanguages],
-  )
 
   const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
@@ -179,7 +176,27 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
 
         <View style={{paddingTop: 12}}>
-          {item.reasonRepost && (
+          {source ? (
+            <Link
+              title={sanitizeDisplayName(source.displayName)}
+              href={source.uri}>
+              <Text
+                type="sm-bold"
+                style={pal.textLight}
+                lineHeight={1.2}
+                numberOfLines={1}>
+                From{' '}
+                <DesktopWebTextLink
+                  type="sm-bold"
+                  style={pal.textLight}
+                  lineHeight={1.2}
+                  numberOfLines={1}
+                  text={sanitizeDisplayName(source.displayName)}
+                  href={source.uri}
+                />
+              </Text>
+            </Link>
+          ) : item.reasonRepost ? (
             <Link
               style={styles.includeReason}
               href={makeProfileLink(item.reasonRepost.by)}
@@ -188,10 +205,10 @@ export const FeedItem = observer(function FeedItemImpl({
               )}>
               <FontAwesomeIcon
                 icon="retweet"
-                style={[
-                  styles.includeReasonIcon,
-                  {color: pal.colors.textLight} as FontAwesomeIconStyle,
-                ]}
+                style={{
+                  marginRight: 4,
+                  color: pal.colors.textLight,
+                }}
               />
               <Text
                 type="sm-bold"
@@ -212,7 +229,7 @@ export const FeedItem = observer(function FeedItemImpl({
                 />
               </Text>
             </Link>
-          )}
+          ) : null}
         </View>
       </View>
 
@@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({
                 />
               </ContentHider>
             ) : null}
-            {needsTranslation && (
-              <View style={[pal.borderDark, styles.translateLink]}>
-                <Link href={translatorUrl} title="Translate">
-                  <Text type="sm" style={pal.link}>
-                    Translate this post
-                  </Text>
-                </Link>
-              </View>
-            )}
           </ContentHider>
           <PostCtrls
             itemUri={itemUri}
@@ -362,12 +370,9 @@ const styles = StyleSheet.create({
   includeReason: {
     flexDirection: 'row',
     marginTop: 2,
-    marginBottom: 4,
+    marginBottom: 2,
     marginLeft: -20,
   },
-  includeReasonIcon: {
-    marginRight: 4,
-  },
   layout: {
     flexDirection: 'row',
     marginTop: 1,
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 47313ee27..1d26f6cbd 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -28,6 +28,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
         <FeedItem
           key={slice.items[0]._reactKey}
           item={slice.items[0]}
+          source={slice.source}
           isThreadParent={slice.isThreadParentAt(0)}
           isThreadChild={slice.isThreadChildAt(0)}
         />
@@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
         <FeedItem
           key={item._reactKey}
           item={item}
+          source={i === 0 ? slice.source : undefined}
           isThreadParent={slice.isThreadParentAt(i)}
           isThreadChild={slice.isThreadChildAt(i)}
           isThreadLastChild={
diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
index 4491b2526..a73ffb68b 100644
--- a/src/view/com/posts/FollowingEmptyState.tsx
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -28,7 +28,7 @@ export function FollowingEmptyState() {
   }, [navigation])
 
   const onPressDiscoverFeeds = React.useCallback(() => {
-    navigation.navigate('DiscoverFeeds')
+    navigation.navigate('Feeds')
   }, [navigation])
 
   return (
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
deleted file mode 100644
index 9c8f4f246..000000000
--- a/src/view/com/posts/MultiFeed.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
-import {
-  ActivityIndicator,
-  RefreshControl,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FlatList} from '../util/Views'
-import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed'
-import {FeedSlice} from './FeedSlice'
-import {Text} from '../util/text/Text'
-import {Link} from '../util/Link'
-import {UserAvatar} from '../util/UserAvatar'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
-import {s} from 'lib/styles'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {CogIcon} from 'lib/icons'
-
-export const MultiFeed = observer(function Feed({
-  multifeed,
-  style,
-  scrollElRef,
-  onScroll,
-  scrollEventThrottle,
-  testID,
-  headerOffset = 0,
-  extraData,
-}: {
-  multifeed: PostsMultiFeedModel
-  style?: StyleProp<ViewStyle>
-  scrollElRef?: MutableRefObject<FlatList<any> | null>
-  onPressTryAgain?: () => void
-  onScroll?: OnScrollCb
-  scrollEventThrottle?: number
-  renderEmptyState?: () => JSX.Element
-  testID?: string
-  headerOffset?: number
-  extraData?: any
-}) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {isMobile} = useWebMediaQueries()
-  const {track} = useAnalytics()
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
-
-  // events
-  // =
-
-  const onRefresh = React.useCallback(async () => {
-    track('MultiFeed:onRefresh')
-    setIsRefreshing(true)
-    try {
-      await multifeed.refresh()
-    } catch (err) {
-      multifeed.rootStore.log.error('Failed to refresh posts feed', err)
-    }
-    setIsRefreshing(false)
-  }, [multifeed, track, setIsRefreshing])
-
-  const onEndReached = React.useCallback(async () => {
-    track('MultiFeed:onEndReached')
-    try {
-      await multifeed.loadMore()
-    } catch (err) {
-      multifeed.rootStore.log.error('Failed to load more posts', err)
-    }
-  }, [multifeed, track])
-
-  // rendering
-  // =
-
-  const renderItem = React.useCallback(
-    ({item}: {item: MultiFeedItem}) => {
-      if (item.type === 'header') {
-        if (!isMobile) {
-          return (
-            <>
-              <View style={[pal.view, pal.border, styles.headerDesktop]}>
-                <Text type="2xl-bold" style={pal.text}>
-                  My Feeds
-                </Text>
-                <Link href="/settings/saved-feeds">
-                  <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
-                </Link>
-              </View>
-              <DiscoverLink />
-            </>
-          )
-        }
-        return (
-          <>
-            <View style={[styles.header, pal.border]} />
-            <DiscoverLink />
-          </>
-        )
-      } else if (item.type === 'feed-header') {
-        return (
-          <View style={styles.feedHeader}>
-            <UserAvatar type="algo" avatar={item.avatar} size={28} />
-            <Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}>
-              {item.title}
-            </Text>
-          </View>
-        )
-      } else if (item.type === 'feed-slice') {
-        return <FeedSlice slice={item.slice} />
-      } else if (item.type === 'feed-loading') {
-        return <PostFeedLoadingPlaceholder />
-      } else if (item.type === 'feed-error') {
-        return <ErrorMessage message={item.error} />
-      } else if (item.type === 'feed-footer') {
-        return (
-          <Link
-            href={item.uri}
-            style={[styles.feedFooter, pal.border, pal.view]}>
-            <Text type="lg" style={pal.link}>
-              See more from {item.title}
-            </Text>
-            <FontAwesomeIcon
-              icon="angle-right"
-              size={18}
-              color={pal.colors.link}
-            />
-          </Link>
-        )
-      } else if (item.type === 'footer') {
-        return <DiscoverLink />
-      }
-      return null
-    },
-    [pal, isMobile],
-  )
-
-  const ListFooter = React.useCallback(
-    () =>
-      multifeed.isLoading && !isRefreshing ? (
-        <View style={styles.loadMore}>
-          <ActivityIndicator color={pal.colors.text} />
-        </View>
-      ) : (
-        <View />
-      ),
-    [multifeed.isLoading, isRefreshing, pal],
-  )
-
-  return (
-    <View testID={testID} style={style}>
-      {multifeed.items.length > 0 && (
-        <FlatList
-          testID={testID ? `${testID}-flatlist` : undefined}
-          ref={scrollElRef}
-          data={multifeed.items}
-          keyExtractor={item => item._reactKey}
-          renderItem={renderItem}
-          ListFooterComponent={ListFooter}
-          refreshControl={
-            <RefreshControl
-              refreshing={isRefreshing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-              progressViewOffset={headerOffset}
-            />
-          }
-          contentContainerStyle={s.contentContainer}
-          style={[{paddingTop: headerOffset}, pal.view, styles.container]}
-          onScroll={onScroll}
-          scrollEventThrottle={scrollEventThrottle}
-          indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
-          removeClippedSubviews={true}
-          contentOffset={{x: 0, y: headerOffset * -1}}
-          extraData={extraData}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      )}
-    </View>
-  )
-})
-
-function DiscoverLink() {
-  const pal = usePalette('default')
-  return (
-    <Link style={[styles.discoverLink, pal.viewLight]} href="/search/feeds">
-      <FontAwesomeIcon icon="search" size={18} color={pal.colors.text} />
-      <Text type="xl-medium" style={pal.text}>
-        Discover new feeds
-      </Text>
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    height: '100%',
-  },
-  header: {
-    borderTopWidth: 1,
-    marginBottom: 4,
-  },
-  headerDesktop: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-    borderBottomWidth: 1,
-    marginBottom: 4,
-    paddingHorizontal: 16,
-    paddingVertical: 8,
-  },
-  feedHeader: {
-    flexDirection: 'row',
-    gap: 8,
-    alignItems: 'center',
-    paddingHorizontal: 16,
-    paddingBottom: 8,
-    marginTop: 12,
-  },
-  feedHeaderTitle: {
-    fontWeight: 'bold',
-  },
-  feedFooter: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    alignItems: 'center',
-    paddingHorizontal: 16,
-    paddingVertical: 16,
-    marginBottom: 12,
-    borderTopWidth: 1,
-    borderBottomWidth: 1,
-  },
-  discoverLink: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 8,
-    paddingHorizontal: 14,
-    paddingVertical: 12,
-    marginHorizontal: 8,
-    marginVertical: 8,
-    gap: 8,
-  },
-  loadMore: {
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 321b6ab63..d4df2bec4 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -26,6 +26,7 @@ import {useStores, RootStoreModel} from 'state/index'
 import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
 import {isAndroid, isDesktopWeb} from 'platform/detection'
 import {sanitizeUrl} from '@braintree/sanitize-url'
+import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 
 type Event =
@@ -38,6 +39,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   href?: string
   title?: string
   children?: React.ReactNode
+  hoverStyle?: StyleProp<ViewStyle>
   noFeedback?: boolean
   asAnchor?: boolean
   anchorNoUnderline?: boolean
@@ -112,8 +114,9 @@ export const Link = observer(function Link({
     props.accessibilityLabel = title
   }
 
+  const Com = props.hoverStyle ? PressableWithHover : Pressable
   return (
-    <Pressable
+    <Com
       testID={testID}
       style={style}
       onPress={onPress}
@@ -123,7 +126,7 @@ export const Link = observer(function Link({
       href={asAnchor ? sanitizeUrl(href) : undefined}
       {...props}>
       {children ? children : <Text>{title || 'link'}</Text>}
-    </Pressable>
+    </Com>
   )
 })
 
@@ -137,6 +140,7 @@ export const TextLink = observer(function TextLink({
   lineHeight,
   dataSet,
   title,
+  onPress,
 }: {
   testID?: string
   type?: TypographyVariant
@@ -154,9 +158,14 @@ export const TextLink = observer(function TextLink({
 
   props.onPress = React.useCallback(
     (e?: Event) => {
+      if (onPress) {
+        e?.preventDefault?.()
+        // @ts-ignore function signature differs by platform -prf
+        return onPress()
+      }
       return onPressInner(store, navigation, sanitizeUrl(href), e)
     },
-    [store, navigation, href],
+    [onPress, store, navigation, href],
   )
   const hrefAttrs = useMemo(() => {
     const isExternal = isExternalUrl(href)
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index bf39fd50c..d7ab1be54 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -174,6 +174,60 @@ export function ProfileCardFeedLoadingPlaceholder() {
   )
 }
 
+export function FeedLoadingPlaceholder({
+  style,
+}: {
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[
+        {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1},
+        pal.border,
+        style,
+      ]}>
+      <View style={[pal.view, {flexDirection: 'row', marginBottom: 10}]}>
+        <LoadingPlaceholder
+          width={36}
+          height={36}
+          style={[styles.avatar, {borderRadius: 6}]}
+        />
+        <View style={[s.flex1]}>
+          <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
+          <LoadingPlaceholder width={120} height={8} />
+        </View>
+      </View>
+      <View style={{paddingHorizontal: 5}}>
+        <LoadingPlaceholder
+          width={260}
+          height={8}
+          style={{marginVertical: 12}}
+        />
+        <LoadingPlaceholder width={120} height={8} />
+      </View>
+    </View>
+  )
+}
+
+export function FeedFeedLoadingPlaceholder() {
+  return (
+    <>
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+    </>
+  )
+}
+
 const styles = StyleSheet.create({
   loadingPlaceholder: {
     borderRadius: 6,
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
new file mode 100644
index 000000000..4eff38a31
--- /dev/null
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {CenteredView} from './Views'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {NavigationProp} from 'lib/routes/types'
+
+const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
+
+export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
+  showBackButton = true,
+  style,
+  children,
+}: React.PropsWithChildren<{
+  showBackButton?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
+  const {track} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+  const canGoBack = navigation.canGoBack()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressMenu = React.useCallback(() => {
+    track('ViewHeader:MenuButtonClicked')
+    store.shell.openDrawer()
+  }, [track, store])
+
+  const Container = isMobile ? View : CenteredView
+  return (
+    <Container style={[styles.header, isMobile && styles.headerMobile, style]}>
+      {showBackButton ? (
+        <TouchableOpacity
+          testID="viewHeaderDrawerBtn"
+          onPress={canGoBack ? onPressBack : onPressMenu}
+          hitSlop={BACK_HITSLOP}
+          style={canGoBack ? styles.backBtn : styles.backBtnWide}
+          accessibilityRole="button"
+          accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
+          accessibilityHint="">
+          {canGoBack ? (
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              style={[styles.backIcon, pal.text]}
+            />
+          ) : (
+            <FontAwesomeIcon
+              size={18}
+              icon="bars"
+              style={[styles.backIcon, pal.textLight]}
+            />
+          )}
+        </TouchableOpacity>
+      ) : null}
+      {children}
+    </Container>
+  )
+})
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingVertical: 12,
+    width: '100%',
+  },
+  headerMobile: {
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+  },
+  backBtn: {
+    width: 30,
+    height: 30,
+  },
+  backBtnWide: {
+    width: 30,
+    height: 30,
+    paddingHorizontal: 6,
+  },
+  backIcon: {
+    marginTop: 6,
+  },
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 0f34f75aa..7a42ab4d3 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -118,7 +118,7 @@ export function UserAvatar({
       return {
         width: size,
         height: size,
-        borderRadius: 8,
+        borderRadius: size > 32 ? 8 : 3,
       }
     }
     return {
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
new file mode 100644
index 000000000..c1eb82bd4
--- /dev/null
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {MagnifyingGlassIcon} from 'lib/icons'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+
+interface Props {
+  query: string
+  setIsInputFocused?: (v: boolean) => void
+  onChangeQuery: (v: string) => void
+  onPressCancelSearch: () => void
+  onSubmitQuery: () => void
+  style?: StyleProp<ViewStyle>
+}
+export function SearchInput({
+  query,
+  setIsInputFocused,
+  onChangeQuery,
+  onPressCancelSearch,
+  onSubmitQuery,
+  style,
+}: Props) {
+  const theme = useTheme()
+  const pal = usePalette('default')
+  const textInput = React.useRef<TextInput>(null)
+
+  const onPressCancelSearchInner = React.useCallback(() => {
+    onPressCancelSearch()
+    textInput.current?.blur()
+  }, [onPressCancelSearch, textInput])
+
+  return (
+    <View style={[pal.viewLight, styles.container, style]}>
+      <MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
+      <TextInput
+        testID="searchTextInput"
+        ref={textInput}
+        placeholder="Search"
+        placeholderTextColor={pal.colors.textLight}
+        selectTextOnFocus
+        returnKeyType="search"
+        value={query}
+        style={[pal.text, styles.input]}
+        keyboardAppearance={theme.colorScheme}
+        onFocus={() => setIsInputFocused?.(true)}
+        onBlur={() => setIsInputFocused?.(false)}
+        onChangeText={onChangeQuery}
+        onSubmitEditing={onSubmitQuery}
+        accessibilityRole="search"
+        accessibilityLabel="Search"
+        accessibilityHint=""
+        autoCorrect={false}
+        autoCapitalize="none"
+      />
+      {query ? (
+        <TouchableOpacity
+          onPress={onPressCancelSearchInner}
+          accessibilityRole="button"
+          accessibilityLabel="Clear search query"
+          accessibilityHint="">
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={pal.textLight as FontAwesomeIconStyle}
+          />
+        </TouchableOpacity>
+      ) : undefined}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+  },
+  icon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  input: {
+    flex: 1,
+    fontSize: 17,
+    minWidth: 0, // overflow mitigation for firefox
+  },
+  cancelBtn: {
+    paddingLeft: 10,
+  },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index ae9cb9361..6b73edd4b 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1 +1,86 @@
-export * from './LoadLatestBtnMobile'
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {clamp} from 'lodash'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {colors} from 'lib/styles'
+import {HITSLOP_20} from 'lib/constants'
+
+export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
+  onPress,
+  label,
+  showIndicator,
+}: {
+  onPress: () => void
+  label: string
+  showIndicator: boolean
+  minimalShellMode?: boolean // NOTE not used on mobile -prf
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+  const safeAreaInsets = useSafeAreaInsets()
+  return (
+    <TouchableOpacity
+      style={[
+        styles.loadLatest,
+        isDesktop && styles.loadLatestDesktop,
+        isTablet && styles.loadLatestTablet,
+        pal.borderDark,
+        pal.view,
+        isMobile &&
+          !store.shell.minimalShellMode && {
+            bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
+          },
+      ]}
+      onPress={onPress}
+      hitSlop={HITSLOP_20}
+      accessibilityRole="button"
+      accessibilityLabel={label}
+      accessibilityHint="">
+      <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
+      {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  loadLatest: {
+    position: 'absolute',
+    left: 18,
+    bottom: 35,
+    borderWidth: 1,
+    width: 52,
+    height: 52,
+    borderRadius: 26,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  loadLatestTablet: {
+    // @ts-ignore web only
+    left: '50vw',
+    // @ts-ignore web only -prf
+    transform: 'translateX(-282px)',
+  },
+  loadLatestDesktop: {
+    // @ts-ignore web only
+    left: '50vw',
+    // @ts-ignore web only -prf
+    transform: 'translateX(-382px)',
+  },
+  indicator: {
+    position: 'absolute',
+    top: 3,
+    right: 3,
+    backgroundColor: colors.blue3,
+    width: 12,
+    height: 12,
+    borderRadius: 6,
+    borderWidth: 1,
+  },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
deleted file mode 100644
index 83c696f7e..000000000
--- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = ({
-  onPress,
-  label,
-  showIndicator,
-  minimalShellMode,
-}: {
-  onPress: () => void
-  label: string
-  showIndicator: boolean
-  minimalShellMode?: boolean
-}) => {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-  if (isMobile) {
-    return (
-      <LoadLatestBtnMobile
-        onPress={onPress}
-        label={label}
-        showIndicator={showIndicator}
-      />
-    )
-  }
-  return (
-    <>
-      {showIndicator && (
-        <TouchableOpacity
-          style={[
-            pal.view,
-            pal.borderDark,
-            styles.loadLatestCentered,
-            minimalShellMode && styles.loadLatestCenteredMinimal,
-          ]}
-          onPress={onPress}
-          hitSlop={HITSLOP_20}
-          accessibilityRole="button"
-          accessibilityLabel={label}
-          accessibilityHint="">
-          <Text type="md-bold" style={pal.text}>
-            {label}
-          </Text>
-        </TouchableOpacity>
-      )}
-      <TouchableOpacity
-        style={[pal.view, pal.borderDark, styles.loadLatest]}
-        onPress={onPress}
-        hitSlop={HITSLOP_20}
-        accessibilityRole="button"
-        accessibilityLabel={label}
-        accessibilityHint="">
-        <Text type="md-bold" style={pal.text}>
-          <FontAwesomeIcon
-            icon="angle-up"
-            size={21}
-            style={[pal.text, styles.icon]}
-          />
-        </Text>
-      </TouchableOpacity>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  loadLatest: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    position: 'absolute',
-    // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-282px)',
-    bottom: 40,
-    width: 54,
-    height: 54,
-    borderRadius: 30,
-    borderWidth: 1,
-  },
-  icon: {
-    position: 'relative',
-    top: 2,
-  },
-  loadLatestCentered: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    position: 'absolute',
-    // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-50%)',
-    top: 60,
-    paddingHorizontal: 24,
-    paddingVertical: 14,
-    borderRadius: 30,
-    borderWidth: 1,
-  },
-  loadLatestCenteredMinimal: {
-    top: 20,
-  },
-})
diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
deleted file mode 100644
index 3e8add5e9..000000000
--- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {clamp} from 'lodash'
-import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {colors} from 'lib/styles'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
-  onPress,
-  label,
-  showIndicator,
-}: {
-  onPress: () => void
-  label: string
-  showIndicator: boolean
-  minimalShellMode?: boolean // NOTE not used on mobile -prf
-}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const safeAreaInsets = useSafeAreaInsets()
-  return (
-    <TouchableOpacity
-      style={[
-        styles.loadLatest,
-        pal.borderDark,
-        pal.view,
-        !store.shell.minimalShellMode && {
-          bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
-        },
-      ]}
-      onPress={onPress}
-      hitSlop={HITSLOP_20}
-      accessibilityRole="button"
-      accessibilityLabel={label}
-      accessibilityHint="">
-      <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
-      {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
-    </TouchableOpacity>
-  )
-})
-
-const styles = StyleSheet.create({
-  loadLatest: {
-    position: 'absolute',
-    left: 18,
-    bottom: 35,
-    borderWidth: 1,
-    width: 52,
-    height: 52,
-    borderRadius: 26,
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  indicator: {
-    position: 'absolute',
-    top: 3,
-    right: 3,
-    backgroundColor: colors.blue3,
-    width: 12,
-    height: 12,
-    borderRadius: 6,
-    borderWidth: 1,
-  },
-})