about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-05-24 22:09:39 -0500
committerPaul Frazee <pfrazee@gmail.com>2023-05-24 22:09:39 -0500
commitdfb39e7c4fcaff3effcc82b412191177fdfdaf22 (patch)
tree0771c5794290cdc29c5689cdd67384aadddcadcb /src
parent12c7f6d6a5f81570950c7ce84976250b28ef666b (diff)
downloadvoidsky-dfb39e7c4fcaff3effcc82b412191177fdfdaf22.tar.zst
Add feed discovery page
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/discovery/feeds.ts97
-rw-r--r--src/view/com/feeds/SavedFeeds.tsx41
-rw-r--r--src/view/screens/CustomFeed.tsx6
-rw-r--r--src/view/screens/DiscoverFeeds.tsx109
7 files changed, 246 insertions, 15 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index ff7a5f5c2..0664ac526 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -35,6 +35,7 @@ import {SearchScreen} from './view/screens/Search'
 import {NotificationsScreen} from './view/screens/Notifications'
 import {ModerationScreen} from './view/screens/Moderation'
 import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
+import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
 import {NotFoundScreen} from './view/screens/NotFound'
 import {SettingsScreen} from './view/screens/Settings'
 import {ProfileScreen} from './view/screens/Profile'
@@ -104,6 +105,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title('Blocked Accounts')}}
       />
       <Stack.Screen
