about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx154
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx226
2 files changed, 254 insertions, 126 deletions
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
new file mode 100644
index 000000000..e5b747cc9
--- /dev/null
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -0,0 +1,154 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {logger} from '#/logger'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Shadow, useProfileShadow} from 'state/cache/profile-shadow'
+import {track} from 'lib/analytics/analytics'
+import {
+  useProfileFollowMutationQueue,
+  useProfileQuery,
+} from 'state/queries/profile'
+import {useRequireAuth} from 'state/session'
+
+export function PostThreadFollowBtn({did}: {did: string}) {
+  const {data: profile, isLoading} = useProfileQuery({did})
+
+  // We will never hit this - the profile will always be cached or loaded above
+  // but it keeps the typechecker happy
+  if (isLoading || !profile) return null
+
+  return <PostThreadFollowBtnLoaded profile={profile} />
+}
+
+function PostThreadFollowBtnLoaded({
+  profile: profileUnshadowed,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+  const navigation = useNavigation()
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
+    useProfileShadow(profileUnshadowed)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+  const requireAuth = useRequireAuth()
+
+  const isFollowing = !!profile.viewer?.following
+  const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
+
+  // This prevents the button from disappearing as soon as we follow.
+  const showFollowBtn = React.useMemo(
+    () => !isFollowing || !wasFollowing,
+    [isFollowing, wasFollowing],
+  )
+
+  /**
+   * We want this button to stay visible even after following, so that the user can unfollow if they want.
+   * However, we need it to disappear after we push to a screen and then come back. We also need it to
+   * show up if we view the post while following, go to the profile and unfollow, then come back to the
+   * post.
+   *
+   * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
+   * we could do this only on focus because the transition animation gives us time to not notice the
+   * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
+   * button renders. So, we update the state in both cases.
+   */
+  React.useEffect(() => {
+    const updateWasFollowing = () => {
+      if (wasFollowing !== isFollowing) {
+        setWasFollowing(isFollowing)
+      }
+    }
+
+    const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
+    const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
+
+    return () => {
+      unsubscribeFocus()
+      unsubscribeBlur()
+    }
+  }, [isFollowing, wasFollowing, navigation])
+
+  const onPress = React.useCallback(() => {
+    if (!isFollowing) {
+      requireAuth(async () => {
+        try {
+          track('ProfileHeader:FollowButtonClicked')
+          await queueFollow()
+        } catch (e: any) {
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to follow', {message: String(e)})
+            Toast.show(_(msg`There was an issue! ${e.toString()}`))
+          }
+        }
+      })
+    } else {
+      requireAuth(async () => {
+        try {
+          track('ProfileHeader:UnfollowButtonClicked')
+          await queueUnfollow()
+        } catch (e: any) {
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to unfollow', {message: String(e)})
+            Toast.show(_(msg`There was an issue! ${e.toString()}`))
+          }
+        }
+      })
+    }
+  }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
+
+  if (!showFollowBtn) return null
+
+  return (
+    <View style={{width: isTabletOrDesktop ? 130 : 120}}>
+      <View style={styles.btnOuter}>
+        <TouchableOpacity
+          testID="followBtn"
+          onPress={onPress}
+          style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Follow ${profile.handle}`)}
+          accessibilityHint={_(
+            msg`Shows posts from ${profile.handle} in your feed`,
+          )}>
+          {isTabletOrDesktop && (
+            <FontAwesomeIcon
+              icon={!isFollowing ? 'plus' : 'check'}
+              style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
+            />
+          )}
+          <Text
+            type="button"
+            style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
+            numberOfLines={1}>
+            {!isFollowing ? <Trans>Follow</Trans> : <Trans>Following</Trans>}
+          </Text>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  btnOuter: {
+    marginLeft: 'auto',
+  },
+  btn: {
+    flexDirection: 'row',
+    borderRadius: 50,
+    paddingVertical: 8,
+    paddingHorizontal: 14,
+  },
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index d3ca6f356..17528b14e 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -9,6 +9,7 @@ import {
 } from '@atproto/api'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {Link, TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {Text} from '../util/text/Text'
@@ -30,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {formatCount} from '../util/numeric/format'
-import {TimeElapsed} from 'view/com/util/TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {MAX_POST_LINES} from 'lib/constants'
@@ -42,6 +42,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {ThreadPost} from '#/state/queries/post-thread'
+import {useSession} from 'state/session'
 import {WhoCanReply} from '../threadgate/WhoCanReply'
 
 export function PostThreadItem({
@@ -113,7 +114,6 @@ export function PostThreadItem({
 }
 
 function PostThreadItemDeleted() {
-  const styles = useStyles()
   const pal = usePalette('default')
   return (
     <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
@@ -163,7 +163,7 @@ let PostThreadItemLoaded = ({
   const [limitLines, setLimitLines] = React.useState(
     () => countLines(richText?.text) >= MAX_POST_LINES,
   )
-  const styles = useStyles()
+  const {currentAccount} = useSession()
   const hasEngagement = post.likeCount || post.repostCount
 
   const rootUri = record.reply?.root?.uri || post.uri
@@ -249,7 +249,7 @@ let PostThreadItemLoaded = ({
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
           accessible={false}>
           <PostSandboxWarning />
-          <View style={styles.layout}>
+          <View style={[styles.layout]}>
             <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
               <PreviewableUserAvatar
                 size={42}
@@ -262,33 +262,18 @@ let PostThreadItemLoaded = ({
             <View style={styles.layoutContent}>
               <View
                 style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
-                <View style={[s.flexRow]}>
-                  <Link
-                    style={styles.metaItem}
-                    href={authorHref}
-                    title={authorTitle}>
-                    <Text
-                      type="xl-bold"
-                      style={[pal.text]}
-                      numberOfLines={1}
-                      lineHeight={1.2}>
-                      {sanitizeDisplayName(
-                        post.author.displayName ||
-                          sanitizeHandle(post.author.handle),
-                      )}
-                    </Text>
-                  </Link>
-                  <TimeElapsed timestamp={post.indexedAt}>
-                    {({timeElapsed}) => (
-                      <Text
-                        type="md"
-                        style={[styles.metaItem, pal.textLight]}
-                        title={niceDate(post.indexedAt)}>
-                        &middot;&nbsp;{timeElapsed}
-                      </Text>
+                <Link style={s.flex1} href={authorHref} title={authorTitle}>
+                  <Text
+                    type="xl-bold"
+                    style={[pal.text]}
+                    numberOfLines={1}
+                    lineHeight={1.2}>
+                    {sanitizeDisplayName(
+                      post.author.displayName ||
+                        sanitizeHandle(post.author.handle),
                     )}
-                  </TimeElapsed>
-                </View>
+                  </Text>
+                </Link>
               </View>
               <View style={styles.meta}>
                 {isAuthorMuted && (
@@ -315,16 +300,16 @@ let PostThreadItemLoaded = ({
                     </Text>
                   </View>
                 )}
-                <Link
-                  style={styles.metaItem}
-                  href={authorHref}
-                  title={authorTitle}>
+                <Link style={s.flex1} href={authorHref} title={authorTitle}>
                   <Text type="md" style={[pal.textLight]} numberOfLines={1}>
                     {sanitizeHandle(post.author.handle, '@')}
                   </Text>
                 </Link>
               </View>
             </View>
+            {currentAccount?.did !== post.author.did && (
+              <PostThreadFollowBtn did={post.author.did} />
+            )}
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
             <ContentHider
@@ -626,7 +611,6 @@ function PostOuterWrapper({
 }>) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
-  const styles = useStyles()
   if (treeView && depth > 0) {
     return (
       <View
@@ -703,94 +687,84 @@ function ExpandedPostDetails({
   )
 }
 
-const useStyles = () => {
-  const {isDesktop} = useWebMediaQueries()
-  return StyleSheet.create({
-    outer: {
-      borderTopWidth: 1,
-      paddingLeft: 8,
-    },
-    outerHighlighted: {
-      paddingTop: 16,
-      paddingLeft: 8,
-      paddingRight: 8,
-    },
-    noTopBorder: {
-      borderTopWidth: 0,
-    },
-    layout: {
-      flexDirection: 'row',
-      gap: 10,
-      paddingLeft: 8,
-    },
-    layoutAvi: {},
-    layoutContent: {
-      flex: 1,
-      paddingRight: 10,
-    },
-    meta: {
-      flexDirection: 'row',
-      paddingTop: 2,
-      paddingBottom: 2,
-    },
-    metaExpandedLine1: {
-      paddingTop: 0,
-      paddingBottom: 0,
-    },
-    metaItem: {
-      paddingRight: 5,
-      maxWidth: isDesktop ? 380 : 220,
-    },
-    alert: {
-      marginBottom: 6,
-    },
-    postTextContainer: {
-      flexDirection: 'row',
-      alignItems: 'center',
-      flexWrap: 'wrap',
-      paddingBottom: 4,
-      paddingRight: 10,
-    },
-    postTextLargeContainer: {
-      paddingHorizontal: 0,
-      paddingRight: 0,
-      paddingBottom: 10,
-    },
-    translateLink: {
-      marginBottom: 6,
-    },
-    contentHider: {
-      marginBottom: 6,
-    },
-    contentHiderChild: {
-      marginTop: 6,
-    },
-    expandedInfo: {
-      flexDirection: 'row',
-      padding: 10,
-      borderTopWidth: 1,
-      borderBottomWidth: 1,
-      marginTop: 5,
-      marginBottom: 15,
-    },
-    expandedInfoItem: {
-      marginRight: 10,
-    },
-    loadMore: {
-      flexDirection: 'row',
-      alignItems: 'center',
-      justifyContent: 'flex-start',
-      gap: 4,
-      paddingHorizontal: 20,
-    },
-    replyLine: {
-      width: 2,
-      marginLeft: 'auto',
-      marginRight: 'auto',
-    },
-    cursor: {
-      // @ts-ignore web only
-      cursor: 'pointer',
-    },
-  })
-}
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    paddingLeft: 8,
+  },
+  outerHighlighted: {
+    paddingTop: 16,
+    paddingLeft: 8,
+    paddingRight: 8,
+  },
+  noTopBorder: {
+    borderTopWidth: 0,
+  },
+  layout: {
+    flexDirection: 'row',
+    paddingHorizontal: 8,
+  },
+  layoutAvi: {},
+  layoutContent: {
+    flex: 1,
+    marginLeft: 10,
+  },
+  meta: {
+    flexDirection: 'row',
+    paddingVertical: 2,
+  },
+  metaExpandedLine1: {
+    paddingVertical: 0,
+  },
+  alert: {
+    marginBottom: 6,
+  },
+  postTextContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flexWrap: 'wrap',
+    paddingBottom: 4,
+    paddingRight: 10,
+  },
+  postTextLargeContainer: {
+    paddingHorizontal: 0,
+    paddingRight: 0,
+    paddingBottom: 10,
+  },
+  translateLink: {
+    marginBottom: 6,
+  },
+  contentHider: {
+    marginBottom: 6,
+  },
+  contentHiderChild: {
+    marginTop: 6,
+  },
+  expandedInfo: {
+    flexDirection: 'row',
+    padding: 10,
+    borderTopWidth: 1,
+    borderBottomWidth: 1,
+    marginTop: 5,
+    marginBottom: 15,
+  },
+  expandedInfoItem: {
+    marginRight: 10,
+  },
+  loadMore: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'flex-start',
+    gap: 4,
+    paddingHorizontal: 20,
+  },
+  replyLine: {
+    width: 2,
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  cursor: {
+    // @ts-ignore web only
+    cursor: 'pointer',
+  },
+})