mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {GestureResponderEvent, View} from 'react-native'
3import {
4 AppBskyActorDefs,
5 moderateProfile,
6 ModerationOpts,
7 RichText as RichTextApi,
8} from '@atproto/api'
9import {msg} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11
12import {LogEvents} from '#/lib/statsig/statsig'
13import {sanitizeDisplayName} from '#/lib/strings/display-names'
14import {useProfileFollowMutationQueue} from '#/state/queries/profile'
15import {sanitizeHandle} from 'lib/strings/handles'
16import {useProfileShadow} from 'state/cache/profile-shadow'
17import {useSession} from 'state/session'
18import * as Toast from '#/view/com/util/Toast'
19import {ProfileCardPills} from 'view/com/profile/ProfileCard'
20import {UserAvatar} from 'view/com/util/UserAvatar'
21import {atoms as a, useTheme} from '#/alf'
22import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
23import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
24import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
25import {Link as InternalLink, LinkProps} from '#/components/Link'
26import {RichText} from '#/components/RichText'
27import {Text} from '#/components/Typography'
28
29export function Default({
30 profile,
31 moderationOpts,
32 logContext = 'ProfileCard',
33}: {
34 profile: AppBskyActorDefs.ProfileViewDetailed
35 moderationOpts: ModerationOpts
36 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
37}) {
38 return (
39 <Link profile={profile}>
40 <Card
41 profile={profile}
42 moderationOpts={moderationOpts}
43 logContext={logContext}
44 />
45 </Link>
46 )
47}
48
49export function Card({
50 profile,
51 moderationOpts,
52 logContext = 'ProfileCard',
53}: {
54 profile: AppBskyActorDefs.ProfileViewDetailed
55 moderationOpts: ModerationOpts
56 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
57}) {
58 const moderation = moderateProfile(profile, moderationOpts)
59
60 return (
61 <Outer>
62 <Header>
63 <Avatar profile={profile} moderationOpts={moderationOpts} />
64 <NameAndHandle profile={profile} moderationOpts={moderationOpts} />
65 <FollowButton
66 profile={profile}
67 moderationOpts={moderationOpts}
68 logContext={logContext}
69 />
70 </Header>
71
72 <ProfileCardPills
73 followedBy={Boolean(profile.viewer?.followedBy)}
74 moderation={moderation}
75 />
76
77 <Description profile={profile} />
78 </Outer>
79 )
80}
81
82export function Outer({
83 children,
84}: {
85 children: React.ReactElement | React.ReactElement[]
86}) {
87 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
88}
89
90export function Header({
91 children,
92}: {
93 children: React.ReactElement | React.ReactElement[]
94}) {
95 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
96}
97
98export function Link({
99 profile,
100 children,
101 style,
102 ...rest
103}: {
104 profile: AppBskyActorDefs.ProfileViewDetailed
105} & Omit<LinkProps, 'to' | 'label'>) {
106 const {_} = useLingui()
107 return (
108 <InternalLink
109 label={_(
110 msg`View ${
111 profile.displayName || sanitizeHandle(profile.handle)
112 }'s profile`,
113 )}
114 to={{
115 screen: 'Profile',
116 params: {name: profile.did},
117 }}
118 style={[a.flex_col, style]}
119 {...rest}>
120 {children}
121 </InternalLink>
122 )
123}
124
125export function Avatar({
126 profile,
127 moderationOpts,
128}: {
129 profile: AppBskyActorDefs.ProfileViewDetailed
130 moderationOpts: ModerationOpts
131}) {
132 const moderation = moderateProfile(profile, moderationOpts)
133
134 return (
135 <UserAvatar
136 size={42}
137 avatar={profile.avatar}
138 type={profile.associated?.labeler ? 'labeler' : 'user'}
139 moderation={moderation.ui('avatar')}
140 />
141 )
142}
143
144export function AvatarPlaceholder() {
145 const t = useTheme()
146 return (
147 <View
148 style={[
149 a.rounded_full,
150 t.atoms.bg_contrast_50,
151 {
152 width: 42,
153 height: 42,
154 },
155 ]}
156 />
157 )
158}
159
160export function NameAndHandle({
161 profile,
162 moderationOpts,
163}: {
164 profile: AppBskyActorDefs.ProfileViewDetailed
165 moderationOpts: ModerationOpts
166}) {
167 const t = useTheme()
168 const moderation = moderateProfile(profile, moderationOpts)
169 const name = sanitizeDisplayName(
170 profile.displayName || sanitizeHandle(profile.handle),
171 moderation.ui('displayName'),
172 )
173 const handle = sanitizeHandle(profile.handle, '@')
174
175 return (
176 <View style={[a.flex_1]}>
177 <Text
178 style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
179 numberOfLines={1}>
180 {name}
181 </Text>
182 <Text
183 style={[a.leading_snug, t.atoms.text_contrast_medium]}
184 numberOfLines={1}>
185 {handle}
186 </Text>
187 </View>
188 )
189}
190
191export function NameAndHandlePlaceholder() {
192 const t = useTheme()
193
194 return (
195 <View style={[a.flex_1, a.gap_xs]}>
196 <View
197 style={[
198 a.rounded_xs,
199 t.atoms.bg_contrast_50,
200 {
201 width: '60%',
202 height: 14,
203 },
204 ]}
205 />
206
207 <View
208 style={[
209 a.rounded_xs,
210 t.atoms.bg_contrast_50,
211 {
212 width: '40%',
213 height: 10,
214 },
215 ]}
216 />
217 </View>
218 )
219}
220
221export function Description({
222 profile: profileUnshadowed,
223}: {
224 profile: AppBskyActorDefs.ProfileViewDetailed
225}) {
226 const profile = useProfileShadow(profileUnshadowed)
227 const {description} = profile
228 const rt = React.useMemo(() => {
229 if (!description) return
230 const rt = new RichTextApi({text: description || ''})
231 rt.detectFacetsWithoutResolution()
232 return rt
233 }, [description])
234 if (!rt) return null
235 if (
236 profile.viewer &&
237 (profile.viewer.blockedBy ||
238 profile.viewer.blocking ||
239 profile.viewer.blockingByList)
240 )
241 return null
242 return (
243 <View style={[a.pt_xs]}>
244 <RichText
245 value={rt}
246 style={[a.leading_snug]}
247 numberOfLines={3}
248 disableLinks
249 />
250 </View>
251 )
252}
253
254export function DescriptionPlaceholder() {
255 const t = useTheme()
256 return (
257 <View style={[a.gap_xs]}>
258 <View
259 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
260 />
261 <View
262 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
263 />
264 <View
265 style={[
266 a.rounded_xs,
267 a.w_full,
268 t.atoms.bg_contrast_50,
269 {height: 12, width: 100},
270 ]}
271 />
272 </View>
273 )
274}
275
276export type FollowButtonProps = {
277 profile: AppBskyActorDefs.ProfileViewBasic
278 moderationOpts: ModerationOpts
279 logContext: LogEvents['profile:follow']['logContext'] &
280 LogEvents['profile:unfollow']['logContext']
281} & Partial<ButtonProps>
282
283export function FollowButton(props: FollowButtonProps) {
284 const {currentAccount, hasSession} = useSession()
285 const isMe = props.profile.did === currentAccount?.did
286 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
287}
288
289export function FollowButtonInner({
290 profile: profileUnshadowed,
291 moderationOpts,
292 logContext,
293 ...rest
294}: FollowButtonProps) {
295 const {_} = useLingui()
296 const profile = useProfileShadow(profileUnshadowed)
297 const moderation = moderateProfile(profile, moderationOpts)
298 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
299 profile,
300 logContext,
301 )
302 const isRound = Boolean(rest.shape && rest.shape === 'round')
303
304 const onPressFollow = async (e: GestureResponderEvent) => {
305 e.preventDefault()
306 e.stopPropagation()
307 try {
308 await queueFollow()
309 Toast.show(
310 _(
311 msg`Following ${sanitizeDisplayName(
312 profile.displayName || profile.handle,
313 moderation.ui('displayName'),
314 )}`,
315 ),
316 )
317 } catch (err: any) {
318 if (err?.name !== 'AbortError') {
319 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
320 }
321 }
322 }
323
324 const onPressUnfollow = async (e: GestureResponderEvent) => {
325 e.preventDefault()
326 e.stopPropagation()
327 try {
328 await queueUnfollow()
329 Toast.show(
330 _(
331 msg`No longer following ${sanitizeDisplayName(
332 profile.displayName || profile.handle,
333 moderation.ui('displayName'),
334 )}`,
335 ),
336 )
337 } catch (err: any) {
338 if (err?.name !== 'AbortError') {
339 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
340 }
341 }
342 }
343
344 const unfollowLabel = _(
345 msg({
346 message: 'Following',
347 comment: 'User is following this account, click to unfollow',
348 }),
349 )
350 const followLabel = _(
351 msg({
352 message: 'Follow',
353 comment: 'User is not following this account, click to follow',
354 }),
355 )
356
357 if (!profile.viewer) return null
358 if (
359 profile.viewer.blockedBy ||
360 profile.viewer.blocking ||
361 profile.viewer.blockingByList
362 )
363 return null
364
365 return (
366 <View>
367 {profile.viewer.following ? (
368 <Button
369 label={unfollowLabel}
370 size="small"
371 variant="solid"
372 color="secondary"
373 {...rest}
374 onPress={onPressUnfollow}>
375 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
376 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
377 </Button>
378 ) : (
379 <Button
380 label={followLabel}
381 size="small"
382 variant="solid"
383 color="primary"
384 {...rest}
385 onPress={onPressFollow}>
386 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
387 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
388 </Button>
389 )}
390 </View>
391 )
392}