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