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