+        name="DiscoverFeeds"
+        component={DiscoverFeedsScreen}
+        options={{title: title('Discover Feeds')}}
+      />
+      <Stack.Screen
         name="Settings"
         component={SettingsScreen}
         options={{title: title('Settings')}}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index b714c10c0..5dca3cc3f 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,6 +9,7 @@ export type CommonNavigatorParams = {
   ModerationMuteLists: undefined
   ModerationMutedAccounts: undefined
   ModerationBlockedAccounts: undefined
+  DiscoverFeeds: undefined
   Settings: undefined
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
diff --git a/src/routes.ts b/src/routes.ts
index 9e3e3f7ea..1c3d91187 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -3,6 +3,7 @@ import {Router} from 'lib/routes/router'
 export const router = new Router({
   Home: '/',
   Search: '/search',
+  DiscoverFeeds: '/search/feeds',
   Notifications: '/notifications',
   Settings: '/settings',
   Moderation: '/moderation',
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
new file mode 100644
index 000000000..26a8d650c
--- /dev/null
+++ b/src/state/models/discovery/feeds.ts
@@ -0,0 +1,97 @@
+import {makeAutoObservable} from 'mobx'
+import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {CustomFeedModel} from '../feeds/custom-feed'
+
+export class FeedsDiscoveryModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasMore() {
+    return false
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  refresh = bundleAsync(async () => {
+    this._xLoading()
+    try {
+      const res =
+        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators(
+          {},
+        )
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.feeds = []
+  }
+
+  // state transitions
+  // =
+
+  _xLoading() {
+    this.isLoading = true
+    this.isRefreshing = true
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch popular feeds', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
+    this.feeds = []
+    for (const f of res.data.feeds) {
+      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx
index e92e741da..610562c9d 100644
--- a/src/view/com/feeds/SavedFeeds.tsx
+++ b/src/view/com/feeds/SavedFeeds.tsx
@@ -53,14 +53,28 @@ export const SavedFeeds = observer(
     const renderListFooterComponent = useCallback(() => {
       return (
         <>
-          <Link
-            style={[styles.footerLink, pal.border]}
-            href="/settings/saved-feeds">
-            <FontAwesomeIcon icon="cog" size={18} color={pal.colors.icon} />
-            <Text type="lg-medium" style={pal.textLight}>
-              Change Order
-            </Text>
-          </Link>
+          <View style={[styles.footerLinks, pal.border]}>
+            <Link style={[styles.footerLink, pal.border]} href="/search/feeds">
+              <FontAwesomeIcon
+                icon="search"
+                size={18}
+                color={pal.colors.icon}
+              />
+              <Text type="lg-medium" style={pal.textLight}>
+                Discover new feeds
+              </Text>
+            </Link>
+            {!store.me.savedFeeds.isEmpty && (
+              <Link
+                style={[styles.footerLink, pal.border]}
+                href="/settings/saved-feeds">
+                <FontAwesomeIcon icon="cog" size={18} color={pal.colors.icon} />
+                <Text type="lg-medium" style={pal.textLight}>
+                  Change Order
+                </Text>
+              </Link>
+            )}
+          </View>
           <View
             style={[
               pal.border,
@@ -82,7 +96,7 @@ export const SavedFeeds = observer(
           </View>
         </>
       )
-    }, [pal])
+    }, [pal, store.me.savedFeeds.isEmpty])
 
     const renderItem = useCallback(
       ({item}) => <CustomFeed key={item.data.uri} item={item} />,
@@ -118,14 +132,16 @@ export const SavedFeeds = observer(
 )
 
 const styles = StyleSheet.create({
+  footerLinks: {
+    marginTop: 8,
+    borderBottomWidth: 1,
+  },
   footerLink: {
     flexDirection: 'row',
     borderTopWidth: 1,
-    borderBottomWidth: 1,
     paddingHorizontal: 26,
     paddingVertical: 18,
     gap: 18,
-    marginTop: 8,
   },
   empty: {
     paddingHorizontal: 18,
@@ -134,7 +150,4 @@ const styles = StyleSheet.create({
     marginHorizontal: 18,
     marginTop: 10,
   },
-  feedItem: {
-    borderTopWidth: 1,
-  },
 })
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index 0690a17d8..49798d758 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -220,7 +220,7 @@ export const CustomFeedScreen = withAuthRequired(
                 </Text>
               )}
               {isDesktopWeb && (
-                <View style={styles.headerBtns}>
+                <View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
                   <Button
                     type={currentFeed?.isSaved ? 'default' : 'inverted'}
                     onPress={onToggleSaved}
@@ -366,6 +366,10 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
   },
+  headerBtnsDesktop: {
+    marginTop: 8,
+    gap: 4,
+  },
   headerAddBtn: {
     flexDirection: 'row',
     alignItems: 'center',
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
new file mode 100644
index 000000000..82a37942f
--- /dev/null
+++ b/src/view/screens/DiscoverFeeds.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import {RefreshControl, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {useStores} from 'state/index'
+import {FeedsDiscoveryModel} from 'state/models/discovery/feeds'
+import {CenteredView, FlatList} from 'view/com/util/Views'
+import {CustomFeed} from 'view/com/feeds/CustomFeed'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb} from 'platform/detection'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
+export const DiscoverFeedsScreen = withAuthRequired(
+  observer(({}: Props) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
+
+    useFocusEffect(
+      React.useCallback(() => {
+        store.shell.setMinimalShellMode(false)
+        feeds.refresh()
+      }, [store, feeds]),
+    )
+
+    const onRefresh = React.useCallback(() => {
+      store.me.savedFeeds.refresh()
+    }, [store])
+
+    const renderListEmptyComponent = React.useCallback(() => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            styles.empty,
+          ]}>
+          <Text type="lg" style={[pal.text]}>
+            {feeds.isLoading
+              ? 'Loading...'
+              : `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
+          </Text>
+        </View>
+      )
+    }, [pal, feeds.isLoading])
+
+    const renderItem = React.useCallback(
+      ({item}) => (
+        <CustomFeed
+          key={item.data.uri}
+          item={item}
+          showSaveBtn
+          showDescription
+          showLikes
+        />
+      ),
+      [],
+    )
+
+    return (
+      <CenteredView style={[styles.container, pal.view]}>
+        <View style={[isDesktopWeb && styles.containerDesktop, pal.border]}>
+          <ViewHeader title="Discover Feeds" showOnDesktop />
+        </View>
+        <FlatList
+          style={[!isDesktopWeb && s.flex1]}
+          data={feeds.feeds}
+          keyExtractor={item => item.data.uri}
+          refreshControl={
+            <RefreshControl
+              refreshing={feeds.isRefreshing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={renderItem}
+          initialNumToRender={10}
+          ListEmptyComponent={renderListEmptyComponent}
+          extraData={feeds.isLoading}
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  empty: {
+    paddingHorizontal: 18,
+    paddingVertical: 16,
+    borderRadius: 8,
+    marginHorizontal: 18,
+    marginTop: 10,
+  },
+})