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/algos/AlgoItem.tsx153
-rw-r--r--src/view/com/algos/SavedFeedItem.tsx50
-rw-r--r--src/view/com/algos/useCustomFeed.ts27
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx14
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx14
-rw-r--r--src/view/com/pager/TabBar.tsx99
-rw-r--r--src/view/com/posts/Feed.tsx6
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx2
-rw-r--r--src/view/com/util/post-embeds/index.tsx28
9 files changed, 347 insertions, 46 deletions
diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx
new file mode 100644
index 000000000..56ee6d1d2
--- /dev/null
+++ b/src/view/com/algos/AlgoItem.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+  TouchableOpacity,
+} from 'react-native'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {colors, s} from 'lib/styles'
+import {UserAvatar} from '../util/UserAvatar'
+import {Button} from '../util/forms/Button'
+import {observer} from 'mobx-react-lite'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
+import {useStores} from 'state/index'
+import {HeartIconSolid} from 'lib/icons'
+import {pluralize} from 'lib/strings/helpers'
+import {AtUri} from '@atproto/api'
+import {isWeb} from 'platform/detection'
+
+const AlgoItem = observer(
+  ({
+    item,
+    style,
+    showBottom = true,
+    reloadOnFocus = false,
+  }: {
+    item: AlgoItemModel
+    style?: StyleProp<ViewStyle>
+    showBottom?: boolean
+    reloadOnFocus?: boolean
+  }) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const navigation = useNavigation<NavigationProp>()
+
+    // TODO: this is pretty hacky, but it works for now
+    // causes issues on web
+    useFocusEffect(() => {
+      if (reloadOnFocus && !isWeb) {
+        item.reload()
+      }
+    })
+
+    return (
+      <TouchableOpacity
+        accessibilityRole="button"
+        style={[styles.container, style]}
+        onPress={() => {
+          navigation.navigate('CustomFeed', {
+            name: item.data.creator.did,
+            rkey: new AtUri(item.data.uri).rkey,
+            displayName:
+              item.data.displayName ??
+              `${item.data.creator.displayName}'s feed`,
+          })
+        }}
+        key={item.data.uri}>
+        <View style={[styles.headerContainer]}>
+          <View style={[s.mr10]}>
+            <UserAvatar size={36} avatar={item.data.avatar} />
+          </View>
+          <View style={[styles.headerTextContainer]}>
+            <Text style={[pal.text, s.bold]}>
+              {item.data.displayName ?? 'Feed name'}
+            </Text>
+            <Text style={[pal.textLight, styles.description]} numberOfLines={5}>
+              {item.data.description ??
+                "Explore our Feed for the latest updates and insights! Dive into a world of intriguing articles, trending news, and exciting stories that cover a wide range of topics. From technology breakthroughs to lifestyle tips, there's something here for everyone. Stay informed and get inspired with us. Join the conversation now!"}
+            </Text>
+          </View>
+        </View>
+
+        {showBottom ? (
+          <View style={styles.bottomContainer}>
+            <View style={styles.likedByContainer}>
+              {/* <View style={styles.likedByAvatars}>
+              <UserAvatar size={24} avatar={item.data.avatar} />
+              <UserAvatar size={24} avatar={item.data.avatar} />
+              <UserAvatar size={24} avatar={item.data.avatar} />
+            </View> */}
+
+              <HeartIconSolid size={16} style={[s.mr2, {color: colors.red3}]} />
+              <Text style={[pal.text, pal.textLight]}>
+                {item.data.likeCount && item.data.likeCount > 0
+                  ? `Liked by ${item.data.likeCount} ${pluralize(
+                      item.data.likeCount,
+                      'other',
+                    )}`
+                  : 'Be the first to like this'}
+              </Text>
+            </View>
+            <View>
+              <Button
+                type={item.isSaved ? 'default' : 'inverted'}
+                onPress={() => {
+                  if (item.data.viewer?.saved) {
+                    store.me.savedFeeds.unsave(item)
+                  } else {
+                    store.me.savedFeeds.save(item)
+                  }
+                }}
+                label={item.data.viewer?.saved ? 'Unsave' : 'Save'}
+              />
+            </View>
+          </View>
+        ) : null}
+      </TouchableOpacity>
+    )
+  },
+)
+export default AlgoItem
+
+const styles = StyleSheet.create({
+  container: {
+    paddingHorizontal: 18,
+    paddingVertical: 20,
+    flexDirection: 'column',
+    flex: 1,
+    borderTopWidth: 1,
+    borderTopColor: '#E5E5E5',
+    gap: 18,
+  },
+  headerContainer: {
+    flexDirection: 'row',
+  },
+  headerTextContainer: {
+    flexDirection: 'column',
+    columnGap: 4,
+    flex: 1,
+  },
+  description: {
+    flex: 1,
+    flexWrap: 'wrap',
+  },
+  bottomContainer: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+  },
+  likedByContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 2,
+  },
+  likedByAvatars: {
+    flexDirection: 'row',
+    gap: -12,
+  },
+})
diff --git a/src/view/com/algos/SavedFeedItem.tsx b/src/view/com/algos/SavedFeedItem.tsx
new file mode 100644
index 000000000..bb4ec10b3
--- /dev/null
+++ b/src/view/com/algos/SavedFeedItem.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View, TouchableOpacity, StyleSheet} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import AlgoItem from './AlgoItem'
+
+export const SavedFeedItem = observer(
+  ({item, savedFeeds}: {item: AlgoItemModel; savedFeeds: SavedFeedsModel}) => {
+    const isPinned = savedFeeds.isPinned(item)
+
+    return (
+      <View style={styles.itemContainer}>
+        <AlgoItem
+          key={item.data.uri}
+          item={item}
+          showBottom={false}
+          style={styles.item}
+        />
+        <TouchableOpacity
+          accessibilityRole="button"
+          onPress={() => {
+            savedFeeds.togglePinnedFeed(item)
+            console.log('pinned', savedFeeds.pinned)
+            console.log('isPinned', savedFeeds.isPinned(item))
+          }}>
+          <FontAwesomeIcon
+            icon="thumb-tack"
+            size={20}
+            color={isPinned ? colors.blue3 : colors.gray3}
+          />
+        </TouchableOpacity>
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginRight: 18,
+  },
+  item: {
+    borderTopWidth: 0,
+  },
+})
diff --git a/src/view/com/algos/useCustomFeed.ts b/src/view/com/algos/useCustomFeed.ts
new file mode 100644
index 000000000..cea9c1cea
--- /dev/null
+++ b/src/view/com/algos/useCustomFeed.ts
@@ -0,0 +1,27 @@
+import {useEffect, useState} from 'react'
+import {useStores} from 'state/index'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+
+export function useCustomFeed(uri: string) {
+  const store = useStores()
+  const [item, setItem] = useState<AlgoItemModel>()
+  useEffect(() => {
+    async function fetchView() {
+      const res = await store.agent.app.bsky.feed.getFeedGenerator({
+        feed: uri,
+      })
+      const view = res.data.view
+      return view
+    }
+    async function buildFeedItem() {
+      const view = await fetchView()
+      if (view) {
+        const temp = new AlgoItemModel(store, view)
+        setItem(temp)
+      }
+    }
+    buildFeedItem()
+  }, [store, uri])
+
+  return item
+}
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 0fc1b7310..6de38fa1d 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useMemo} from 'react'
 import {Animated, StyleSheet} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
