about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-08-08 09:19:51 -0500
committerGitHub <noreply@github.com>2024-08-08 09:19:51 -0500
commit1e3b2d6f42839501ce47f88a19ffd477f1e2f82d (patch)
treead6d439aa5d61a8ebf69e72be6eed579caafcd3f
parentaf5262682eac63a54fb2f6351a5894b647251ab4 (diff)
downloadvoidsky-1e3b2d6f42839501ce47f88a19ffd477f1e2f82d.tar.zst
ALF suggested follows in profile header (#4828)
* Refactor ProfileHeaderSuggestedFollows

* Load fresh data every time

* Oops, missed a file

* Update ProfileCard.Link usage, tweak copy
-rw-r--r--src/lib/statsig/events.ts4
-rw-r--r--src/state/queries/suggested-follows.ts1
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx379
3 files changed, 155 insertions, 229 deletions
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 997a366a4..9a427ad40 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -159,6 +159,7 @@ export type LogEvents = {
       | 'AvatarButton'
       | 'StarterPackProfilesList'
       | 'FeedInterstitial'
+      | 'ProfileHeaderSuggestedFollows'
   }
   'profile:unfollow': {
     logContext:
@@ -173,6 +174,7 @@ export type LogEvents = {
       | 'AvatarButton'
       | 'StarterPackProfilesList'
       | 'FeedInterstitial'
+      | 'ProfileHeaderSuggestedFollows'
   }
   'chat:create': {
     logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -211,6 +213,8 @@ export type LogEvents = {
   'feed:interstitial:profileCard:press': {}
   'feed:interstitial:feedCard:press': {}
 
+  'profile:header:suggestedFollowsCard:press': {}
+
   'debug:followingPrefs': {
     followingShowRepliesFromPref: 'all' | 'following' | 'off'
     followingRepliesMinLikePref: number
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index a1244721a..f5d51a974 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
 export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
   const agent = useAgent()
   return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
+    gcTime: 0,
     queryKey: suggestedFollowsByActorQueryKey(did),
     queryFn: async () => {
       const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index c7df4d75b..356b3f09c 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -1,32 +1,60 @@
 import React from 'react'
-import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
-import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
+import {ScrollView, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {logEvent} from '#/lib/statsig/statsig'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
 import {isWeb} from 'platform/detection'
-import {Button} from 'view/com/util/forms/Button'
-import {Link} from 'view/com/util/Link'
-import {Text} from 'view/com/util/text/Text'
-import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
-import * as Toast from '../util/Toast'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
 
-const OUTER_PADDING = 10
-const INNER_PADDING = 14
-const TOTAL_HEIGHT = 250
+const OUTER_PADDING = a.p_md.padding
+const INNER_PADDING = a.p_lg.padding
+const TOTAL_HEIGHT = 232
+const MOBILE_CARD_WIDTH = 300
+
+function CardOuter({
+  children,
+  style,
+}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_lg,
+        a.rounded_md,
+        a.border,
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        {
+          width: MOBILE_CARD_WIDTH,
+        },
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function SuggestedFollowPlaceholder() {
+  const t = useTheme()
+  return (
+    <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
+      <ProfileCard.Header>
+        <ProfileCard.AvatarPlaceholder />
+        <ProfileCard.NameAndHandlePlaceholder />
+      </ProfileCard.Header>
+
+      <ProfileCard.DescriptionPlaceholder />
+    </CardOuter>
+  )
+}
 
 export function ProfileHeaderSuggestedFollows({
   actorDid,
@@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({
   actorDid: string
   requestDismiss: () => void
 }) {
-  const pal = usePalette('default')
-  const {isLoading, data} = useSuggestedFollowsByActorQuery({
-    did: actorDid,
-  })
+  const t = useTheme()
+  const {_} = useLingui()
+  const {isLoading: isSuggestionsLoading, data} =
+    useSuggestedFollowsByActorQuery({
+      did: actorDid,
+    })
+  const moderationOpts = useModerationOpts()
+  const isLoading = isSuggestionsLoading || !moderationOpts
+
   return (
     <View
       style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
       pointerEvents="box-none">
       <View
         pointerEvents="box-none"
-        style={{
-          backgroundColor: pal.viewLight.backgroundColor,
-          height: '100%',
-          paddingTop: INNER_PADDING / 2,
-        }}>
+        style={[
+          t.atoms.bg_contrast_25,
+          {
+            height: '100%',
+            paddingTop: INNER_PADDING / 2,
+          },
+        ]}>
         <View
           pointerEvents="box-none"
-          style={{
-            flexDirection: 'row',
-            justifyContent: 'space-between',
-            alignItems: 'center',
-            paddingTop: 4,
-            paddingBottom: INNER_PADDING / 2,
-            paddingLeft: INNER_PADDING,
-            paddingRight: INNER_PADDING / 2,
-          }}>
-          <Text type="sm-bold" style={[pal.textLight]}>
-            <Trans>Suggested for you</Trans>
+          style={[
+            a.flex_row,
+            a.justify_between,
+            a.align_center,
+            a.pt_xs,
+            {
+              paddingBottom: INNER_PADDING / 2,
+              paddingLeft: INNER_PADDING,
+              paddingRight: INNER_PADDING / 2,
+            },
+          ]}>
+          <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+            <Trans>Similar accounts</Trans>
           </Text>
 
-          <Pressable
-            accessibilityRole="button"
+          <Button
             onPress={requestDismiss}
             hitSlop={10}
-            style={{padding: INNER_PADDING / 2}}>
-            <FontAwesomeIcon
-              icon="x"
-              size={12}
-              style={pal.textLight as FontAwesomeIconStyle}
-            />
-          </Pressable>
+            label={_(msg`Dismiss`)}
+            size="xsmall"
+            variant="ghost"
+            color="secondary"
+            shape="round">
+            <ButtonIcon icon={X} size="sm" />
+          </Button>
         </View>
 
         <ScrollView
@@ -83,187 +119,72 @@ export function ProfileHeaderSuggestedFollows({
           showsHorizontalScrollIndicator={isWeb}
           persistentScrollbar={true}
           scrollIndicatorInsets={{bottom: 0}}
-          scrollEnabled={true}
-          contentContainerStyle={{
-            alignItems: 'flex-start',
-            paddingLeft: INNER_PADDING / 2,
-            paddingBottom: INNER_PADDING,
-          }}>
-          {isLoading ? (
-            <>
-              <SuggestedFollowSkeleton />
-              <SuggestedFollowSkeleton />
-              <SuggestedFollowSkeleton />
-              <SuggestedFollowSkeleton />
-              <SuggestedFollowSkeleton />
-              <SuggestedFollowSkeleton />
-            </>
-          ) : data ? (
-            data.suggestions
-              .filter(s => (s.associated?.labeler ? false : true))
-              .map(profile => (
-                <SuggestedFollow key={profile.did} profile={profile} />
-              ))
-          ) : (
-            <View />
-          )}
+          snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
+          decelerationRate="fast">
+          <View
+            style={[
+              a.flex_row,
+              a.gap_sm,
+              {
+                paddingHorizontal: INNER_PADDING,
+                paddingBottom: INNER_PADDING,
+              },
+            ]}>
+            {isLoading ? (
+              <>
+                <SuggestedFollowPlaceholder />
+                <SuggestedFollowPlaceholder />
+                <SuggestedFollowPlaceholder />
+                <SuggestedFollowPlaceholder />
+                <SuggestedFollowPlaceholder />
+              </>
+            ) : data ? (
+              data.suggestions
+                .filter(s => (s.associated?.labeler ? false : true))
+                .map(profile => (
+                  <ProfileCard.Link
+                    key={profile.did}
+                    profile={profile}
+                    onPress={() => {
+                      logEvent('profile:header:suggestedFollowsCard:press', {})
+                    }}
+                    style={[a.flex_1]}>
+                    {({hovered, pressed}) => (
+                      <CardOuter
+                        style={[
+                          a.flex_1,
+                          (hovered || pressed) && t.atoms.border_contrast_high,
+                        ]}>
+                        <ProfileCard.Outer>
+                          <ProfileCard.Header>
+                            <ProfileCard.Avatar
+                              profile={profile}
+                              moderationOpts={moderationOpts}
+                            />
+                            <ProfileCard.NameAndHandle
+                              profile={profile}
+                              moderationOpts={moderationOpts}
+                            />
+                            <ProfileCard.FollowButton
+                              profile={profile}
+                              moderationOpts={moderationOpts}
+                              logContext="ProfileHeaderSuggestedFollows"
+                              color="secondary_inverted"
+                              shape="round"
+                            />
+                          </ProfileCard.Header>
+                          <ProfileCard.Description profile={profile} />
+                        </ProfileCard.Outer>
+                      </CardOuter>
+                    )}
+                  </ProfileCard.Link>
+                ))
+            ) : (
+              <View />
+            )}
+          </View>
         </ScrollView>
       </View>
     </View>
   )
 }
-
-function SuggestedFollowSkeleton() {
-  const pal = usePalette('default')
-  return (
-    <View
-      style={[
-        styles.suggestedFollowCardOuter,
-        {
-          backgroundColor: pal.view.backgroundColor,
-        },
-      ]}>
-      <View
-        style={{
-          height: 60,
-          width: 60,
-          borderRadius: 60,
-          backgroundColor: pal.viewLight.backgroundColor,
-          opacity: 0.6,
-        }}
-      />
-      <View
-        style={{
-          height: 17,
-          width: 70,
-          borderRadius: 4,
-          backgroundColor: pal.viewLight.backgroundColor,
-          marginTop: 12,
-          marginBottom: 4,
-        }}
-      />
-      <View
-        style={{
-          height: 12,
-          width: 70,
-          borderRadius: 4,
-          backgroundColor: pal.viewLight.backgroundColor,
-          marginBottom: 12,
-          opacity: 0.6,
-        }}
-      />
-      <View
-        style={{
-          height: 32,
-          borderRadius: 32,
-          width: '100%',
-          backgroundColor: pal.viewLight.backgroundColor,
-        }}
-      />
-    </View>
-  )
-}
-
-function SuggestedFollow({
-  profile: profileUnshadowed,
-}: {
-  profile: AppBskyActorDefs.ProfileView
-}) {
-  const {track} = useAnalytics()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const moderationOpts = useModerationOpts()
-  const profile = useProfileShadow(profileUnshadowed)
-  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
-    profile,
-    'ProfileHeaderSuggestedFollows',
-  )
-
-  const onPressFollow = React.useCallback(async () => {
-    try {
-      track('ProfileHeader:SuggestedFollowFollowed')
-      await queueFollow()
-    } catch (e: any) {
-      if (e?.name !== 'AbortError') {
-        Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
-      }
-    }
-  }, [queueFollow, track, _])
-
-  const onPressUnfollow = React.useCallback(async () => {
-    try {
-      await queueUnfollow()
-    } catch (e: any) {
-      if (e?.name !== 'AbortError') {
-        Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
-      }
-    }
-  }, [queueUnfollow, _])
-
-  if (!moderationOpts) {
-    return null
-  }
-  const moderation = moderateProfile(profile, moderationOpts)
-  const following = profile.viewer?.following
-  return (
-    <Link
-      href={makeProfileLink(profile)}
-      title={profile.handle}
-      asAnchor
-      anchorNoUnderline>
-      <View
-        style={[
-          styles.suggestedFollowCardOuter,
-          {
-            backgroundColor: pal.view.backgroundColor,
-          },
-        ]}>
-        <PreviewableUserAvatar
-          size={60}
-          profile={profile}
-          avatar={profile.avatar}
-          moderation={moderation.ui('avatar')}
-        />
-
-        <View style={{width: '100%', paddingVertical: 12}}>
-          <Text
-            type="xs-medium"
-            style={[pal.text, {textAlign: 'center'}]}
-            numberOfLines={1}>
-            {sanitizeDisplayName(
-              profile.displayName || sanitizeHandle(profile.handle),
-              moderation.ui('displayName'),
-            )}
-          </Text>
-          <Text
-            type="xs-medium"
-            style={[pal.textLight, {textAlign: 'center'}]}
-            numberOfLines={1}>
-            {sanitizeHandle(profile.handle, '@')}
-          </Text>
-        </View>
-
-        <Button
-          label={following ? _(msg`Unfollow`) : _(msg`Follow`)}
-          type="inverted"
-          labelStyle={{textAlign: 'center'}}
-          onPress={following ? onPressUnfollow : onPressFollow}
-        />
-      </View>
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  suggestedFollowCardOuter: {
-    marginHorizontal: INNER_PADDING / 2,
-    paddingTop: 10,
-    paddingBottom: 12,
-    paddingHorizontal: 10,
-    borderRadius: 8,
-    width: 130,
-    alignItems: 'center',
-    overflow: 'hidden',
-    flexShrink: 1,
-  },
-})