about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-06-11 17:42:28 -0500
committerGitHub <noreply@github.com>2024-06-11 15:42:28 -0700
commitbb0a6a4b6c4e86c62d599c424dae35c9ee9d200d (patch)
treedc4732574140d62efe2bb205193c93996b05a98e /src
parent7011ac8f72ed18153ea485b6cce2e18040de2dc9 (diff)
downloadvoidsky-bb0a6a4b6c4e86c62d599c424dae35c9ee9d200d.tar.zst
Add KnownFollowers component to standard profile header (#4420)
* Add KnownFollowers component to standard profile header

* Prep for known followers screen

* Add known followers screen

* Tighten space

* Add pressed state

* Edit title

* Vertically center

* Don't show if no known followers

* Bump sdk

* Use actual followers.length to show

* Updates to show logic, space

* Prevent fresh data from applying to cached screens

* Tighten space

* Better label

* Oxford comma

* Fix count logic

* Add bskyweb route

* Useless ternary

* Minor spacing tweak

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx8
-rw-r--r--src/components/KnownFollowers.tsx200
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx14
-rw-r--r--src/screens/Profile/Header/Shell.tsx2
-rw-r--r--src/screens/Profile/KnownFollowers.tsx134
-rw-r--r--src/state/queries/known-followers.ts34
8 files changed, 393 insertions, 1 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 8f8855d67..67b89e262 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -41,6 +41,7 @@ import {PreferencesThreads} from 'view/screens/PreferencesThreads'
 import {SavedFeeds} from 'view/screens/SavedFeeds'
 import HashtagScreen from '#/screens/Hashtag'
 import {ModerationScreen} from '#/screens/Moderation'
+import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
 import {init as initAnalytics} from './lib/analytics/analytics'
 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
@@ -170,6 +171,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         })}
       />
       <Stack.Screen