@@ -27,6 +27,14 @@ const FeedsTabBarDesktop = observer(
     props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
   ) => {
     const store = useStores()
+    const items = useMemo(
+      () => [
+        'Following',
+        "What's hot",
+        ...store.me.savedFeeds.listOfPinnedFeedNames,
+      ],
+      [store.me.savedFeeds.listOfPinnedFeedNames],
+    )
     const pal = usePalette('default')
     const interp = useAnimatedValue(0)
 
@@ -44,12 +52,14 @@ const FeedsTabBarDesktop = observer(
         {translateY: Animated.multiply(interp, -100)},
       ],
     }
+
     return (
       // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
       <Animated.View style={[pal.view, styles.tabBar, transform]}>
         <TabBar
           {...props}
-          items={['Following', "What's hot"]}
+          key={items.join(',')}
+          items={items}
           indicatorPosition="bottom"
           indicatorColor={pal.colors.link}
         />
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 725c44603..ab8f98309 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useMemo} from 'react'
 import {Animated, StyleSheet, TouchableOpacity} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
@@ -32,6 +32,15 @@ export const FeedsTabBar = observer(
       store.shell.openDrawer()
     }, [store])
 
+    const items = useMemo(
+      () => [
+        'Following',
+        "What's hot",
+        ...store.me.savedFeeds.listOfPinnedFeedNames,
+      ],
+      [store.me.savedFeeds.listOfPinnedFeedNames],
+    )
+
     return (
       <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
         <TouchableOpacity
@@ -44,8 +53,9 @@ export const FeedsTabBar = observer(
           <UserAvatar avatar={store.me.avatar} size={30} />
         </TouchableOpacity>
         <TabBar
+          key={items.join(',')}
           {...props}
-          items={['Following', "What's hot"]}
+          items={items}
           indicatorPosition="bottom"
           indicatorColor={pal.colors.link}
         />
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index a0b72a93f..9294b6026 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,5 +1,5 @@
 import React, {createRef, useState, useMemo, useRef} from 'react'
-import {Animated, StyleSheet, View} from 'react-native'
+import {Animated, StyleSheet, View, ScrollView} from 'react-native'
 import {Text} from '../util/text/Text'
 import {PressableWithHover} from '../util/PressableWithHover'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -43,27 +43,39 @@ export function TabBar({
   )
   const panX = Animated.add(position, offset)
   const containerRef = useRef<View>(null)
+  const [scrollX, setScrollX] = useState(0)
 
-  const indicatorStyle = {
-    backgroundColor: indicatorColor || pal.colors.link,
-    bottom:
-      indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
-    top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
-    transform: [
-      {
-        translateX: panX.interpolate({
-          inputRange: items.map((_item, i) => i),
-          outputRange: itemLayouts.map(l => l.x + l.width / 2),
-        }),
-      },
-      {
-        scaleX: panX.interpolate({
-          inputRange: items.map((_item, i) => i),
-          outputRange: itemLayouts.map(l => l.width),
-        }),
-      },
+  const indicatorStyle = useMemo(
+    () => ({
+      backgroundColor: indicatorColor || pal.colors.link,
+      bottom:
+        indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
+      top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
+      transform: [
+        {
+          translateX: panX.interpolate({
+            inputRange: items.map((_item, i) => i),
+            outputRange: itemLayouts.map(l => l.x + l.width / 2 - scrollX),
+          }),
+        },
+        {
+          scaleX: panX.interpolate({
+            inputRange: items.map((_item, i) => i),
+            outputRange: itemLayouts.map(l => l.width),
+          }),
+        },
+      ],
+    }),
+    [
+      indicatorColor,
+      indicatorPosition,
+      itemLayouts,
+      items,
+      panX,
+      pal.colors.link,
+      scrollX,
     ],
-  }
+  )
 
   const onLayout = React.useCallback(() => {
     const promises = []
@@ -105,26 +117,33 @@ export function TabBar({
       onLayout={onLayout}
       ref={containerRef}>
       <Animated.View style={[styles.indicator, indicatorStyle]} />
-      {items.map((item, i) => {
-        const selected = i === selectedPage
-        return (
-          <PressableWithHover
-            ref={itemRefs[i]}
-            key={item}
-            style={
-              indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
-            }
-            hoverStyle={pal.viewLight}
-            onPress={() => onPressItem(i)}>
-            <Text
-              type="xl-bold"
-              testID={testID ? `${testID}-${item}` : undefined}
-              style={selected ? pal.text : pal.textLight}>
-              {item}
-            </Text>
-          </PressableWithHover>
-        )
-      })}
+      <ScrollView
+        horizontal={true}
+        showsHorizontalScrollIndicator={false}
+        onScroll={({nativeEvent}) => {
+          setScrollX(nativeEvent.contentOffset.x)
+        }}>
+        {items.map((item, i) => {
+          const selected = i === selectedPage
+          return (
+            <PressableWithHover
+              ref={itemRefs[i]}
+              key={item}
+              style={
+                indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
+              }
+              hoverStyle={pal.viewLight}
+              onPress={() => onPressItem(i)}>
+              <Text
+                type="xl-bold"
+                testID={testID ? `${testID}-${item}` : undefined}
+                style={selected ? pal.text : pal.textLight}>
+                {item}
+              </Text>
+            </PressableWithHover>
+          )
+        })}
+      </ScrollView>
     </View>
   )
 }
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 998cfe0c9..5b0110df8 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -34,6 +34,8 @@ export const Feed = observer(function Feed({
   renderEmptyState,
   testID,
   headerOffset = 0,
+  ListHeaderComponent,
+  extraData,
 }: {
   feed: PostsFeedModel
   style?: StyleProp<ViewStyle>
@@ -44,6 +46,8 @@ export const Feed = observer(function Feed({
   renderEmptyState?: () => JSX.Element
   testID?: string
   headerOffset?: number
+  ListHeaderComponent?: () => JSX.Element
+  extraData?: any
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
@@ -163,6 +167,7 @@ export const Feed = observer(function Feed({
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
           ListFooterComponent={FeedFooter}
+          ListHeaderComponent={ListHeaderComponent}
           refreshControl={
             <RefreshControl
               refreshing={isRefreshing}
@@ -179,6 +184,7 @@ export const Feed = observer(function Feed({
           onEndReachedThreshold={0.6}
           removeClippedSubviews={true}
           contentOffset={{x: 0, y: headerOffset * -1}}
+          extraData={extraData}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
         />
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 5c0296e28..9980e9de0 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -205,7 +205,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         }>
         {opts.isLiked ? (
           <HeartIconSolid
-            style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
+            style={styles.ctrlIconLiked}
             size={opts.big ? 22 : 16}
           />
         ) : (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index a55ff9050..328b9305b 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -13,6 +13,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyFeedPost,
+  AppBskyFeedDefs,
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -24,6 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import QuoteEmbed from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
+import AlgoItem from 'view/com/algos/AlgoItem'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -42,6 +45,8 @@ export function PostEmbeds({
   const pal = usePalette('default')
   const store = useStores()
 
+  // quote post with media
+  // =
   if (
     AppBskyEmbedRecordWithMedia.isView(embed) &&
     AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
@@ -65,6 +70,8 @@ export function PostEmbeds({
     )
   }
 
+  // quote post
+  // =
   if (AppBskyEmbedRecord.isView(embed)) {
     if (
       AppBskyEmbedRecord.isViewRecord(embed.record) &&
@@ -87,6 +94,8 @@ export function PostEmbeds({
     }
   }
 
+  // image embed
+  // =
   if (AppBskyEmbedImages.isView(embed)) {
     const {images} = embed
 
@@ -132,10 +141,11 @@ export function PostEmbeds({
           />
         </View>
       )
-      // }
     }
   }
 
+  // external link embed
+  // =
   if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
     const youtubeVideoId = getYoutubeVideoId(link.uri)
@@ -153,6 +163,22 @@ export function PostEmbeds({
       </Link>
     )
   }
+
+  // custom feed embed (i.e. generator view)
+  // =
+  if (
+    AppBskyEmbedRecord.isView(embed) &&
+    AppBskyFeedDefs.isGeneratorView(embed.record)
+  ) {
+    return (
+      <AlgoItem
+        item={new AlgoItemModel(store, embed.record)}
+        style={[pal.view, pal.border, styles.extOuter]}
+        reloadOnFocus={true}
+      />
+    )
+  }
+
   return <View />
 }