about summary refs log tree commit diff
path: root/src/components/KnownFollowers.tsx
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/components/KnownFollowers.tsx
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/components/KnownFollowers.tsx')
-rw-r--r--src/components/KnownFollowers.tsx200
1 files changed, 200 insertions, 0 deletions
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>
+  )
+}