Files
bsky-social-app/src/components/ProfileCard.tsx
T
Eric Bailey a51fc8e434 @atproto/api@next integration (#7344)
* Bump SDK

* Use consistent type in profile query

* Omit  from constraint for profile shadow

* Replace isRecord with isValidRecord in QuoteEmbed

* Omit type from constraint for old ProfileCard

* Omit type from constraint in profile queries where appropriate

* Use correct type for update profile mutation

* Conslidate and fix check for isValidRecord in Post.tsx

* Replace isRecord with isValidRecord in PostThreadItem

* Remove redundant cast in PostThreadFollowBtn

* Ignore errors in DebugMod screen

* Use matching type in ProfileFollows screen

* Use matching type in ProfileFollowers screen

* Migrate to isValidRecord in PostFeedItem

* Use matching type if PostRepostedBy

* Omit type from constraint in avatar props

* Use matching types in NotificationFeedItem

* Todo

* Use isValidRecord in NotfyFeedItem

* Improve MediaPreview types

* Migrate another isValidRecord in NotificationFeedItem

* Migrate to isValidView in queries/util

* Migrate to isValidRecord in threadgate/util

* Fix types in threadgates

* Fix up types in starter-packs queries

* Todo

* Specify exact types in search-posts

* Use internal type util to align types

* Ditto last

* Migrate postgate/index

* Specify exact types in post-thread

* Use correct type in post-quotes

* FIX potential bug in post-thread

* Use correct type in post-feed

* Add correct type guards to notifications/feed

* Migrate a guard in notifications/util

* Migrate guard in Wizard/State

* [@next] Profile handling, migrate `ProfileCard` (#7347)

* Introduce new utils for profiles, migrate old ProfileCard

* Rename, reorg

* Add parseEmbed utils

* Expand AnyProfileView to include chat profile view, update post shadow to reflect this

* Cast for perf reasons

* Tighten up types now that we have AnyProfileView

* Add fastIsType util

* Use `assertDid`

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use util types

* Comment

* Use fastIsType where no validation was happening before

* suggestions (#7382)

* suggestions

* Revert unneeded changes

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Use new util

* Rename to dangerousIsType

* Convert object shape

* Use dangerous util

* Use dangerous util

* Use dangerous util, we can trust post records

* Use dangerous util

* Use AnyProfileVIew

* Convert object shape

* Clean up handling

* Patch moderateProfile to accept known profile views, to discuss

* Add AnyStarterPackView and related, implement in first usage

* Remove validation, fix type, fix ref

* Migrate over list-conversations

* Clarify intent behind precacheProfile and its unstable query cache

* Clean up unstable profile cache

* Fix types during label creation in PwiOptOut (#7346)

* Tighten types in queries/list

* Chat: use correct profile views

* Chat: fix log type check

* Chat: construct lexically correct shape, even though it's only internal usage

* Chat: use correct profile types

* Chat: fix type check in logs

* Starter: use correct profile types

* Starter: use correct profile types

* Starter: tighten types to match lex

* Any profile type will work in blocked-and-muted

* Use dangerous util

* Use dangerousIsType

* Update new ProfileCard to use AnyProfileView

* Use dangerousIsType

* Remove outdated todo

* Use correct profile type

* Use correct profile types

* Tighten up types

* Use dangerousIsType

* Chat: more type fixes

* Remove unused file

* Add a few  utils

* Remove unused file

* Ignore feedPost.__source

* Clean up types, leave validation in critical path

* Use dangerousIstype

* Use ANyProfileView

* Use isValidRecord

* Use dangerousIsType

* Fix types in ListCard

* Fix FeedInterstitials types

* Fix types in FeedCard

* Fix types in dms ReportDialog

* Fix types in SearchablePeopleList

* Fix bad type in composer opts

* Starter: ok these need to be loose too

* Clarify docs

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Less code

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use package exports

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use package exports

* Bump sdk, update $Typed imports

* Format

* Format

* Fix weird TS error

* Remove patch

* Beter name

* It's memo, can validate

* Tighten up parseEmbed, dogfood

* Bump sdk

* Use asPredicate

* Loosen types a bit

* use asPredicate

* Fix types

* Use asPredicate

* Use asPredicate

* Use asPredicate

* Clean up upsertProfile types

* Use asPredicate

* Use Un util

* Fix types

* Use new AnyProfileView

* Use dangerousIsType

* Use asPredicate

* Use asPredicate

* Add fallback content-type to pass typecheck

* Clean up upsertProfile types

* Align types

* Use dangerousIsType

* Use dangerousIsType

* Use asPredicate

* Align types

* Convert findLast

* Align types

* Just ignore type errors and use findLast

* Rename atproto -> bsky

* Add validate util

* Fix type error

* Loosen types

* Export post

* rename atp bsky

* Remove unused code

* minor changes

* Bump deps

* Fix types

* Tighten back up loose check

* Tighten back up loose check

* Fix small bug

* Update comment

* Revert change

---------

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>
Co-authored-by: Matthieu Sieben <matthieu.sieben@gmail.com>
2025-02-18 08:54:25 -06:00

407 lines
9.7 KiB
TypeScript

import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
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'
import * as bsky from '#/types/bsky'
export function Default({
profile,
moderationOpts,
logContext = 'ProfileCard',
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
return (
<Link profile={profile}>
<Card
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Link>
)
}
export function Card({
profile,
moderationOpts,
logContext = 'ProfileCard',
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
<Outer>
<Header>
<Avatar profile={profile} moderationOpts={moderationOpts} />
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
<FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Header>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
<Description profile={profile} />
</Outer>
)
}
export function Outer({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
}
export function Header({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
export function Link({
profile,
children,
style,
...rest
}: {
profile: bsky.profile.AnyProfileView
} & Omit<LinkProps, 'to' | 'label'>) {
const {_} = useLingui()
return (
<InternalLink
label={_(
msg`View ${
profile.displayName || sanitizeHandle(profile.handle)
}'s profile`,
)}
to={{
screen: 'Profile',
params: {name: profile.did},
}}
style={[a.flex_col, style]}
{...rest}>
{children}
</InternalLink>
)
}
export function Avatar({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
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 AvatarPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 42,
height: 42,
},
]}
/>
)
}
export function NameAndHandle({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
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
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
</View>
)
}
export function NameAndHandlePlaceholder() {
const t = useTheme()
return (
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
},
]}
/>
</View>
)
}
export function Description({
profile: profileUnshadowed,
numberOfLines = 3,
}: {
profile: bsky.profile.AnyProfileView
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const rt = React.useMemo(() => {
if (!('description' in profile)) return
const rt = new RichTextApi({text: profile.description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [profile])
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={numberOfLines}
disableLinks
/>
</View>
)
}
export function DescriptionPlaceholder({
numberOfLines = 3,
}: {
numberOfLines?: number
}) {
const t = useTheme()
return (
<View style={[{gap: 8}]}>
{Array(numberOfLines)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
))}
</View>
)
}
export type FollowButtonProps = {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
} & 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,
moderationOpts,
logContext,
onPress: onPressProp,
onFollow,
colorInverted,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const moderation = moderateProfile(profile, moderationOpts)
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()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
onPressProp?.(e)
onFollow?.()
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
onPressProp?.(e)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}
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={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
</View>
)
}