about summary refs log tree commit diff
path: root/src/view/screens/ProfileFeed.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/screens/ProfileFeed.tsx')
-rw-r--r--src/view/screens/ProfileFeed.tsx535
1 files changed, 535 insertions, 0 deletions
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
new file mode 100644
index 000000000..70e52bf7a
--- /dev/null
+++ b/src/view/screens/ProfileFeed.tsx
@@ -0,0 +1,535 @@
+import React, {useMemo, useCallback} from 'react'
+import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {useNavigation} from '@react-navigation/native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {HeartIcon, HeartIconSolid} from 'lib/icons'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {makeRecordUri} from 'lib/strings/url-helpers'
+import {colors, s} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {FeedSourceModel} from 'state/models/content/feed-source'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
+import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
+import {Feed} from 'view/com/posts/Feed'
+import {TextLink} from 'view/com/util/Link'
+import {Button} from 'view/com/util/forms/Button'
+import {Text} from 'view/com/util/text/Text'
+import {RichText} from 'view/com/util/text/RichText'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {FAB} from 'view/com/util/fab/FAB'
+import {EmptyState} from 'view/com/util/EmptyState'
+import * as Toast from 'view/com/util/Toast'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {useCustomFeed} from 'lib/hooks/useCustomFeed'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {shareUrl} from 'lib/sharing'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {Haptics} from 'lib/haptics'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
+import {resolveName} from 'lib/api'
+import {makeCustomFeedLink} from 'lib/routes/links'
+import {pluralize} from 'lib/strings/helpers'
+import {CenteredView, ScrollView} from 'view/com/util/Views'
+import {NavigationProp} from 'lib/routes/types'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {makeProfileLink} from 'lib/routes/links'
+import {ComposeIcon2} from 'lib/icons'
+
+const SECTION_TITLES = ['Posts', 'About']
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
+export const ProfileFeedScreen = withAuthRequired(
+  observer(function ProfileFeedScreenImpl(props: Props) {
+    const pal = usePalette('default')
+    const store = useStores()
+    const navigation = useNavigation<NavigationProp>()
+
+    const {name: handleOrDid} = props.route.params
+
+    const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
+    const [error, setError] = React.useState<string | undefined>()
+
+    const onPressBack = React.useCallback(() => {
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    }, [navigation])
+
+    React.useEffect(() => {
+      /*
+       * We must resolve the DID of the feed owner before we can fetch the feed.
+       */
+      async function fetchDid() {
+        try {
+          const did = await resolveName(store, handleOrDid)
+          setFeedOwnerDid(did)
+        } catch (e) {
+          setError(
+            `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
+          )
+        }
+      }
+
+      fetchDid()
+    }, [store, handleOrDid, setFeedOwnerDid])
+
+    if (error) {
+      return (
+        <CenteredView>
+          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+            <Text type="title-lg" style={[pal.text, s.mb10]}>
+              Could not load feed
+            </Text>
+            <Text type="md" style={[pal.text, s.mb20]}>
+              {error}
+            </Text>
+
+            <View style={{flexDirection: 'row'}}>
+              <Button
+                type="default"
+                accessibilityLabel="Go Back"
+                accessibilityHint="Return to previous page"
+                onPress={onPressBack}
+                style={{flexShrink: 1}}>
+                <Text type="button" style={pal.text}>
+                  Go Back
+                </Text>
+              </Button>
+            </View>
+          </View>
+        </CenteredView>
+      )
+    }
+
+    return feedOwnerDid ? (
+      <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
+    ) : (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }),
+)
+
+export const ProfileFeedScreenInner = observer(
+  function ProfileFeedScreenInnerImpl({
+    route,
+    feedOwnerDid,
+  }: Props & {feedOwnerDid: string}) {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {track} = useAnalytics()
+    const feedSectionRef = React.useRef<SectionRef>(null)
+    const {rkey, name: handleOrDid} = route.params
+    const uri = useMemo(
+      () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
+      [rkey, feedOwnerDid],
+    )
+    const feedInfo = useCustomFeed(uri)
+    const feed: PostsFeedModel = useMemo(() => {
+      const model = new PostsFeedModel(store, 'custom', {
+        feed: uri,
+      })
+      model.setup()
+      return model
+    }, [store, uri])
+    const isPinned = store.preferences.isPinnedFeed(uri)
+    useSetTitle(feedInfo?.displayName)
+
+    // events
+    // =
+
+    const onToggleSaved = React.useCallback(async () => {
+      try {
+        Haptics.default()
+        if (feedInfo?.isSaved) {
+          await feedInfo?.unsave()
+        } else {
+          await feedInfo?.save()
+        }
+      } catch (err) {
+        Toast.show(
+          'There was an an issue updating your feeds, please check your internet connection and try again.',
+        )
+        store.log.error('Failed up update feeds', {err})
+      }
+    }, [store, feedInfo])
+
+    const onToggleLiked = React.useCallback(async () => {
+      Haptics.default()
+      try {
+        if (feedInfo?.isLiked) {
+          await feedInfo?.unlike()
+        } else {
+          await feedInfo?.like()
+        }
+      } catch (err) {
+        Toast.show(
+          'There was an an issue contacting the server, please check your internet connection and try again.',
+        )
+        store.log.error('Failed up toggle like', {err})
+      }
+    }, [store, feedInfo])
+
+    const onTogglePinned = React.useCallback(async () => {
+      Haptics.default()
+      if (feedInfo) {
+        feedInfo.togglePin().catch(e => {
+          Toast.show('There was an issue contacting the server')
+          store.log.error('Failed to toggle pinned feed', {e})
+        })
+      }
+    }, [store, feedInfo])
+
+    const onPressShare = React.useCallback(() => {
+      const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
+      shareUrl(url)
+      track('CustomFeed:Share')
+    }, [handleOrDid, rkey, track])
+
+    const onPressReport = React.useCallback(() => {
+      if (!feedInfo) return
+      store.shell.openModal({
+        name: 'report',
+        uri: feedInfo.uri,
+        cid: feedInfo.cid,
+      })
+    }, [store, feedInfo])
+
+    const onCurrentPageSelected = React.useCallback(
+      (index: number) => {
+        if (index === 0) {
+          feedSectionRef.current?.scrollToTop()
+        }
+      },
+      [feedSectionRef],
+    )
+
+    // render
+    // =
+
+    const dropdownItems: DropdownItem[] = React.useMemo(() => {
+      return [
+        {
+          testID: 'feedHeaderDropdownToggleSavedBtn',
+          label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds',
+          onPress: onToggleSaved,
+          icon: feedInfo?.isSaved
+            ? {
+                ios: {
+                  name: 'trash',
+                },
+                android: 'ic_delete',
+                web: ['far', 'trash-can'],
+              }
+            : {
+                ios: {
+                  name: 'plus',
+                },
+                android: '',
+                web: 'plus',
+              },
+        },
+        {
+          testID: 'feedHeaderDropdownReportBtn',
+          label: 'Report feed',
+          onPress: onPressReport,
+          icon: {
+            ios: {
+              name: 'exclamationmark.triangle',
+            },
+            android: 'ic_menu_report_image',
+            web: 'circle-exclamation',
+          },
+        },
+        {
+          testID: 'feedHeaderDropdownShareBtn',
+          label: 'Share link',
+          onPress: onPressShare,
+          icon: {
+            ios: {
+              name: 'square.and.arrow.up',
+            },
+            android: 'ic_menu_share',
+            web: 'share',
+          },
+        },
+      ] as DropdownItem[]
+    }, [feedInfo, onToggleSaved, onPressReport, onPressShare])
+
+    const renderHeader = useCallback(() => {
+      return (
+        <ProfileSubpageHeader
+          isLoading={!feedInfo?.hasLoaded}
+          href={makeCustomFeedLink(feedOwnerDid, rkey)}
+          title={feedInfo?.displayName}
+          avatar={feedInfo?.avatar}
+          isOwner={feedInfo?.isOwner}
+          creator={
+            feedInfo
+              ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
+              : undefined
+          }
+          avatarType="algo">
+          {feedInfo && (
+            <>
+              <Button
+                type="default"
+                label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
+                onPress={onToggleSaved}
+                style={styles.btn}
+              />
+              <Button
+                type={isPinned ? 'default' : 'inverted'}
+                label={isPinned ? 'Unpin' : 'Pin to home'}
+                onPress={onTogglePinned}
+                style={styles.btn}
+              />
+            </>
+          )}
+          <NativeDropdown
+            testID="headerDropdownBtn"
+            items={dropdownItems}
+            accessibilityLabel="More options"
+            accessibilityHint="">
+            <View style={[pal.viewLight, styles.btn]}>
+              <FontAwesomeIcon
+                icon="ellipsis"
+                size={20}
+                color={pal.colors.text}
+              />
+            </View>
+          </NativeDropdown>
+        </ProfileSubpageHeader>
+      )
+    }, [
+      pal,
+      feedOwnerDid,
+      rkey,
+      feedInfo,
+      isPinned,
+      onTogglePinned,
+      onToggleSaved,
+      dropdownItems,
+    ])
+
+    return (
+      <View style={s.hContentRegion}>
+        <PagerWithHeader
+          items={SECTION_TITLES}
+          renderHeader={renderHeader}
+          onCurrentPageSelected={onCurrentPageSelected}>
+          {({onScroll, headerHeight, isScrolledDown}) => (
+            <FeedSection
+              key="1"
+              ref={feedSectionRef}
+              feed={feed}
+              onScroll={onScroll}
+              headerHeight={headerHeight}
+              isScrolledDown={isScrolledDown}
+            />
+          )}
+          {({onScroll, headerHeight}) => (
+            <ScrollView
+              key="2"
+              onScroll={onScroll}
+              scrollEventThrottle={1}
+              contentContainerStyle={{paddingTop: headerHeight}}>
+              <AboutSection
+                feedOwnerDid={feedOwnerDid}
+                feedRkey={rkey}
+                feedInfo={feedInfo}
+                onToggleLiked={onToggleLiked}
+              />
+            </ScrollView>
+          )}
+        </PagerWithHeader>
+        <FAB
+          testID="composeFAB"
+          onPress={() => store.shell.openComposer({})}
+          icon={
+            <ComposeIcon2
+              strokeWidth={1.5}
+              size={29}
+              style={{color: 'white'}}
+            />
+          }
+          accessibilityRole="button"
+          accessibilityLabel="New post"
+          accessibilityHint=""
+        />
+      </View>
+    )
+  },
+)
+
+interface FeedSectionProps {
+  feed: PostsFeedModel
+  onScroll: OnScrollCb
+  headerHeight: number
+  isScrolledDown: boolean
+}
+const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
+  function FeedSectionImpl(
+    {feed, onScroll, headerHeight, isScrolledDown},
+    ref,
+  ) {
+    const hasNew = feed.hasNewLatest && !feed.isRefreshing
+    const scrollElRef = React.useRef<FlatList>(null)
+
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+    }, [scrollElRef, headerHeight])
+
+    const onPressLoadLatest = React.useCallback(() => {
+      onScrollToTop()
+      feed.refresh()
+    }, [feed, onScrollToTop])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderPostsEmpty = useCallback(() => {
+      return <EmptyState icon="feed" message="This feed is empty!" />
+    }, [])
+
+    return (
+      <View>
+        <Feed
+          feed={feed}
+          scrollElRef={scrollElRef}
+          onScroll={onScroll}
+          scrollEventThrottle={5}
+          renderEmptyState={renderPostsEmpty}
+          headerOffset={headerHeight}
+        />
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onPressLoadLatest}
+            label="Load new posts"
+            showIndicator={hasNew}
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+const AboutSection = observer(function AboutPageImpl({
+  feedOwnerDid,
+  feedRkey,
+  feedInfo,
+  onToggleLiked,
+}: {
+  feedOwnerDid: string
+  feedRkey: string
+  feedInfo: FeedSourceModel | undefined
+  onToggleLiked: () => void
+}) {
+  const pal = usePalette('default')
+
+  if (!feedInfo) {
+    return <View />
+  }
+  return (
+    <View
+      style={[
+        {
+          borderTopWidth: 1,
+          paddingVertical: 20,
+          paddingHorizontal: 20,
+          gap: 12,
+        },
+        pal.border,
+      ]}>
+      {feedInfo.descriptionRT ? (
+        <RichText
+          testID="listDescription"
+          type="lg"
+          style={pal.text}
+          richText={feedInfo.descriptionRT}
+        />
+      ) : (
+        <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
+          No description
+        </Text>
+      )}
+      <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
+        <Button
+          type="default"
+          testID="toggleLikeBtn"
+          accessibilityLabel="Like this feed"
+          accessibilityHint=""
+          onPress={onToggleLiked}
+          style={{paddingHorizontal: 10}}>
+          {feedInfo?.isLiked ? (
+            <HeartIconSolid size={19} style={styles.liked} />
+          ) : (
+            <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
+          )}
+        </Button>
+        {typeof feedInfo.likeCount === 'number' && (
+          <TextLink
+            href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
+            text={`Liked by ${feedInfo.likeCount} ${pluralize(
+              feedInfo.likeCount,
+              'user',
+            )}`}
+            style={[pal.textLight, s.semiBold]}
+          />
+        )}
+      </View>
+      <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+        Created by{' '}
+        {feedInfo.isOwner ? (
+          'you'
+        ) : (
+          <TextLink
+            text={sanitizeHandle(feedInfo.creatorHandle, '@')}
+            href={makeProfileLink({
+              did: feedInfo.creatorDid,
+              handle: feedInfo.creatorHandle,
+            })}
+            style={pal.textLight}
+          />
+        )}
+      </Text>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    paddingVertical: 7,
+    paddingHorizontal: 14,
+    borderRadius: 50,
+    marginLeft: 6,
+  },
+  liked: {
+    color: colors.red3,
+  },
+  notFoundContainer: {
+    margin: 10,
+    paddingHorizontal: 18,
+    paddingVertical: 14,
+    borderRadius: 6,
+  },
+})