about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/ProfileCard.tsx309
1 files changed, 250 insertions, 59 deletions
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index a0d222854..a6ca7627b 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -1,20 +1,32 @@
 import React from 'react'
-import {View} from 'react-native'
-import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {GestureResponderEvent, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ModerationOpts,
+  RichText as RichTextApi,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {useProfileShadow} from 'state/cache/profile-shadow'
 import {useSession} from 'state/session'
-import {FollowButton} from 'view/com/profile/FollowButton'
+import * as Toast from '#/view/com/util/Toast'
 import {ProfileCardPills} from 'view/com/profile/ProfileCard'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
-import {Link} from '#/components/Link'
+import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {RichText} from '#/components/RichText'
 import {Text} from '#/components/Typography'
 
 export function Default({
-  profile: profileUnshadowed,
+  profile,
   moderationOpts,
   logContext = 'ProfileCard',
 }: {
@@ -22,70 +34,249 @@ export function Default({
   moderationOpts: ModerationOpts
   logContext?: 'ProfileCard' | 'StarterPackProfilesList'
 }) {
-  const t = useTheme()
-  const {currentAccount, hasSession} = useSession()
+  return (
+    <Link did={profile.did}>
+      <Card
+        profile={profile}
+        moderationOpts={moderationOpts}
+        logContext={logContext}
+      />
+    </Link>
+  )
+}
 
-  const profile = useProfileShadow(profileUnshadowed)
-  const name = createSanitizedDisplayName(profile)
-  const handle = `@${sanitizeHandle(profile.handle)}`
+export function Card({
+  profile,
+  moderationOpts,
+  logContext = 'ProfileCard',
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+  logContext?: 'ProfileCard' | 'StarterPackProfilesList'
+}) {
   const moderation = moderateProfile(profile, moderationOpts)
 
   return (
-    <Wrapper did={profile.did}>
-      <View style={[a.flex_row, a.gap_sm]}>
-        <UserAvatar
-          size={42}
-          avatar={profile.avatar}
-          type={
-            profile.associated?.labeler
-              ? 'labeler'
-              : profile.associated?.feedgens
-              ? 'algo'
-              : 'user'
-          }
-          moderation={moderation.ui('avatar')}
-        />
-        <View style={[a.flex_1]}>
-          <Text
-            style={[a.text_md, a.font_bold, a.leading_snug]}
-            numberOfLines={1}>
-            {name}
-          </Text>
-          <Text
-            style={[a.leading_snug, t.atoms.text_contrast_medium]}
-            numberOfLines={1}>
-            {handle}
-          </Text>
-        </View>
-        {hasSession && profile.did !== currentAccount?.did && (
-          <View style={[a.justify_center, {marginLeft: 'auto'}]}>
-            <FollowButton profile={profile} logContext={logContext} />
-          </View>
-        )}
-      </View>
-      <View style={[a.mb_xs]}>
-        <ProfileCardPills
-          followedBy={Boolean(profile.viewer?.followedBy)}
-          moderation={moderation}
-        />
-      </View>
-      {profile.description && (
-        <Text numberOfLines={3} style={[a.leading_snug]}>
-          {profile.description}
-        </Text>
-      )}
-    </Wrapper>
+    <Outer>
+      <Header>
+        <Avatar profile={profile} moderationOpts={moderationOpts} />
+        <NameAndHandle profile={profile} moderationOpts={moderationOpts} />
+        <FollowButton profile={profile} logContext={logContext} />
+      </Header>
+
+      <ProfileCardPills
+        followedBy={Boolean(profile.viewer?.followedBy)}
+        moderation={moderation}
+      />
+
+      <Description profile={profile} />
+    </Outer>
   )
 }
 
-function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
+export function Outer({
+  children,
+}: {
+  children: React.ReactElement | React.ReactElement[]
+}) {
+  return <View style={[a.flex_1, a.gap_xs]}>{children}</View>
+}
+
+export function Header({
+  children,
+}: {
+  children: React.ReactElement | React.ReactElement[]
+}) {
+  return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
+}
+
+export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) {
   return (
-    <Link
+    <InternalLink
       to={{
         screen: 'Profile',
         params: {name: did},
       }}>
-      <View style={[a.flex_1, a.gap_xs]}>{children}</View>
-    </Link>
+      {children}
+    </InternalLink>
+  )
+}
+
+export function Avatar({
+  profile,
+  moderationOpts,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+}) {
+  const moderation = moderateProfile(profile, moderationOpts)
+
+  return (
+    <UserAvatar
+      size={42}
+      avatar={profile.avatar}
+      type={profile.associated?.labeler ? 'labeler' : 'user'}
+      moderation={moderation.ui('avatar')}
+    />
+  )
+}
+
+export function NameAndHandle({
+  profile,
+  moderationOpts,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  const moderation = moderateProfile(profile, moderationOpts)
+  const name = sanitizeDisplayName(
+    profile.displayName || sanitizeHandle(profile.handle),
+    moderation.ui('displayName'),
+  )
+  const handle = sanitizeHandle(profile.handle, '@')
+
+  return (
+    <View style={[a.flex_1]}>
+      <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
+        {name}
+      </Text>
+      <Text
+        style={[a.leading_snug, t.atoms.text_contrast_medium]}
+        numberOfLines={1}>
+        {handle}
+      </Text>
+    </View>
+  )
+}
+
+export function Description({
+  profile: profileUnshadowed,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+  const profile = useProfileShadow(profileUnshadowed)
+  const {description} = profile
+  const rt = React.useMemo(() => {
+    if (!description) return
+    const rt = new RichTextApi({text: description || ''})
+    rt.detectFacetsWithoutResolution()
+    return rt
+  }, [description])
+  if (!rt) return null
+  if (
+    profile.viewer &&
+    (profile.viewer.blockedBy ||
+      profile.viewer.blocking ||
+      profile.viewer.blockingByList)
+  )
+    return null
+  return (
+    <View style={[a.pt_xs]}>
+      <RichText
+        value={rt}
+        style={[a.leading_snug]}
+        numberOfLines={3}
+        disableLinks
+      />
+    </View>
+  )
+}
+
+export type FollowButtonProps = {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  logContext: 'ProfileCard' | 'StarterPackProfilesList'
+} & Partial<ButtonProps>
+
+export function FollowButton(props: FollowButtonProps) {
+  const {currentAccount, hasSession} = useSession()
+  const isMe = props.profile.did === currentAccount?.did
+  return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
+}
+
+export function FollowButtonInner({
+  profile: profileUnshadowed,
+  logContext,
+  ...rest
+}: FollowButtonProps) {
+  const {_} = useLingui()
+  const profile = useProfileShadow(profileUnshadowed)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    logContext,
+  )
+  const isRound = Boolean(rest.shape && rest.shape === 'round')
+
+  const onPressFollow = async (e: GestureResponderEvent) => {
+    e.preventDefault()
+    e.stopPropagation()
+    try {
+      await queueFollow()
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        Toast.show(_(msg`An issue occurred, please try again.`))
+      }
+    }
+  }
+
+  const onPressUnfollow = async (e: GestureResponderEvent) => {
+    e.preventDefault()
+    e.stopPropagation()
+    try {
+      await queueUnfollow()
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        Toast.show(_(msg`An issue occurred, please try again.`))
+      }
+    }
+  }
+
+  const unfollowLabel = _(
+    msg({
+      message: 'Following',
+      comment: 'User is following this account, click to unfollow',
+    }),
+  )
+  const followLabel = _(
+    msg({
+      message: 'Follow',
+      comment: 'User is not following this account, click to follow',
+    }),
+  )
+
+  if (!profile.viewer) return null
+  if (
+    profile.viewer.blockedBy ||
+    profile.viewer.blocking ||
+    profile.viewer.blockingByList
+  )
+    return null
+
+  return (
+    <View>
+      {profile.viewer.following ? (
+        <Button
+          label={unfollowLabel}
+          size="small"
+          variant="solid"
+          color="secondary"
+          {...rest}
+          onPress={onPressUnfollow}>
+          <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
+          {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
+        </Button>
+      ) : (
+        <Button
+          label={followLabel}
+          size="small"
+          variant="solid"
+          color="primary"
+          {...rest}
+          onPress={onPressFollow}>
+          <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
+          {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
+        </Button>
+      )}
+    </View>
   )
 }