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