about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/personPlus_stroke2_corner0_rounded.svg1
-rw-r--r--src/components/Button.tsx2
-rw-r--r--src/components/dms/ConvoMenu.tsx8
-rw-r--r--src/components/icons/Person.tsx12
-rw-r--r--src/components/icons/PersonCheck.tsx5
-rw-r--r--src/components/icons/PersonX.tsx5
-rw-r--r--src/lib/statsig/events.ts2
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx17
-rw-r--r--src/view/com/post/Post.tsx17
-rw-r--r--src/view/com/posts/AviFollowButton.tsx115
-rw-r--r--src/view/com/posts/AviFollowButton.web.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx21
-rw-r--r--src/view/com/profile/ProfileMenu.tsx6
14 files changed, 177 insertions, 36 deletions
diff --git a/assets/icons/personPlus_stroke2_corner0_rounded.svg b/assets/icons/personPlus_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..118268bf9
--- /dev/null
+++ b/assets/icons/personPlus_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index e22faa060..982f42213 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -64,7 +64,7 @@ type NonTextElements =
 
 export type ButtonProps = Pick<
   PressableProps,
-  'disabled' | 'onPress' | 'testID' | 'onLongPress'
+  'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
 > &
   AccessibilityProps &
   VariantProps & {
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 79ca34f17..3f680120b 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -26,9 +26,11 @@ import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components
 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
-import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
-import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
-import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
+import {
+  Person_Stroke2_Corner0_Rounded as Person,
+  PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
+  PersonX_Stroke2_Corner0_Rounded as PersonX,
+} from '#/components/icons/Person'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
 import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx
index 6d09148c9..7fceada53 100644
--- a/src/components/icons/Person.tsx
+++ b/src/components/icons/Person.tsx
@@ -3,3 +3,15 @@ import {createSinglePathSVG} from './TEMPLATE'
 export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
 })
