Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix hover card animation with a reducer (#3547)

authored by

dan and committed by
GitHub
41925bdc eeb1b5e3

+137 -84
+137 -84
src/components/ProfileHoverCard/index.web.tsx
··· 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 8 import {makeProfileLink} from '#/lib/routes/links' 10 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 11 10 import {sanitizeHandle} from '#/lib/strings/handles' ··· 51 50 return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} /> 52 51 } 53 52 54 - type State = 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding' 53 + type State = { 54 + stage: 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding' 55 + effect?: () => () => any 56 + } 57 + 58 + type Action = 59 + | 'pressed' 60 + | 'hovered' 61 + | 'unhovered' 62 + | 'show-timer-elapsed' 63 + | 'hide-timer-elapsed' 64 + | 'hide-animation-completed' 55 65 56 66 const SHOW_DELAY = 350 57 67 const SHOW_DURATION = 300 ··· 59 69 const HIDE_DURATION = 200 60 70 61 71 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { 62 - const [state, setState] = React.useState<State>('hidden') 63 72 const {refs, floatingStyles} = useFloating({ 64 73 middleware: floatingMiddlewares, 65 74 }) 66 - const animationStyle = { 67 - animation: 68 - state === 'hiding' 69 - ? `avatarHoverFadeOut ${HIDE_DURATION}ms both` 70 - : `avatarHoverFadeIn ${SHOW_DURATION}ms both`, 71 - } 72 75 73 - const prefetchProfileQuery = usePrefetchProfileQuery() 74 - const prefetchedProfile = React.useRef(false) 75 - const prefetchIfNeeded = React.useCallback(async () => { 76 - if (!prefetchedProfile.current) { 77 - prefetchedProfile.current = true 78 - prefetchProfileQuery(props.did) 79 - } 80 - }, [prefetchProfileQuery, props.did]) 76 + const [currentState, dispatch] = React.useReducer( 77 + // Tip: console.log(state, action) when debugging. 78 + (state: State, action: Action): State => { 79 + // Regardless of which stage we're in, pressing always hides the card. 80 + if (action === 'pressed') { 81 + return {stage: 'hidden'} 82 + } 81 83 82 - const isVisible = 83 - state === 'showing' || state === 'might-hide' || state === 'hiding' 84 + if (state.stage === 'hidden') { 85 + // Our story starts when the card is hidden. 86 + // If the user hovers, we kick off a grace period before showing the card. 87 + if (action === 'hovered') { 88 + return { 89 + stage: 'might-show', 90 + effect() { 91 + const id = setTimeout( 92 + () => dispatch('show-timer-elapsed'), 93 + SHOW_DELAY, 94 + ) 95 + return () => { 96 + clearTimeout(id) 97 + } 98 + }, 99 + } 100 + } 101 + } 102 + 103 + if (state.stage === 'might-show') { 104 + // We're in the grace period when we decide whether to show the card. 105 + // At this point, two things can happen. Either the user unhovers, and 106 + // we go back to hidden--or they linger enough that we'll show the card. 107 + if (action === 'unhovered') { 108 + return {stage: 'hidden'} 109 + } 110 + if (action === 'show-timer-elapsed') { 111 + return {stage: 'showing'} 112 + } 113 + } 84 114 85 - // We need at most one timeout at a time (to transition to the next state). 86 - const nextTimeout = React.useRef<NodeJS.Timeout | null>(null) 87 - const transitionToState = React.useCallback((nextState: State) => { 88 - if (nextTimeout.current) { 89 - clearTimeout(nextTimeout.current) 90 - nextTimeout.current = null 91 - } 92 - setState(nextState) 93 - }, []) 115 + if (state.stage === 'showing') { 116 + // We're showing the card now. 117 + // If the user unhovers, we'll start a grace period before hiding the card. 118 + if (action === 'unhovered') { 119 + return { 120 + stage: 'might-hide', 121 + effect() { 122 + const id = setTimeout( 123 + () => dispatch('hide-timer-elapsed'), 124 + HIDE_DELAY, 125 + ) 126 + return () => clearTimeout(id) 127 + }, 128 + } 129 + } 130 + } 94 131 95 - const onReadyToShow = useNonReactiveCallback(() => { 96 - if (state === 'might-show') { 97 - transitionToState('showing') 98 - } 99 - }) 132 + if (state.stage === 'might-hide') { 133 + // We're in the grace period when we decide whether to hide the card. 134 + // At this point, two things can happen. Either the user hovers, and 135 + // we go back to showing it--or they linger enough that we'll start hiding the card. 136 + if (action === 'hovered') { 137 + return {stage: 'showing'} 138 + } 139 + if (action === 'hide-timer-elapsed') { 140 + return { 141 + stage: 'hiding', 142 + effect() { 143 + const id = setTimeout( 144 + () => dispatch('hide-animation-completed'), 145 + HIDE_DURATION, 146 + ) 147 + return () => clearTimeout(id) 148 + }, 149 + } 150 + } 151 + } 100 152 101 - const onReadyToHide = useNonReactiveCallback(() => { 102 - if (state === 'might-hide') { 103 - transitionToState('hiding') 104 - nextTimeout.current = setTimeout(onHidingAnimationEnd, HIDE_DURATION) 105 - } 106 - }) 153 + if (state.stage === 'hiding') { 154 + // We're currently playing the hiding animation. 155 + // We'll ignore all inputs now and wait for the animation to finish. 156 + // At that point, we'll hide the entire thing, going back to square one. 157 + if (action === 'hide-animation-completed') { 158 + return {stage: 'hidden'} 159 + } 160 + } 107 161 108 - const onHidingAnimationEnd = useNonReactiveCallback(() => { 109 - if (state === 'hiding') { 110 - transitionToState('hidden') 111 - } 112 - }) 162 + // Something else happened. Keep calm and carry on. 163 + return state 164 + }, 165 + {stage: 'hidden'}, 166 + ) 113 167 114 - const onReceiveHover = useNonReactiveCallback(() => { 115 - prefetchIfNeeded() 116 - if (state === 'hidden') { 117 - transitionToState('might-show') 118 - nextTimeout.current = setTimeout(onReadyToShow, SHOW_DELAY) 119 - } else if (state === 'might-show') { 120 - // Do nothing 121 - } else if (state === 'showing') { 122 - // Do nothing 123 - } else if (state === 'might-hide') { 124 - transitionToState('showing') 125 - } else if (state === 'hiding') { 126 - transitionToState('showing') 168 + React.useEffect(() => { 169 + if (currentState.effect) { 170 + const effect = currentState.effect 171 + delete currentState.effect // Mark as completed 172 + return effect() 127 173 } 128 - }) 174 + }, [currentState]) 129 175 130 - const onLoseHover = useNonReactiveCallback(() => { 131 - if (state === 'hidden') { 132 - // Do nothing 133 - } else if (state === 'might-show') { 134 - transitionToState('hidden') 135 - } else if (state === 'showing') { 136 - transitionToState('might-hide') 137 - nextTimeout.current = setTimeout(onReadyToHide, HIDE_DELAY) 138 - } else if (state === 'might-hide') { 139 - // Do nothing 140 - } else if (state === 'hiding') { 141 - // Do nothing 176 + const prefetchProfileQuery = usePrefetchProfileQuery() 177 + const prefetchedProfile = React.useRef(false) 178 + const prefetchIfNeeded = React.useCallback(async () => { 179 + if (!prefetchedProfile.current) { 180 + prefetchedProfile.current = true 181 + prefetchProfileQuery(props.did) 142 182 } 143 - }) 183 + }, [prefetchProfileQuery, props.did]) 144 184 145 185 const onPointerEnterTarget = React.useCallback(() => { 146 - onReceiveHover() 147 - }, [onReceiveHover]) 186 + prefetchIfNeeded() 187 + dispatch('hovered') 188 + }, [prefetchIfNeeded]) 148 189 149 190 const onPointerLeaveTarget = React.useCallback(() => { 150 - onLoseHover() 151 - }, [onLoseHover]) 191 + dispatch('unhovered') 192 + }, []) 152 193 153 194 const onPointerEnterCard = React.useCallback(() => { 154 - onReceiveHover() 155 - }, [onReceiveHover]) 195 + dispatch('hovered') 196 + }, []) 156 197 157 198 const onPointerLeaveCard = React.useCallback(() => { 158 - onLoseHover() 159 - }, [onLoseHover]) 199 + dispatch('unhovered') 200 + }, []) 201 + 202 + const onPress = React.useCallback(() => { 203 + dispatch('pressed') 204 + }, []) 205 + 206 + const isVisible = 207 + currentState.stage === 'showing' || 208 + currentState.stage === 'might-hide' || 209 + currentState.stage === 'hiding' 160 210 161 - const onDismiss = React.useCallback(() => { 162 - transitionToState('hidden') 163 - }, [transitionToState]) 211 + const animationStyle = { 212 + animation: 213 + currentState.stage === 'hiding' 214 + ? `avatarHoverFadeOut ${HIDE_DURATION}ms both` 215 + : `avatarHoverFadeIn ${SHOW_DURATION}ms both`, 216 + } 164 217 165 218 return ( 166 219 <div 167 220 ref={refs.setReference} 168 221 onPointerEnter={onPointerEnterTarget} 169 222 onPointerLeave={onPointerLeaveTarget} 170 - onMouseUp={onDismiss} 223 + onMouseUp={onPress} 171 224 style={{ 172 225 display: props.inline ? 'inline' : 'block', 173 226 }}> ··· 180 233 style={floatingStyles} 181 234 onPointerEnter={onPointerEnterCard} 182 235 onPointerLeave={onPointerLeaveCard}> 183 - <Card did={props.did} hide={onDismiss} /> 236 + <Card did={props.did} hide={onPress} /> 184 237 </div> 185 238 </div> 186 239 </Portal>