forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useCallback} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 moderateProfile,
6 type ModerationOpts,
7} from '@atproto/api'
8import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
9import {msg, plural} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react'
11import {useNavigation} from '@react-navigation/native'
12
13import {getModerationCauseKey} from '#/lib/moderation'
14import {makeProfileLink} from '#/lib/routes/links'
15import {type NavigationProp} from '#/lib/routes/types'
16import {sanitizeDisplayName} from '#/lib/strings/display-names'
17import {sanitizeHandle} from '#/lib/strings/handles'
18import {useProfileShadow} from '#/state/cache/profile-shadow'
19import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics'
20import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics'
21import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics'
22import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
23import {useModerationOpts} from '#/state/preferences/moderation-opts'
24import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
25import {useSession} from '#/state/session'
26import {formatCount} from '#/view/com/util/numeric/format'
27import {UserAvatar} from '#/view/com/util/UserAvatar'
28import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
29import {atoms as a, useTheme} from '#/alf'
30import {Button, ButtonIcon, ButtonText} from '#/components/Button'
31import {useFollowMethods} from '#/components/hooks/useFollowMethods'
32import {useRichText} from '#/components/hooks/useRichText'
33import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
34import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
35import {
36 KnownFollowers,
37 shouldShowKnownFollowers,
38} from '#/components/KnownFollowers'
39import {InlineLinkText, Link} from '#/components/Link'
40import {Loader} from '#/components/Loader'
41import {PdsBadge} from '#/components/PdsBadge'
42import * as Pills from '#/components/Pills'
43import {Portal} from '#/components/Portal'
44import {RichText} from '#/components/RichText'
45import {Text} from '#/components/Typography'
46import {useSimpleVerificationState} from '#/components/verification'
47import {VerificationCheck} from '#/components/verification/VerificationCheck'
48import {IS_WEB_TOUCH_DEVICE} from '#/env'
49import {useActorStatus} from '#/features/liveNow'
50import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
51import {type ProfileHoverCardProps} from './types'
52
53const floatingMiddlewares = [
54 offset(4),
55 flip({padding: 16}),
56 shift({padding: 16}),
57 size({
58 padding: 16,
59 apply({availableWidth, availableHeight, elements}) {
60 Object.assign(elements.floating.style, {
61 maxWidth: `${availableWidth}px`,
62 maxHeight: `${availableHeight}px`,
63 })
64 },
65 }),
66]
67
68export function ProfileHoverCard(props: ProfileHoverCardProps) {
69 const prefetchProfileQuery = usePrefetchProfileQuery()
70 const prefetchedProfile = React.useRef(false)
71 const onPointerMove = () => {
72 if (!prefetchedProfile.current) {
73 prefetchedProfile.current = true
74 prefetchProfileQuery(props.did)
75 }
76 }
77
78 if (props.disable || IS_WEB_TOUCH_DEVICE) {
79 return props.children
80 } else {
81 return (
82 <View
83 onPointerMove={onPointerMove}
84 style={[a.flex_shrink, props.inline && a.inline, props.style]}>
85 <ProfileHoverCardInner {...props} />
86 </View>
87 )
88 }
89}
90
91type State =
92 | {
93 stage: 'hidden' | 'might-hide' | 'hiding'
94 effect?: () => () => any
95 }
96 | {
97 stage: 'might-show' | 'showing'
98 effect?: () => () => any
99 reason: 'hovered-target' | 'hovered-card'
100 }
101
102type Action =
103 | 'pressed'
104 | 'scrolled-while-showing'
105 | 'hovered-target'
106 | 'unhovered-target'
107 | 'hovered-card'
108 | 'unhovered-card'
109 | 'hovered-long-enough'
110 | 'unhovered-long-enough'
111 | 'finished-animating-hide'
112
113const SHOW_DELAY = 500
114const SHOW_DURATION = 300
115const HIDE_DELAY = 150
116const HIDE_DURATION = 200
117
118export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
119 const navigation = useNavigation<NavigationProp>()
120
121 const {refs, floatingStyles} = useFloating({
122 middleware: floatingMiddlewares,
123 })
124
125 const [currentState, dispatch] = React.useReducer(
126 // Tip: console.log(state, action) when debugging.
127 (state: State, action: Action): State => {
128 // Pressing within a card should always hide it.
129 // No matter which stage we're in.
130 if (action === 'pressed') {
131 return hidden()
132 }
133
134 // --- Hidden ---
135 // In the beginning, the card is not displayed.
136 function hidden(): State {
137 return {stage: 'hidden'}
138 }
139 if (state.stage === 'hidden') {
140 // The user can kick things off by hovering a target.
141 if (action === 'hovered-target') {
142 return mightShow({
143 reason: action,
144 })
145 }
146 }
147
148 // --- Might Show ---
149 // The card is not visible yet but we're considering showing it.
150 function mightShow({
151 waitMs = SHOW_DELAY,
152 reason,
153 }: {
154 waitMs?: number
155 reason: 'hovered-target' | 'hovered-card'
156 }): State {
157 return {
158 stage: 'might-show',
159 reason,
160 effect() {
161 const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs)
162 return () => {
163 clearTimeout(id)
164 }
165 },
166 }
167 }
168 if (state.stage === 'might-show') {
169 // We'll make a decision at the end of a grace period timeout.
170 if (action === 'unhovered-target' || action === 'unhovered-card') {
171 return hidden()
172 }
173 if (action === 'hovered-long-enough') {
174 return showing({
175 reason: state.reason,
176 })
177 }
178 }
179
180 // --- Showing ---
181 // The card is beginning to show up and then will remain visible.
182 function showing({
183 reason,
184 }: {
185 reason: 'hovered-target' | 'hovered-card'
186 }): State {
187 return {
188 stage: 'showing',
189 reason,
190 effect() {
191 function onScroll() {
192 dispatch('scrolled-while-showing')
193 }
194 window.addEventListener('scroll', onScroll)
195 return () => window.removeEventListener('scroll', onScroll)
196 },
197 }
198 }
199 if (state.stage === 'showing') {
200 // If the user moves the pointer away, we'll begin to consider hiding it.
201 if (action === 'unhovered-target' || action === 'unhovered-card') {
202 return mightHide()
203 }
204 // Scrolling away if the hover is on the target instantly hides without a delay.
205 // If the hover is already on the card, we won't this.
206 if (
207 state.reason === 'hovered-target' &&
208 action === 'scrolled-while-showing'
209 ) {
210 return hiding()
211 }
212 }
213
214 // --- Might Hide ---
215 // The user has moved hover away from a visible card.
216 function mightHide({waitMs = HIDE_DELAY}: {waitMs?: number} = {}): State {
217 return {
218 stage: 'might-hide',
219 effect() {
220 const id = setTimeout(
221 () => dispatch('unhovered-long-enough'),
222 waitMs,
223 )
224 return () => clearTimeout(id)
225 },
226 }
227 }
228 if (state.stage === 'might-hide') {
229 // We'll make a decision based on whether it received hover again in time.
230 if (action === 'hovered-target' || action === 'hovered-card') {
231 return showing({
232 reason: action,
233 })
234 }
235 if (action === 'unhovered-long-enough') {
236 return hiding()
237 }
238 }
239
240 // --- Hiding ---
241 // The user waited enough outside that we're hiding the card.
242 function hiding({
243 animationDurationMs = HIDE_DURATION,
244 }: {
245 animationDurationMs?: number
246 } = {}): State {
247 return {
248 stage: 'hiding',
249 effect() {
250 const id = setTimeout(
251 () => dispatch('finished-animating-hide'),
252 animationDurationMs,
253 )
254 return () => clearTimeout(id)
255 },
256 }
257 }
258 if (state.stage === 'hiding') {
259 // While hiding, we don't want to be interrupted by anything else.
260 // When the animation finishes, we loop back to the initial hidden state.
261 if (action === 'finished-animating-hide') {
262 return hidden()
263 }
264 }
265
266 return state
267 },
268 {stage: 'hidden'},
269 )
270
271 React.useEffect(() => {
272 if (currentState.effect) {
273 const effect = currentState.effect
274 return effect()
275 }
276 }, [currentState])
277
278 const prefetchProfileQuery = usePrefetchProfileQuery()
279 const prefetchedProfile = React.useRef(false)
280 const prefetchIfNeeded = React.useCallback(async () => {
281 if (!prefetchedProfile.current) {
282 prefetchedProfile.current = true
283 prefetchProfileQuery(props.did)
284 }
285 }, [prefetchProfileQuery, props.did])
286
287 const didFireHover = React.useRef(false)
288 const onPointerMoveTarget = React.useCallback(() => {
289 prefetchIfNeeded()
290 // Conceptually we want something like onPointerEnter,
291 // but we want to ignore entering only due to scrolling.
292 // So instead we hover on the first onPointerMove.
293 if (!didFireHover.current) {
294 didFireHover.current = true
295 dispatch('hovered-target')
296 }
297 }, [prefetchIfNeeded])
298
299 const onPointerLeaveTarget = React.useCallback(() => {
300 didFireHover.current = false
301 dispatch('unhovered-target')
302 }, [])
303
304 const onPointerEnterCard = React.useCallback(() => {
305 dispatch('hovered-card')
306 }, [])
307
308 const onPointerLeaveCard = React.useCallback(() => {
309 dispatch('unhovered-card')
310 }, [])
311
312 const onPress = React.useCallback(() => {
313 dispatch('pressed')
314 }, [])
315
316 const isVisible =
317 currentState.stage === 'showing' ||
318 currentState.stage === 'might-hide' ||
319 currentState.stage === 'hiding'
320
321 const animationStyle = {
322 animation:
323 currentState.stage === 'hiding'
324 ? `fadeOut ${HIDE_DURATION}ms both`
325 : `fadeIn ${SHOW_DURATION}ms both`,
326 }
327
328 return (
329 <View
330 // @ts-ignore View is being used as div
331 ref={refs.setReference}
332 onPointerMove={onPointerMoveTarget}
333 onPointerLeave={onPointerLeaveTarget}
334 // @ts-ignore web only prop
335 onMouseUp={onPress}
336 style={[a.flex_shrink, props.inline && a.inline]}>
337 {props.children}
338 {isVisible && (
339 <Portal>
340 <div
341 ref={refs.setFloating}
342 style={floatingStyles}
343 onPointerEnter={onPointerEnterCard}
344 onPointerLeave={onPointerLeaveCard}>
345 <div style={{willChange: 'transform', ...animationStyle}}>
346 <Card did={props.did} hide={onPress} navigation={navigation} />
347 </div>
348 </div>
349 </Portal>
350 )}
351 </View>
352 )
353}
354
355let Card = ({
356 did,
357 hide,
358 navigation,
359}: {
360 did: string
361 hide: () => void
362 navigation: NavigationProp
363}): React.ReactNode => {
364 const t = useTheme()
365
366 const profile = useProfileQuery({did})
367 const moderationOpts = useModerationOpts()
368
369 const data = profile.data
370
371 const status = useActorStatus(data)
372
373 const onPressOpenProfile = useCallback(() => {
374 if (!status.isActive || !data) return
375 hide()
376 navigation.push('Profile', {
377 name: data.handle,
378 })
379 }, [hide, navigation, status, data])
380
381 return (
382 <View
383 style={[
384 !status.isActive && a.p_lg,
385 a.border,
386 a.rounded_md,
387 a.overflow_hidden,
388 t.atoms.bg,
389 t.atoms.border_contrast_low,
390 t.atoms.shadow_lg,
391 {width: status.isActive ? 350 : 300},
392 a.max_w_full,
393 ]}>
394 {data && moderationOpts ? (
395 status.isActive ? (
396 <LiveStatus
397 status={status}
398 profile={data}
399 embed={status.embed}
400 padding="lg"
401 onPressOpenProfile={onPressOpenProfile}
402 />
403 ) : (
404 <Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
405 )
406 ) : (
407 <View
408 style={[
409 a.justify_center,
410 a.align_center,
411 {minHeight: 200},
412 a.w_full,
413 ]}>
414 <Loader size="xl" />
415 </View>
416 )}
417 </View>
418 )
419}
420Card = React.memo(Card)
421
422function Inner({
423 profile,
424 moderationOpts,
425 hide,
426}: {
427 profile: AppBskyActorDefs.ProfileViewDetailed
428 moderationOpts: ModerationOpts
429 hide: () => void
430}) {
431 const t = useTheme()
432 const {_, i18n} = useLingui()
433 const {currentAccount} = useSession()
434 const moderation = React.useMemo(
435 () => moderateProfile(profile, moderationOpts),
436 [profile, moderationOpts],
437 )
438 const [descriptionRT] = useRichText(profile.description ?? '')
439 const profileShadow = useProfileShadow(profile)
440 const {follow, unfollow} = useFollowMethods({
441 profile: profileShadow,
442 logContext: 'ProfileHoverCard',
443 })
444 const isBlockedUser =
445 profile.viewer?.blocking ||
446 profile.viewer?.blockedBy ||
447 profile.viewer?.blockingByList
448 const following = formatCount(i18n, profile.followsCount || 0)
449 const followers = formatCount(i18n, profile.followersCount || 0)
450 const pluralizedFollowers = plural(profile.followersCount || 0, {
451 one: 'follower',
452 other: 'followers',
453 })
454 const pluralizedFollowings = plural(profile.followsCount || 0, {
455 one: 'following',
456 other: 'following',
457 })
458 const profileURL = makeProfileLink({
459 did: profile.did,
460 handle: profile.handle,
461 })
462 const isMe = React.useMemo(
463 () => currentAccount?.did === profile.did,
464 [currentAccount, profile],
465 )
466 const isLabeler = profile.associated?.labeler
467 const verification = useSimpleVerificationState({profile})
468
469 const enableSquareButtons = useEnableSquareButtons()
470
471 // disable metrics
472 const disableFollowersMetrics = useDisableFollowersMetrics()
473 const disableFollowingMetrics = useDisableFollowingMetrics()
474 const disableFollowedByMetrics = useDisableFollowedByMetrics()
475
476 return (
477 <View>
478 <View style={[a.flex_row, a.justify_between, a.align_start]}>
479 <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
480 <UserAvatar
481 size={64}
482 avatar={profile.avatar}
483 type={isLabeler ? 'labeler' : 'user'}
484 moderation={moderation.ui('avatar')}
485 />
486 </Link>
487
488 {!isMe &&
489 !isLabeler &&
490 (isBlockedUser ? (
491 <Link
492 to={profileURL}
493 label={_(msg`View blocked user's profile`)}
494 onPress={hide}
495 size="small"
496 color="secondary"
497 variant="solid"
498 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}>
499 <ButtonText>{_(msg`View profile`)}</ButtonText>
500 </Link>
501 ) : (
502 <Button
503 size="small"
504 color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
505 variant="solid"
506 label={
507 profileShadow.viewer?.following
508 ? profileShadow.viewer?.followedBy
509 ? _(msg`Mutuals`)
510 : _(msg`Following`)
511 : profileShadow.viewer?.followedBy
512 ? _(msg`Follow back`)
513 : _(msg`Follow`)
514 }
515 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}
516 onPress={profileShadow.viewer?.following ? unfollow : follow}>
517 <ButtonIcon
518 position="left"
519 icon={profileShadow.viewer?.following ? Check : Plus}
520 />
521 <ButtonText>
522 {profileShadow.viewer?.following
523 ? profileShadow.viewer?.followedBy
524 ? _(msg`Mutuals`)
525 : _(msg`Following`)
526 : profileShadow.viewer?.followedBy
527 ? _(msg`Follow back`)
528 : _(msg`Follow`)}
529 </ButtonText>
530 </Button>
531 ))}
532 </View>
533
534 <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
535 <View style={[a.pb_sm, a.flex_1]}>
536 <View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}>
537 <Text
538 numberOfLines={1}
539 style={[
540 a.text_lg,
541 a.leading_snug,
542 a.font_semi_bold,
543 a.self_start,
544 ]}>
545 {sanitizeDisplayName(
546 profile.displayName || sanitizeHandle(profile.handle),
547 moderation.ui('displayName'),
548 )}
549 </Text>
550 <View style={[a.pl_xs, {marginTop: -2}]}>
551 <PdsBadge did={profile.did} size="md" interactive={false} />
552 </View>
553 {verification.showBadge && (
554 <View
555 style={[
556 a.pl_xs,
557 {
558 marginTop: -2,
559 },
560 ]}>
561 <VerificationCheck
562 width={14}
563 verifier={verification.role === 'verifier'}
564 />
565 </View>
566 )}
567 </View>
568
569 <ProfileHeaderHandle profile={profileShadow} disableTaps />
570 </View>
571 </Link>
572
573 {isBlockedUser && (
574 <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
575 {moderation.ui('profileView').alerts.map(cause => (
576 <Pills.Label
577 key={getModerationCauseKey(cause)}
578 size="lg"
579 cause={cause}
580 disableDetailsDialog
581 />
582 ))}
583 </View>
584 )}
585
586 {!isBlockedUser && (
587 <>
588 {disableFollowersMetrics && disableFollowingMetrics ? null : (
589 <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
590 {!disableFollowersMetrics ? (
591 <InlineLinkText
592 to={makeProfileLink(profile, 'followers')}
593 label={`${followers} ${pluralizedFollowers}`}
594 style={[t.atoms.text]}
595 onPress={hide}>
596 <Text style={[a.text_md, a.font_semi_bold]}>
597 {followers}{' '}
598 </Text>
599 <Text style={[t.atoms.text_contrast_medium]}>
600 {pluralizedFollowers}
601 </Text>
602 </InlineLinkText>
603 ) : null}
604 {!disableFollowingMetrics ? (
605 <InlineLinkText
606 to={makeProfileLink(profile, 'follows')}
607 label={_(msg`${following} following`)}
608 style={[t.atoms.text]}
609 onPress={hide}>
610 <Text style={[a.text_md, a.font_semi_bold]}>
611 {following}{' '}
612 </Text>
613 <Text style={[t.atoms.text_contrast_medium]}>
614 {pluralizedFollowings}
615 </Text>
616 </InlineLinkText>
617 ) : null}
618 </View>
619 )}
620
621 {profile.description?.trim() && !moderation.ui('profileView').blur ? (
622 <View style={[a.pt_md]}>
623 <RichText
624 numberOfLines={8}
625 value={descriptionRT}
626 onLinkPress={hide}
627 />
628 </View>
629 ) : undefined}
630
631 {!isMe &&
632 !disableFollowedByMetrics &&
633 shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
634 <View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
635 <KnownFollowers
636 profile={profile}
637 moderationOpts={moderationOpts}
638 onLinkPress={hide}
639 />
640 </View>
641 )}
642 </>
643 )}
644 </View>
645 )
646}