+
+export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
+})
+
+export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
+})
+
+export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/PersonCheck.tsx b/src/components/icons/PersonCheck.tsx
deleted file mode 100644
index 097271d89..000000000
--- a/src/components/icons/PersonCheck.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import {createSinglePathSVG} from './TEMPLATE'
-
-export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
-  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
-})
diff --git a/src/components/icons/PersonX.tsx b/src/components/icons/PersonX.tsx
deleted file mode 100644
index a015e1376..000000000
--- a/src/components/icons/PersonX.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import {createSinglePathSVG} from './TEMPLATE'
-
-export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
-  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
-})
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 42f4737f8..00444c18c 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -115,6 +115,7 @@ export type LogEvents = {
       | 'ProfileHeaderSuggestedFollows'
       | 'ProfileMenu'
       | 'ProfileHoverCard'
+      | 'AvatarButton'
   }
   'profile:unfollow': {
     logContext:
@@ -126,6 +127,7 @@ export type LogEvents = {
       | 'ProfileMenu'
       | 'ProfileHoverCard'
       | 'Chat'
+      | 'AvatarButton'
   }
   'chat:create': {
     logContext: 'ProfileHeader' | 'NewChatDialog'
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 2721871f3..4481935f7 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,4 +1,5 @@
 export type Gate =
   // Keep this alphabetic please.
   | 'request_notifications_permission_after_onboarding_v2'
+  | 'show_avi_follow_button'
   | 'show_follow_back_label_v2'
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 9d2985f15..981b4e72f 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -40,6 +40,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {PostAlerts} from '../../../components/moderation/PostAlerts'
 import {PostHider} from '../../../components/moderation/PostHider'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
+import {AviFollowButton} from '../posts/AviFollowButton'
 import {WhoCanReply} from '../threadgate/WhoCanReply'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Link, TextLink} from '../util/Link'
@@ -470,12 +471,16 @@ let PostThreadItemLoaded = ({
               {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
               {!isThreadedChild && (
                 <View style={styles.layoutAvi}>
-                  <PreviewableUserAvatar
-                    size={38}
-                    profile={post.author}
-                    moderation={moderation.ui('avatar')}
-                    type={post.author.associated?.labeler ? 'labeler' : 'user'}
-                  />
+                  <AviFollowButton author={post.author} moderation={moderation}>
+                    <PreviewableUserAvatar
+                      size={38}
+                      profile={post.author}
+                      moderation={moderation.ui('avatar')}
+                      type={
+                        post.author.associated?.labeler ? 'labeler' : 'user'
+                      }
+                    />
+                  </AviFollowButton>
 
                   {showChildReplyLine && (
                     <View
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 1a7185cd9..f666e2968 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -22,6 +22,7 @@ import {makeProfileLink} from 'lib/routes/links'
 import {countLines} from 'lib/strings/helpers'
 import {colors, s} from 'lib/styles'
 import {precacheProfile} from 'state/queries/profile'
+import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
 import {atoms as a} from '#/alf'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {RichText} from '#/components/RichText'
@@ -146,12 +147,14 @@ function PostInner({
       {showReplyLine && <View style={styles.replyLine} />}
       <View style={styles.layout}>
         <View style={styles.layoutAvi}>
-          <PreviewableUserAvatar
-            size={52}
-            profile={post.author}
-            moderation={moderation.ui('avatar')}
-            type={post.author.associated?.labeler ? 'labeler' : 'user'}
-          />
+          <AviFollowButton author={post.author} moderation={moderation}>
+            <PreviewableUserAvatar
+              size={52}
+              profile={post.author}
+              moderation={moderation.ui('avatar')}
+              type={post.author.associated?.labeler ? 'labeler' : 'user'}
+            />
+          </AviFollowButton>
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
@@ -245,9 +248,9 @@ const styles = StyleSheet.create({
   },
   layout: {
     flexDirection: 'row',
+    gap: 10,
   },
   layoutAvi: {
-    width: 70,
     paddingLeft: 8,
   },
   layoutContent: {
diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx
new file mode 100644
index 000000000..9358967e1
--- /dev/null
+++ b/src/view/com/posts/AviFollowButton.tsx
@@ -0,0 +1,115 @@
+import React, {useState} from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {createHitslop} from '#/lib/constants'
+import {NavigationProp} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import {
+  DropdownItem,
+  NativeDropdown,
+} from '#/view/com/util/forms/NativeDropdown'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useFollowMethods} from '#/components/hooks/useFollowMethods'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+
+export function AviFollowButton({
+  author,
+  moderation,
+  children,
+}: {
+  author: AppBskyActorDefs.ProfileViewBasic
+  moderation: ModerationDecision
+  children: React.ReactNode
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const profile = useProfileShadow(author)
+  const {follow} = useFollowMethods({
+    profile: profile,
+    logContext: 'AvatarButton',
+  })
+  const gate = useGate()
+  const {currentAccount, hasSession} = useSession()
+  const [followed, setFollowed] = useState<string | null>(null)
+  const navigation = useNavigation<NavigationProp>()
+
+  const name = sanitizeDisplayName(
+    profile.displayName || profile.handle,
+    moderation.ui('displayName'),
+  )
+  const isFollowing =
+    profile.viewer?.following ||
+    profile.did === followed ||
+    profile.did === currentAccount?.did
+
+  function onPress() {
+    follow()
+    setFollowed(profile.did)
+    Toast.show(_(msg`Following ${name}`))
+  }
+
+  const items: DropdownItem[] = [
+    {
+      label: _(msg`View profile`),
+      onPress: () => {
+        navigation.navigate('Profile', {name: profile.did})
+      },
+      icon: {
+        ios: {
+          name: 'arrow.up.right.square',
+        },
+        android: '',
+        web: ['far', 'arrow-up-right-from-square'],
+      },
+    },
+    {
+      label: _(msg`Follow ${name}`),
+      onPress: onPress,
+      icon: {
+        ios: {
+          name: 'person.badge.plus',
+        },
+        android: '',
+        web: ['far', 'user-plus'],
+      },
+    },
+  ]
+
+  return hasSession && gate('show_avi_follow_button') ? (
+    <View style={a.relative}>
+      {children}
+
+      {!isFollowing && (
+        <Button
+          label={_(msg`Open ${name} profile shortcut menu`)}
+          hitSlop={createHitslop(3)}
+          style={[
+            a.rounded_full,
+            t.atoms.bg_contrast_975,
+            a.absolute,
+            {
+              bottom: -2,
+              right: -2,
+              borderWidth: 1,
+              borderColor: t.atoms.bg.backgroundColor,
+            },
+          ]}>
+          <NativeDropdown items={items}>
+            <Plus size="sm" fill={t.atoms.bg.backgroundColor} />
+          </NativeDropdown>
+        </Button>
+      )}
+    </View>
+  ) : (
+    children
+  )
+}
diff --git a/src/view/com/posts/AviFollowButton.web.tsx b/src/view/com/posts/AviFollowButton.web.tsx
new file mode 100644
index 000000000..6ad3c9f1f
--- /dev/null
+++ b/src/view/com/posts/AviFollowButton.web.tsx
@@ -0,0 +1 @@
+export {Fragment as AviFollowButton} from 'react'
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 8077c2968..b10ffe19f 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -41,6 +41,7 @@ import {PostEmbeds} from '../util/post-embeds'
 import {PostMeta} from '../util/PostMeta'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
+import {AviFollowButton} from './AviFollowButton'
 
 interface FeedItemProps {
   record: AppBskyFeedPost.Record
@@ -284,13 +285,15 @@ let FeedItemInner = ({
 
       <View style={styles.layout}>
         <View style={styles.layoutAvi}>
-          <PreviewableUserAvatar
-            size={52}
-            profile={post.author}
-            moderation={moderation.ui('avatar')}
-            type={post.author.associated?.labeler ? 'labeler' : 'user'}
-            onBeforePress={onOpenAuthor}
-          />
+          <AviFollowButton author={post.author} moderation={moderation}>
+            <PreviewableUserAvatar
+              size={52}
+              profile={post.author}
+              moderation={moderation.ui('avatar')}
+              type={post.author.associated?.labeler ? 'labeler' : 'user'}
+              onBeforePress={onOpenAuthor}
+            />
+          </AviFollowButton>
           {isThreadParent && (
             <View
               style={[
@@ -470,9 +473,13 @@ const styles = StyleSheet.create({
   },
   layoutAvi: {
     paddingLeft: 8,
+    position: 'relative',
+    zIndex: 999,
   },
   layoutContent: {
+    position: 'relative',
     flex: 1,
+    zIndex: 0,
   },
   alert: {
     marginTop: 6,
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index e3357ec1e..5d39e5f0f 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -30,8 +30,10 @@ import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
-import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
-import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
+import {
+  PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
+  PersonX_Stroke2_Corner0_Rounded as PersonX,
+} from '#/components/icons/Person'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
 import * as Menu from '#/components/Menu'