about summary refs log tree commit diff
path: root/src/view/com/feeds
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/feeds')
-rw-r--r--src/view/com/feeds/FeedPage.tsx194
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx144
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx222
3 files changed, 439 insertions, 121 deletions
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 1037007b7..f3f07a8bd 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -4,74 +4,56 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {useIsFocused} from '@react-navigation/native'
 import {useAnalytics} from '@segment/analytics-react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {ComposeIcon2} from 'lib/icons'
 import {colors, s} from 'lib/styles'
-import {observer} from 'mobx-react-lite'
 import React from 'react'
-import {FlatList, View} from 'react-native'
-import {useStores} from 'state/index'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
+import {FlatList, View, useWindowDimensions} from 'react-native'
 import {Feed} from '../posts/Feed'
 import {TextLink} from '../util/Link'
 import {FAB} from '../util/fab/FAB'
 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
-import useAppState from 'react-native-appstate-hook'
-import {logger} from '#/logger'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
+import {listenSoftReset, emitSoftReset} from '#/state/events'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
-export const FeedPage = observer(function FeedPageImpl({
+const POLL_FREQ = 30e3 // 30sec
+
+export function FeedPage({
   testID,
   isPageFocused,
   feed,
+  feedParams,
   renderEmptyState,
   renderEndOfFeed,
 }: {
   testID?: string
-  feed: PostsFeedModel
+  feed: FeedDescriptor
+  feedParams?: FeedParams
   isPageFocused: boolean
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
 }) {
-  const store = useStores()
+  const {isSandbox, hasSession} = useSession()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
+  const queryClient = useQueryClient()
+  const {openComposer} = useComposerControls()
   const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
   const scrollElRef = React.useRef<FlatList>(null)
-  const {appState} = useAppState({
-    onForeground: () => doPoll(true),
-  })
   const isScreenFocused = useIsFocused()
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
-
-  React.useEffect(() => {
-    // called on first load
-    if (!feed.hasLoaded && isPageFocused) {
-      feed.setup()
-    }
-  }, [isPageFocused, feed])
-
-  const doPoll = React.useCallback(
-    (knownActive = false) => {
-      if (
-        (!knownActive && appState !== 'active') ||
-        !isScreenFocused ||
-        !isPageFocused
-      ) {
-        return
-      }
-      if (feed.isLoading) {
-        return
-      }
-      logger.debug('HomeScreen: Polling for new posts')
-      feed.checkForLatest()
-    },
-    [appState, isScreenFocused, isPageFocused, feed],
-  )
+  const [hasNew, setHasNew] = React.useState(false)
 
   const scrollToTop = React.useCallback(() => {
     scrollElRef.current?.scrollToOffset({offset: -headerOffset})
@@ -81,41 +63,30 @@ export const FeedPage = observer(function FeedPageImpl({
   const onSoftReset = React.useCallback(() => {
     if (isPageFocused) {
       scrollToTop()
-      feed.refresh()
+      truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+      setHasNew(false)
     }
-  }, [isPageFocused, scrollToTop, feed])
+  }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
 
   // fires when page within screen is activated/deactivated
-  // - check for latest
   React.useEffect(() => {
     if (!isPageFocused || !isScreenFocused) {
       return
     }
-
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const feedCleanup = feed.registerListeners()
-    const pollInterval = setInterval(doPoll, POLL_FREQ)
-
     screen('Feed')
-    logger.debug('HomeScreen: Updating feed')
-    feed.checkForLatest()
-
-    return () => {
-      clearInterval(pollInterval)
-      softResetSub.remove()
-      feedCleanup()
-    }
-  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
+    return listenSoftReset(onSoftReset)
+  }, [onSoftReset, screen, isPageFocused, isScreenFocused])
 
   const onPressCompose = React.useCallback(() => {
     track('HomeScreen:PressCompose')
-    store.shell.openComposer({})
-  }, [store, track])
+    openComposer({})
+  }, [openComposer, track])
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    feed.refresh()
-  }, [feed, scrollToTop])
+    truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+    setHasNew(false)
+  }, [scrollToTop, feed, queryClient, setHasNew])
 
   const ListHeaderComponent = React.useCallback(() => {
     if (isDesktop) {
@@ -137,7 +108,7 @@ export const FeedPage = observer(function FeedPageImpl({
             style={[pal.text, {fontWeight: 'bold'}]}
             text={
               <>
-                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
                 {hasNew && (
                   <View
                     style={{
@@ -151,35 +122,50 @@ export const FeedPage = observer(function FeedPageImpl({
                 )}
               </>
             }
-            onPress={() => store.emitScreenSoftReset()}
-          />
-          <TextLink
-            type="title-lg"
-            href="/settings/home-feed"
-            style={{fontWeight: 'bold'}}
-            accessibilityLabel="Feed Preferences"
-            accessibilityHint=""
-            text={
-              <FontAwesomeIcon
-                icon="sliders"
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            }
+            onPress={emitSoftReset}
           />
+          {hasSession && (
+            <TextLink
+              type="title-lg"
+              href="/settings/home-feed"
+              style={{fontWeight: 'bold'}}
+              accessibilityLabel={_(msg`Feed Preferences`)}
+              accessibilityHint=""
+              text={
+                <FontAwesomeIcon
+                  icon="sliders"
+                  style={pal.textLight as FontAwesomeIconStyle}
+                />
+              }
+            />
+          )}
         </View>
       )
     }
     return <></>
-  }, [isDesktop, pal, store, hasNew])
+  }, [
+    isDesktop,
+    pal.view,
+    pal.text,
+    pal.textLight,
+    hasNew,
+    _,
+    isSandbox,
+    hasSession,
+  ])
 
   return (
     <View testID={testID} style={s.h100pct}>
       <Feed
         testID={testID ? `${testID}-feed` : undefined}
+        enabled={isPageFocused}
         feed={feed}
+        feedParams={feedParams}
+        pollInterval={POLL_FREQ}
         scrollElRef={scrollElRef}
         onScroll={onMainScroll}
-        scrollEventThrottle={100}
+        onHasNew={setHasNew}
+        scrollEventThrottle={1}
         renderEmptyState={renderEmptyState}
         renderEndOfFeed={renderEndOfFeed}
         ListHeaderComponent={ListHeaderComponent}
@@ -188,18 +174,52 @@ export const FeedPage = observer(function FeedPageImpl({
       {(isScrolledDown || hasNew) && (
         <LoadLatestBtn
           onPress={onPressLoadLatest}
-          label="Load new posts"
+          label={_(msg`Load new posts`)}
           showIndicator={hasNew}
         />
       )}
-      <FAB
-        testID="composeFAB"
-        onPress={onPressCompose}
-        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-        accessibilityRole="button"
-        accessibilityLabel="New post"
-        accessibilityHint=""
-      />
+
+      {hasSession && (
+        <FAB
+          testID="composeFAB"
+          onPress={onPressCompose}
+          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`New post`)}
+          accessibilityHint=""
+        />
+      )}
     </View>
   )
-})
+}
+
+function useHeaderOffset() {
+  const {isDesktop, isTablet} = useWebMediaQueries()
+  const {fontScale} = useWindowDimensions()
+  const {hasSession} = useSession()
+
+  if (isDesktop) {
+    return 0
+  }
+  if (isTablet) {
+    if (hasSession) {
+      return 50
+    } else {
+      return 0
+    }
+  }
+
+  if (hasSession) {
+    const navBarPad = 16
+    const navBarText = 21 * fontScale
+    const tabBarPad = 20 + 3 // nav bar padding + border
+    const tabBarText = 16 * fontScale
+    const magic = 7 * fontScale
+    return navBarPad + navBarText + tabBarPad + tabBarText + magic
+  } else {
+    const navBarPad = 16
+    const navBarText = 21 * fontScale
+    const magic = 4 * fontScale
+    return navBarPad + navBarText + magic
+  }
+}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 2c4335dc1..1f2af069b 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -6,43 +6,110 @@ import {RichText} from '../util/text/RichText'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {UserAvatar} from '../util/UserAvatar'
-import {observer} from 'mobx-react-lite'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useStores} from 'state/index'
 import {pluralize} from 'lib/strings/helpers'
 import {AtUri} from '@atproto/api'
 import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePinFeedMutation,
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+  useSaveFeedMutation,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
-export const FeedSourceCard = observer(function FeedSourceCardImpl({
-  item,
+export function FeedSourceCard({
+  feedUri,
   style,
   showSaveBtn = false,
   showDescription = false,
   showLikes = false,
+  LoadingComponent,
+  pinOnSave = false,
 }: {
-  item: FeedSourceModel
+  feedUri: string
   style?: StyleProp<ViewStyle>
   showSaveBtn?: boolean
   showDescription?: boolean
   showLikes?: boolean
+  LoadingComponent?: JSX.Element
+  pinOnSave?: boolean
+}) {
+  const {data: preferences} = usePreferencesQuery()
+  const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!feed || !preferences) {
+    return LoadingComponent ? (
+      LoadingComponent
+    ) : (
+      <FeedLoadingPlaceholder style={{flex: 1}} />
+    )
+  }
+
+  return (
+    <FeedSourceCardLoaded
+      feed={feed}
+      preferences={preferences}
+      style={style}
+      showSaveBtn={showSaveBtn}
+      showDescription={showDescription}
+      showLikes={showLikes}
+      pinOnSave={pinOnSave}
+    />
+  )
+}
+
+export function FeedSourceCardLoaded({
+  feed,
+  preferences,
+  style,
+  showSaveBtn = false,
+  showDescription = false,
+  showLikes = false,
+  pinOnSave = false,
+}: {
+  feed: FeedSourceInfo
+  preferences: UsePreferencesQueryResponse
+  style?: StyleProp<ViewStyle>
+  showSaveBtn?: boolean
+  showDescription?: boolean
+  showLikes?: boolean
+  pinOnSave?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
+  const {openModal} = useModalControls()
+
+  const {isPending: isSavePending, mutateAsync: saveFeed} =
+    useSaveFeedMutation()
+  const {isPending: isRemovePending, mutateAsync: removeFeed} =
+    useRemoveFeedMutation()
+  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
+
+  const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri))
 
   const onToggleSaved = React.useCallback(async () => {
-    if (item.isSaved) {
-      store.shell.openModal({
+    // Only feeds can be un/saved, lists are handled elsewhere
+    if (feed?.type !== 'feed') return
+
+    if (isSaved) {
+      openModal({
         name: 'confirm',
-        title: 'Remove from my feeds',
-        message: `Remove ${item.displayName} from my feeds?`,
+        title: _(msg`Remove from my feeds`),
+        message: _(msg`Remove ${feed.displayName} from my feeds?`),
         onPressConfirm: async () => {
           try {
-            await item.unsave()
+            await removeFeed({uri: feed.uri})
+            // await item.unsave()
             Toast.show('Removed from my feeds')
           } catch (e) {
             Toast.show('There was an issue contacting your server')
@@ -52,58 +119,67 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
       })
     } else {
       try {
-        await item.save()
+        if (pinOnSave) {
+          await pinFeed({uri: feed.uri})
+        } else {
+          await saveFeed({uri: feed.uri})
+        }
         Toast.show('Added to my feeds')
       } catch (e) {
         Toast.show('There was an issue contacting your server')
         logger.error('Failed to save feed', {error: e})
       }
     }
-  }, [store, item])
+  }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
+
+  if (!feed || !preferences) return null
 
   return (
     <Pressable
-      testID={`feed-${item.displayName}`}
+      testID={`feed-${feed.displayName}`}
       accessibilityRole="button"
       style={[styles.container, pal.border, style]}
       onPress={() => {
-        if (item.type === 'feed-generator') {
+        if (feed.type === 'feed') {
           navigation.push('ProfileFeed', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
+            name: feed.creatorDid,
+            rkey: new AtUri(feed.uri).rkey,
           })
-        } else if (item.type === 'list') {
+        } else if (feed.type === 'list') {
           navigation.push('ProfileList', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
+            name: feed.creatorDid,
+            rkey: new AtUri(feed.uri).rkey,
           })
         }
       }}
-      key={item.uri}>
+      key={feed.uri}>
       <View style={[styles.headerContainer]}>
         <View style={[s.mr10]}>
-          <UserAvatar type="algo" size={36} avatar={item.avatar} />
+          <UserAvatar type="algo" size={36} avatar={feed.avatar} />
         </View>
         <View style={[styles.headerTextContainer]}>
           <Text style={[pal.text, s.bold]} numberOfLines={3}>
-            {item.displayName}
+            {feed.displayName}
           </Text>
           <Text style={[pal.textLight]} numberOfLines={3}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
+            {feed.type === 'feed' ? 'Feed' : 'List'} by{' '}
+            {sanitizeHandle(feed.creatorHandle, '@')}
           </Text>
         </View>
-        {showSaveBtn && (
+
+        {showSaveBtn && feed.type === 'feed' && (
           <View>
             <Pressable
+              disabled={isSavePending || isPinPending || isRemovePending}
               accessibilityRole="button"
               accessibilityLabel={
-                item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
+                isSaved ? 'Remove from my feeds' : 'Add to my feeds'
               }
               accessibilityHint=""
               onPress={onToggleSaved}
               hitSlop={15}
               style={styles.btn}>
-              {item.isSaved ? (
+              {isSaved ? (
                 <FontAwesomeIcon
                   icon={['far', 'trash-can']}
                   size={19}
@@ -121,23 +197,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
         )}
       </View>
 
-      {showDescription && item.descriptionRT ? (
+      {showDescription && feed.description ? (
         <RichText
           style={[pal.textLight, styles.description]}
-          richText={item.descriptionRT}
+          richText={feed.description}
           numberOfLines={3}
         />
       ) : null}
 
-      {showLikes ? (
+      {showLikes && feed.type === 'feed' ? (
         <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-          Liked by {item.likeCount || 0}{' '}
-          {pluralize(item.likeCount || 0, 'user')}
+          Liked by {feed.likeCount || 0}{' '}
+          {pluralize(feed.likeCount || 0, 'user')}
         </Text>
       ) : null}
     </Pressable>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
new file mode 100644
index 000000000..618f4e5cd
--- /dev/null
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -0,0 +1,222 @@
+import React, {MutableRefObject} from 'react'
+import {
+  Dimensions,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {FlatList} from '../util/Views'
+import {FeedSourceCardLoaded} from './FeedSourceCard'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {logger} from '#/logger'
+import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
+import {useAnimatedScrollHandler} from 'react-native-reanimated'
+import {useTheme} from '#/lib/ThemeContext'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {hydrateFeedGenerator} from '#/state/queries/feed'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+
+const LOADING = {_reactKey: '__loading__'}
+const EMPTY = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+interface ProfileFeedgensProps {
+  did: string
+  scrollElRef: MutableRefObject<FlatList<any> | null>
+  onScroll?: OnScrollHandler
+  scrollEventThrottle?: number
+  headerOffset: number
+  enabled?: boolean
+  style?: StyleProp<ViewStyle>
+  testID?: string
+}
+
+export const ProfileFeedgens = React.forwardRef<
+  SectionRef,
+  ProfileFeedgensProps
+>(function ProfileFeedgensImpl(
+  {
+    did,
+    scrollElRef,
+    onScroll,
+    scrollEventThrottle,
+    headerOffset,
+    enabled,
+    style,
+    testID,
+  },
+  ref,
+) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const opts = React.useMemo(() => ({enabled}), [enabled])
+  const {
+    data,
+    isFetching,
+    isFetched,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFeedgensQuery(did, opts)
+  const isEmpty = !isFetching && !data?.pages[0]?.feeds.length
+  const {data: preferences} = usePreferencesQuery()
+
+  const items = React.useMemo(() => {
+    let items: any[] = []
+    if (isError && isEmpty) {
+      items = items.concat([ERROR_ITEM])
+    }
+    if (!isFetched && isFetching) {
+      items = items.concat([LOADING])
+    } else if (isEmpty) {
+      items = items.concat([EMPTY])
+    } else if (data?.pages) {
+      for (const page of data?.pages) {
+        items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed)))
+      }
+    }
+    if (isError && !isEmpty) {
+      items = items.concat([LOAD_MORE_ERROR_ITEM])
+    }
+    return items
+  }, [isError, isEmpty, isFetched, isFetching, data])
+
+  // events
+  // =
+
+  const queryClient = useQueryClient()
+
+  const onScrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
+    queryClient.invalidateQueries({queryKey: RQKEY(did)})
+  }, [scrollElRef, queryClient, headerOffset, did])
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh feeds', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more feeds', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const onPressRetryLoadMore = React.useCallback(() => {
+    fetchNextPage()
+  }, [fetchNextPage])
+
+  // rendering
+  // =
+
+  const renderItemInner = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === EMPTY) {
+        return (
+          <View
+            testID="listsEmpty"
+            style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
+            <Text style={pal.textLight}>
+              <Trans>You have no feeds.</Trans>
+            </Text>
+          </View>
+        )
+      } else if (item === ERROR_ITEM) {
+        return (
+          <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
+        )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching your lists. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
+      } else if (item === LOADING) {
+        return <FeedLoadingPlaceholder />
+      }
+      if (preferences) {
+        return (
+          <FeedSourceCardLoaded
+            feed={item}
+            preferences={preferences}
+            style={styles.item}
+            showLikes
+          />
+        )
+      }
+      return null
+    },
+    [error, refetch, onPressRetryLoadMore, pal, preferences],
+  )
+
+  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
+  return (
+    <View testID={testID} style={style}>
+      <FlatList
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={(item: any) => item._reactKey || item.uri}
+        renderItem={renderItemInner}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+            progressViewOffset={headerOffset}
+          />
+        }
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
+        style={{paddingTop: headerOffset}}
+        onScroll={onScroll != null ? scrollHandler : undefined}
+        scrollEventThrottle={scrollEventThrottle}
+        indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
+        removeClippedSubviews={true}
+        contentOffset={{x: 0, y: headerOffset * -1}}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+        onEndReached={onEndReached}
+      />
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  item: {
+    paddingHorizontal: 18,
+  },
+})