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 {sanitizeHandle} from '#/lib/strings/handles'
15import {useProfileShadow} from '#/state/cache/profile-shadow'
16import {useProfileFollowMutationQueue} from '#/state/queries/profile'
17import {useSession} from '#/state/session'
18import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
19import * as Toast from '#/view/com/util/Toast'
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 emoji
179 style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
180 numberOfLines={1}>
181 {name}
182 </Text>
183 <Text
184 emoji
185 style={[a.leading_snug, t.atoms.text_contrast_medium]}
186 numberOfLines={1}>
187 {handle}
188 </Text>
189 </View>
190 )
191}
192
193export function NameAndHandlePlaceholder() {
194 const t = useTheme()
195
196 return (
197 <View style={[a.flex_1, a.gap_xs]}>
198 <View
199 style={[
200 a.rounded_xs,
201 t.atoms.bg_contrast_50,
202 {
203 width: '60%',
204 height: 14,
205 },
206 ]}
207 />
208
209 <View
210 style={[
211 a.rounded_xs,
212 t.atoms.bg_contrast_50,
213 {
214 width: '40%',
215 height: 10,
216 },
217 ]}
218 />
219 </View>
220 )
221}
222
223export function Description({
224 profile: profileUnshadowed,
225 numberOfLines = 3,
226}: {
227 profile: AppBskyActorDefs.ProfileViewDetailed
228 numberOfLines?: number
229}) {
230 const profile = useProfileShadow(profileUnshadowed)
231 const {description} = profile
232 const rt = React.useMemo(() => {
233 if (!description) return
234 const rt = new RichTextApi({text: description || ''})
235 rt.detectFacetsWithoutResolution()
236 return rt
237 }, [description])
238 if (!rt) return null
239 if (
240 profile.viewer &&
241 (profile.viewer.blockedBy ||
242 profile.viewer.blocking ||
243 profile.viewer.blockingByList)
244 )
245 return null
246 return (
247 <View style={[a.pt_xs]}>
248 <RichText
249 value={rt}
250 style={[a.leading_snug]}
251 numberOfLines={numberOfLines}
252 disableLinks
253 />
254 </View>
255 )
256}
257
258export function DescriptionPlaceholder({
259 numberOfLines = 3,
260}: {
261 numberOfLines?: number
262}) {
263 const t = useTheme()
264 return (
265 <View style={[{gap: 8}]}>
266 {Array(numberOfLines)
267 .fill(0)
268 .map((_, i) => (
269 <View
270 key={i}
271 style={[
272 a.rounded_xs,
273 a.w_full,
274 t.atoms.bg_contrast_50,
275 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
276 ]}
277 />
278 ))}
279 </View>
280 )
281}
282
283export type FollowButtonProps = {
284 profile: AppBskyActorDefs.ProfileViewBasic
285 moderationOpts: ModerationOpts
286 logContext: LogEvents['profile:follow:sampled']['logContext'] &
287 LogEvents['profile:unfollow:sampled']['logContext']
288} & Partial<ButtonProps>
289
290export function FollowButton(props: FollowButtonProps) {
291 const {currentAccount, hasSession} = useSession()
292 const isMe = props.profile.did === currentAccount?.did
293 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
294}
295
296export function FollowButtonInner({
297 profile: profileUnshadowed,
298 moderationOpts,
299 logContext,
300 ...rest
301}: FollowButtonProps) {
302 const {_} = useLingui()
303 const profile = useProfileShadow(profileUnshadowed)
304 const moderation = moderateProfile(profile, moderationOpts)
305 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
306 profile,
307 logContext,
308 )
309 const isRound = Boolean(rest.shape && rest.shape === 'round')
310
311 const onPressFollow = async (e: GestureResponderEvent) => {
312 e.preventDefault()
313 e.stopPropagation()
314 try {
315 await queueFollow()
316 Toast.show(
317 _(
318 msg`Following ${sanitizeDisplayName(
319 profile.displayName || profile.handle,
320 moderation.ui('displayName'),
321 )}`,
322 ),
323 )
324 } catch (err: any) {
325 if (err?.name !== 'AbortError') {
326 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
327 }
328 }
329 }
330
331 const onPressUnfollow = async (e: GestureResponderEvent) => {
332 e.preventDefault()
333 e.stopPropagation()
334 try {
335 await queueUnfollow()
336 Toast.show(
337 _(
338 msg`No longer following ${sanitizeDisplayName(
339 profile.displayName || profile.handle,
340 moderation.ui('displayName'),
341 )}`,
342 ),
343 )
344 } catch (err: any) {
345 if (err?.name !== 'AbortError') {
346 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
347 }
348 }
349 }
350
351 const unfollowLabel = _(
352 msg({
353 message: 'Following',
354 comment: 'User is following this account, click to unfollow',
355 }),
356 )
357 const followLabel = _(
358 msg({
359 message: 'Follow',
360 comment: 'User is not following this account, click to follow',
361 }),
362 )
363
364 if (!profile.viewer) return null
365 if (
366 profile.viewer.blockedBy ||
367 profile.viewer.blocking ||
368 profile.viewer.blockingByList
369 )
370 return null
371
372 return (
373 <View>
374 {profile.viewer.following ? (
375 <Button
376 label={unfollowLabel}
377 size="small"
378 variant="solid"
379 color="secondary"
380 {...rest}
381 onPress={onPressUnfollow}>
382 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
383 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
384 </Button>
385 ) : (
386 <Button
387 label={followLabel}
388 size="small"
389 variant="solid"
390 color="primary"
391 {...rest}
392 onPress={onPressFollow}>
393 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
394 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
395 </Button>
396 )}
397 </View>
398 )
399}