Bluesky app fork with some witchin' additions 💫

Fade in animation for card (#3521)

* fade in and out the card

one more fix

dont leave an invisible card behind

okay just about there

move styles

glitch

clear hide timeouts on card enter

about there

* Tweak timings

* Rewrite with explicit states

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by hailey.at

Dan Abramov and committed by
GitHub
228d947a 1a9eeb76

+126 -57
+11
bskyweb/templates/base.html
··· 235 235 inset:0; 236 236 animation: rotate 500ms linear infinite; 237 237 } 238 + 239 + @keyframes avatarHoverFadeIn { 240 + from { opacity: 0; } 241 + to { opacity: 1; } 242 + } 243 + 244 + @keyframes avatarHoverFadeOut { 245 + from { opacity: 1; } 246 + to { opacity: 0; } 247 + } 248 + </style> 238 249 </style> 239 250 {% include "scripts.html" %} 240 251 <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+105 -57
src/components/ProfileHoverCard/index.web.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 3 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 5 4 import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' 6 5 import {msg, Trans} from '@lingui/macro' 7 6 import {useLingui} from '@lingui/react' 8 7 8 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 9 import {makeProfileLink} from '#/lib/routes/links' 10 10 import {sanitizeDisplayName} from '#/lib/strings/display-names' 11 11 import {sanitizeHandle} from '#/lib/strings/handles' ··· 51 51 return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} /> 52 52 } 53 53 54 + type State = 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding' 55 + 56 + const SHOW_DELAY = 350 57 + const SHOW_DURATION = 300 58 + const HIDE_DELAY = 200 59 + const HIDE_DURATION = 200 60 + 54 61 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { 55 - const [hovered, setHovered] = React.useState(false) 62 + const [state, setState] = React.useState<State>('hidden') 56 63 const {refs, floatingStyles} = useFloating({ 57 64 middleware: floatingMiddlewares, 58 65 }) 66 + const animationStyle = { 67 + animation: 68 + state === 'hiding' 69 + ? `avatarHoverFadeOut ${HIDE_DURATION}ms both` 70 + : `avatarHoverFadeIn ${SHOW_DURATION}ms both`, 71 + } 72 + 59 73 const prefetchProfileQuery = usePrefetchProfileQuery() 74 + const prefetchedProfile = React.useRef(false) 75 + const prefetchIfNeeded = React.useCallback(async () => { 76 + if (!prefetchedProfile.current) { 77 + prefetchProfileQuery(props.did) 78 + } 79 + }, [prefetchProfileQuery, props.did]) 60 80 61 - const prefetchedProfile = React.useRef(false) 62 - const targetHovered = React.useRef(false) 63 - const cardHovered = React.useRef(false) 64 - const targetClicked = React.useRef(false) 65 - const showTimeout = React.useRef<NodeJS.Timeout>() 81 + const isVisible = 82 + state === 'showing' || state === 'might-hide' || state === 'hiding' 83 + 84 + // We need at most one timeout at a time (to transition to the next state). 85 + const nextTimeout = React.useRef<NodeJS.Timeout | null>(null) 86 + const transitionToState = React.useCallback((nextState: State) => { 87 + if (nextTimeout.current) { 88 + clearTimeout(nextTimeout.current) 89 + nextTimeout.current = null 90 + } 91 + setState(nextState) 92 + }, []) 93 + 94 + const onReadyToShow = useNonReactiveCallback(() => { 95 + if (state === 'might-show') { 96 + transitionToState('showing') 97 + } 98 + }) 99 + 100 + const onReadyToHide = useNonReactiveCallback(() => { 101 + if (state === 'might-hide') { 102 + transitionToState('hiding') 103 + nextTimeout.current = setTimeout(onHidingAnimationEnd, HIDE_DURATION) 104 + } 105 + }) 106 + 107 + const onHidingAnimationEnd = useNonReactiveCallback(() => { 108 + if (state === 'hiding') { 109 + transitionToState('hidden') 110 + } 111 + }) 112 + 113 + const onReceiveHover = useNonReactiveCallback(() => { 114 + prefetchIfNeeded() 115 + if (state === 'hidden') { 116 + transitionToState('might-show') 117 + nextTimeout.current = setTimeout(onReadyToShow, SHOW_DELAY) 118 + } else if (state === 'might-show') { 119 + // Do nothing 120 + } else if (state === 'showing') { 121 + // Do nothing 122 + } else if (state === 'might-hide') { 123 + transitionToState('showing') 124 + } else if (state === 'hiding') { 125 + transitionToState('showing') 126 + } 127 + }) 128 + 129 + const onLoseHover = useNonReactiveCallback(() => { 130 + if (state === 'hidden') { 131 + // Do nothing 132 + } else if (state === 'might-show') { 133 + transitionToState('hidden') 134 + } else if (state === 'showing') { 135 + transitionToState('might-hide') 136 + nextTimeout.current = setTimeout(onReadyToHide, HIDE_DELAY) 137 + } else if (state === 'might-hide') { 138 + // Do nothing 139 + } else if (state === 'hiding') { 140 + // Do nothing 141 + } 142 + }) 66 143 67 144 const onPointerEnterTarget = React.useCallback(() => { 68 - showTimeout.current = setTimeout(async () => { 69 - targetHovered.current = true 145 + onReceiveHover() 146 + }, [onReceiveHover]) 70 147 71 - if (prefetchedProfile.current) { 72 - // if we're navigating 73 - if (targetClicked.current) return 74 - setHovered(true) 75 - } else { 76 - await prefetchProfileQuery(props.did) 148 + const onPointerLeaveTarget = React.useCallback(() => { 149 + onLoseHover() 150 + }, [onLoseHover]) 77 151 78 - if (targetHovered.current) { 79 - setHovered(true) 80 - } 81 - prefetchedProfile.current = true 82 - } 83 - }, 350) 84 - }, [props.did, prefetchProfileQuery]) 85 152 const onPointerEnterCard = React.useCallback(() => { 86 - cardHovered.current = true 87 - // if we're navigating 88 - if (targetClicked.current) return 89 - setHovered(true) 90 - }, []) 91 - const onPointerLeaveTarget = React.useCallback(() => { 92 - clearTimeout(showTimeout.current) 93 - targetHovered.current = false 94 - setTimeout(() => { 95 - if (cardHovered.current) return 96 - setHovered(false) 97 - }, 100) 98 - }, []) 153 + onReceiveHover() 154 + }, [onReceiveHover]) 155 + 99 156 const onPointerLeaveCard = React.useCallback(() => { 100 - cardHovered.current = false 101 - setTimeout(() => { 102 - if (targetHovered.current) return 103 - setHovered(false) 104 - }, 100) 105 - }, []) 106 - const onClickTarget = React.useCallback(() => { 107 - targetClicked.current = true 108 - setHovered(false) 109 - }, []) 110 - const hide = React.useCallback(() => { 111 - setHovered(false) 112 - }, []) 157 + onLoseHover() 158 + }, [onLoseHover]) 159 + 160 + const onDismiss = React.useCallback(() => { 161 + transitionToState('hidden') 162 + }, [transitionToState]) 113 163 114 164 return ( 115 165 <div 116 166 ref={refs.setReference} 117 167 onPointerEnter={onPointerEnterTarget} 118 168 onPointerLeave={onPointerLeaveTarget} 119 - onMouseUp={onClickTarget} 169 + onMouseUp={onDismiss} 120 170 style={{ 121 171 display: props.inline ? 'inline' : 'block', 122 172 }}> 123 173 {props.children} 124 - 125 - {hovered && ( 174 + {isVisible && ( 126 175 <Portal> 127 - <Animated.View 128 - entering={FadeIn.duration(80)} 129 - exiting={FadeOut.duration(80)}> 176 + <div style={animationStyle}> 130 177 <div 131 178 ref={refs.setFloating} 132 179 style={floatingStyles} 133 180 onPointerEnter={onPointerEnterCard} 134 181 onPointerLeave={onPointerLeaveCard}> 135 - <Card did={props.did} hide={hide} /> 182 + <Card did={props.did} hide={onDismiss} /> 136 183 </div> 137 - </Animated.View> 184 + </div> 138 185 </Portal> 139 186 )} 140 187 </div> 141 188 ) 142 189 } 143 190 144 - function Card({did, hide}: {did: string; hide: () => void}) { 191 + let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => { 145 192 const t = useTheme() 146 193 147 194 const profile = useProfileQuery({did}) ··· 173 220 </View> 174 221 ) 175 222 } 223 + Card = React.memo(Card) 176 224 177 225 function Inner({ 178 226 profile,
+10
web/index.html
··· 239 239 inset:0; 240 240 animation: rotate 500ms linear infinite; 241 241 } 242 + 243 + @keyframes avatarHoverFadeIn { 244 + from { opacity: 0; } 245 + to { opacity: 1; } 246 + } 247 + 248 + @keyframes avatarHoverFadeOut { 249 + from { opacity: 1; } 250 + to { opacity: 0; } 251 + } 242 252 </style> 243 253 </head> 244 254