+        name="ProfileKnownFollowers"
+        getComponent={() => ProfileKnownFollowersScreen}
+        options={({route}) => ({
+          title: title(msg`Followers of @${route.params.name} that you know`),
+        })}
+      />
+      <Stack.Screen
         name="ProfileList"
         getComponent={() => ProfileListScreen}
         options={{title: title(msg`List`), requireAuth: true}}
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
new file mode 100644
index 000000000..b99fe3398
--- /dev/null
+++ b/src/components/KnownFollowers.tsx
@@ -0,0 +1,200 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {msg, plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+const AVI_SIZE = 30
+const AVI_BORDER = 1
+
+/**
+ * Shared logic to determine if `KnownFollowers` should be shown.
+ *
+ * Checks the # of actual returned users instead of the `count` value, because
+ * `count` includes blocked users and `followers` does not.
+ */
+export function shouldShowKnownFollowers(
+  knownFollowers?: AppBskyActorDefs.KnownFollowers,
+) {
+  return knownFollowers && knownFollowers.followers.length > 0
+}
+
+export function KnownFollowers({
+  profile,
+  moderationOpts,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+}) {
+  const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
+    new Map(),
+  )
+
+  /*
+   * Results for `knownFollowers` are not sorted consistently, so when
+   * revalidating we can see a flash of this data updating. This cache prevents
+   * this happening for screens that remain in memory. When pushing a new
+   * screen, or once this one is popped, this cache is empty, so new data is
+   * displayed.
+   */
+  if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
+    cache.current.set(profile.did, profile.viewer.knownFollowers)
+  }
+
+  const cachedKnownFollowers = cache.current.get(profile.did)
+
+  if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
+    return (
+      <KnownFollowersInner
+        profile={profile}
+        cachedKnownFollowers={cachedKnownFollowers}
+        moderationOpts={moderationOpts}
+      />
+    )
+  }
+
+  return null
+}
+
+function KnownFollowersInner({
+  profile,
+  moderationOpts,
+  cachedKnownFollowers,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+  cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const textStyle = [
+    a.flex_1,
+    a.text_sm,
+    a.leading_snug,
+    t.atoms.text_contrast_medium,
+  ]
+
+  // list of users, minus blocks
+  const returnedCount = cachedKnownFollowers.followers.length
+  // db count, includes blocks
+  const fullCount = cachedKnownFollowers.count
+  // knownFollowers can return up to 5 users, but will exclude blocks
+  // therefore, if we have less 5 users, use whichever count is lower
+  const count =
+    returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount
+
+  const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
+    const moderation = moderateProfile(f, moderationOpts)
+    return {
+      profile: {
+        ...f,
+        displayName: sanitizeDisplayName(
+          f.displayName || f.handle,
+          moderation.ui('displayName'),
+        ),
+      },
+      moderation,
+    }
+  })
+
+  return (
+    <Link
+      label={_(
+        msg`Press to view followers of this account that you also follow`,
+      )}
+      to={makeProfileLink(profile, 'known-followers')}
+      style={[
+        a.flex_1,
+        a.flex_row,
+        a.gap_md,
+        a.align_center,
+        {marginLeft: -AVI_BORDER},
+      ]}>
+      {({hovered, pressed}) => (
+        <>
+          <View
+            style={[
+              {
+                height: AVI_SIZE,
+                width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
+              },
+              pressed && {
+                opacity: 0.5,
+              },
+            ]}>
+            {slice.map(({profile: prof, moderation}, i) => (
+              <View
+                key={prof.did}
+                style={[
+                  a.absolute,
+                  a.rounded_full,
+                  {
+                    borderWidth: AVI_BORDER,
+                    borderColor: t.atoms.bg.backgroundColor,
+                    width: AVI_SIZE + AVI_BORDER * 2,
+                    height: AVI_SIZE + AVI_BORDER * 2,
+                    left: i * a.gap_md.gap,
+                    zIndex: AVI_BORDER - i,
+                  },
+                ]}>
+                <UserAvatar
+                  size={AVI_SIZE}
+                  avatar={prof.avatar}
+                  moderation={moderation.ui('avatar')}
+                />
+              </View>
+            ))}
+          </View>
+
+          <Text
+            style={[
+              textStyle,
+              hovered && {
+                textDecorationLine: 'underline',
+                textDecorationColor: t.atoms.text_contrast_medium.color,
+              },
+              pressed && {
+                opacity: 0.5,
+              },
+            ]}
+            numberOfLines={2}>
+            <Trans>Followed by</Trans>{' '}
+            {count > 2 ? (
+              <>
+                {slice.slice(0, 2).map(({profile: prof}, i) => (
+                  <Text key={prof.did} style={textStyle}>
+                    {prof.displayName}
+                    {i === 0 && ', '}
+                  </Text>
+                ))}
+                {', '}
+                {plural(count - 2, {
+                  one: 'and # other',
+                  other: 'and # others',
+                })}
+              </>
+            ) : count === 2 ? (
+              slice.map(({profile: prof}, i) => (
+                <Text key={prof.did} style={textStyle}>
+                  {prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''}
+                </Text>
+              ))
+            ) : (
+              <Text key={slice[0].profile.did} style={textStyle}>
+                {slice[0].profile.displayName}
+              </Text>
+            )}
+          </Text>
+        </>
+      )}
+    </Link>
+  )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index caa861b6e..403c2bb67 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -15,6 +15,7 @@ export type CommonNavigatorParams = {
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
   ProfileFollows: {name: string}
+  ProfileKnownFollowers: {name: string}
   ProfileList: {name: string; rkey: string}
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
diff --git a/src/routes.ts b/src/routes.ts
index 6845cccd0..de711f5dc 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -15,6 +15,7 @@ export const router = new Router({
   Profile: ['/profile/:name', '/profile/:name/rss'],
   ProfileFollowers: '/profile/:name/followers',
   ProfileFollows: '/profile/:name/follows',
+  ProfileKnownFollowers: '/profile/:name/known-followers',
   ProfileList: '/profile/:name/lists/:rkey',
   PostThread: '/profile/:name/post/:rkey',
   PostLikedBy: '/profile/:name/post/:rkey/liked-by',
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index f4b8d7705..f8a87a68e 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -30,6 +30,10 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {
+  KnownFollowers,
+  shouldShowKnownFollowers,
+} from '#/components/KnownFollowers'
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
 import {ProfileHeaderDisplayName} from './DisplayName'
@@ -268,6 +272,16 @@ let ProfileHeaderStandard = ({
                 />
               </View>
             ) : undefined}
+
+            {!isMe &&
+              shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+                <View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
+                  <KnownFollowers
+                    profile={profile}
+                    moderationOpts={moderationOpts}
+                  />
+                </View>
+              )}
           </>
         )}
       </View>
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 553b38a3b..82cba1704 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -83,7 +83,7 @@ let ProfileHeaderShell = ({
 
       {!isPlaceholderProfile && (
         <View
-          style={[a.px_lg, a.pb_sm]}
+          style={[a.px_lg, a.py_xs]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
           {isMe ? (
             <LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
diff --git a/src/screens/Profile/KnownFollowers.tsx b/src/screens/Profile/KnownFollowers.tsx
new file mode 100644
index 000000000..5cb45a11e
--- /dev/null
+++ b/src/screens/Profile/KnownFollowers.tsx
@@ -0,0 +1,134 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {useProfileKnownFollowersQuery} from '#/state/queries/known-followers'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {List} from '#/view/com/util/List'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {
+  ListFooter,
+  ListHeaderDesktop,
+  ListMaybePlaceholder,
+} from '#/components/Lists'
+
+function renderItem({item}: {item: AppBskyActorDefs.ProfileViewBasic}) {
+  return <ProfileCardWithFollowBtn key={item.did} profile={item} />
+}
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
+  return item.did
+}
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'ProfileKnownFollowers'
+>
+export const ProfileKnownFollowersScreen = ({route}: Props) => {
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const initialNumToRender = useInitialNumToRender()
+
+  const {name} = route.params
+
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedDid,
+    isLoading: isDidLoading,
+    error: resolveError,
+  } = useResolveDidQuery(route.params.name)
+  const {
+    data,
+    isLoading: isFollowersLoading,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    error,
+    refetch,
+  } = useProfileKnownFollowersQuery(resolvedDid)
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh followers', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetchingNextPage || !hasNextPage || !!error) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more followers', {message: err})
+    }
+  }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
+
+  const followers = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.followers)
+    }
+    return []
+  }, [data])
+
+  const isError = Boolean(resolveError || error)
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  if (followers.length < 1) {
+    return (
+      <ListMaybePlaceholder
+        isLoading={isDidLoading || isFollowersLoading}
+        isError={isError}
+        emptyType="results"
+        emptyMessage={_(msg`You don't follow any users who follow @${name}.`)}
+        errorMessage={cleanError(resolveError || error)}
+        onRetry={isError ? refetch : undefined}
+      />
+    )
+  }
+
+  return (
+    <View style={{flex: 1}}>
+      <ViewHeader title={_(msg`Followers you know`)} />
+      <List
+        data={followers}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
+        onEndReached={onEndReached}
+        onEndReachedThreshold={4}
+        ListHeaderComponent={
+          <ListHeaderDesktop title={_(msg`Followers you know`)} />
+        }
+        ListFooterComponent={
+          <ListFooter
+            isFetchingNextPage={isFetchingNextPage}
+            error={cleanError(error)}
+            onRetry={fetchNextPage}
+          />
+        }
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+        initialNumToRender={initialNumToRender}
+        windowSize={11}
+      />
+    </View>
+  )
+}
diff --git a/src/state/queries/known-followers.ts b/src/state/queries/known-followers.ts
new file mode 100644
index 000000000..adcbf4b50
--- /dev/null
+++ b/src/state/queries/known-followers.ts
@@ -0,0 +1,34 @@
+import {AppBskyGraphGetKnownFollowers} from '@atproto/api'
+import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+
+const PAGE_SIZE = 50
+type RQPageParam = string | undefined
+
+const RQKEY_ROOT = 'profile-known-followers'
+export const RQKEY = (did: string) => [RQKEY_ROOT, did]
+
+export function useProfileKnownFollowersQuery(did: string | undefined) {
+  const agent = useAgent()
+  return useInfiniteQuery<
+    AppBskyGraphGetKnownFollowers.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did || ''),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getKnownFollowers({
+        actor: did!,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+    enabled: !!did,
+  })
+}