Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Dismissable toasts (#6345)

* dismissable toast

* adjust top offset

* improve a11y

* stretchy pull-down

* Dismiss web on tap

* Simplify code

---------

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

authored by

Samuel Newman
Dan Abramov
and committed by
GitHub
76ca72cf 5d4aaa5b

+175 -45
+159 -44
src/view/com/util/Toast.tsx
··· 1 - import {useEffect, useState} from 'react' 2 - import {View} from 'react-native' 3 - import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated' 1 + import {useEffect, useMemo, useRef, useState} from 'react' 2 + import {AccessibilityInfo, View} from 'react-native' 3 + import { 4 + Gesture, 5 + GestureDetector, 6 + GestureHandlerRootView, 7 + } from 'react-native-gesture-handler' 8 + import Animated, { 9 + FadeInUp, 10 + FadeOutUp, 11 + runOnJS, 12 + useAnimatedReaction, 13 + useAnimatedStyle, 14 + useSharedValue, 15 + withDecay, 16 + withSpring, 17 + } from 'react-native-reanimated' 4 18 import RootSiblings from 'react-native-root-siblings' 5 19 import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 20 import { ··· 8 22 Props as FontAwesomeProps, 9 23 } from '@fortawesome/react-native-fontawesome' 10 24 25 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 11 26 import {atoms as a, useTheme} from '#/alf' 12 27 import {Text} from '#/components/Typography' 13 28 import {IS_TEST} from '#/env' ··· 19 34 icon: FontAwesomeProps['icon'] = 'check', 20 35 ) { 21 36 if (IS_TEST) return 22 - const item = new RootSiblings(<Toast message={message} icon={icon} />) 23 - // timeout has some leeway to account for the animation 24 - setTimeout(() => { 25 - item.destroy() 26 - }, TIMEOUT + 1e3) 37 + AccessibilityInfo.announceForAccessibility(message) 38 + const item = new RootSiblings( 39 + <Toast message={message} icon={icon} destroy={() => item.destroy()} />, 40 + ) 27 41 } 28 42 29 43 function Toast({ 30 44 message, 31 45 icon, 46 + destroy, 32 47 }: { 33 48 message: string 34 49 icon: FontAwesomeProps['icon'] 50 + destroy: () => void 35 51 }) { 36 52 const t = useTheme() 37 53 const {top} = useSafeAreaInsets() 54 + const isPanning = useSharedValue(false) 55 + const dismissSwipeTranslateY = useSharedValue(0) 56 + const [cardHeight, setCardHeight] = useState(0) 38 57 39 58 // for the exit animation to work on iOS the animated component 40 59 // must not be the root component 41 60 // so we need to wrap it in a view and unmount the toast ahead of time 42 61 const [alive, setAlive] = useState(true) 62 + 63 + const hideAndDestroyImmediately = () => { 64 + setAlive(false) 65 + setTimeout(() => { 66 + destroy() 67 + }, 1e3) 68 + } 69 + 70 + const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 71 + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { 72 + clearTimeout(destroyTimeoutRef.current) 73 + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) 74 + }) 75 + const pauseDestroy = useNonReactiveCallback(() => { 76 + clearTimeout(destroyTimeoutRef.current) 77 + }) 43 78 44 79 useEffect(() => { 45 - setTimeout(() => { 46 - setAlive(false) 47 - }, TIMEOUT) 48 - }, []) 80 + hideAndDestroyAfterTimeout() 81 + }, [hideAndDestroyAfterTimeout]) 82 + 83 + const panGesture = useMemo(() => { 84 + return Gesture.Pan() 85 + .activeOffsetY([-10, 10]) 86 + .failOffsetX([-10, 10]) 87 + .maxPointers(1) 88 + .onStart(() => { 89 + 'worklet' 90 + if (!alive) return 91 + isPanning.set(true) 92 + runOnJS(pauseDestroy)() 93 + }) 94 + .onUpdate(e => { 95 + 'worklet' 96 + if (!alive) return 97 + dismissSwipeTranslateY.value = e.translationY 98 + }) 99 + .onEnd(e => { 100 + 'worklet' 101 + if (!alive) return 102 + runOnJS(hideAndDestroyAfterTimeout)() 103 + isPanning.set(false) 104 + if (e.velocityY < -100) { 105 + if (dismissSwipeTranslateY.value === 0) { 106 + // HACK: If the initial value is 0, withDecay() animation doesn't start. 107 + // This is a bug in Reanimated, but for now we'll work around it like this. 108 + dismissSwipeTranslateY.value = 1 109 + } 110 + dismissSwipeTranslateY.value = withDecay({ 111 + velocity: e.velocityY, 112 + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), 113 + deceleration: 1, 114 + }) 115 + } else { 116 + dismissSwipeTranslateY.value = withSpring(0, { 117 + stiffness: 500, 118 + damping: 50, 119 + }) 120 + } 121 + }) 122 + }, [ 123 + dismissSwipeTranslateY, 124 + isPanning, 125 + alive, 126 + hideAndDestroyAfterTimeout, 127 + pauseDestroy, 128 + ]) 129 + 130 + const topOffset = top + 10 131 + 132 + useAnimatedReaction( 133 + () => 134 + !isPanning.get() && 135 + dismissSwipeTranslateY.get() < -topOffset - cardHeight, 136 + (isSwipedAway, prevIsSwipedAway) => { 137 + 'worklet' 138 + if (isSwipedAway && !prevIsSwipedAway) { 139 + runOnJS(destroy)() 140 + } 141 + }, 142 + ) 143 + 144 + const animatedStyle = useAnimatedStyle(() => { 145 + const translation = dismissSwipeTranslateY.get() 146 + return { 147 + transform: [ 148 + { 149 + translateY: translation > 0 ? translation ** 0.7 : translation, 150 + }, 151 + ], 152 + } 153 + }) 49 154 50 155 return ( 51 - <View 52 - style={[a.absolute, {top: top + 15, left: 16, right: 16}]} 53 - pointerEvents="none"> 156 + <GestureHandlerRootView 157 + style={[a.absolute, {top: topOffset, left: 16, right: 16}]} 158 + pointerEvents="box-none"> 54 159 {alive && ( 55 160 <Animated.View 56 161 entering={FadeInUp} 57 162 exiting={FadeOutUp} 58 - style={[ 59 - a.flex_1, 60 - t.atoms.bg, 61 - a.shadow_lg, 62 - t.atoms.border_contrast_medium, 63 - a.rounded_sm, 64 - a.px_md, 65 - a.py_lg, 66 - a.border, 67 - a.flex_row, 68 - a.gap_md, 69 - ]}> 70 - <View 163 + style={[a.flex_1]}> 164 + <Animated.View 165 + onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 166 + accessibilityRole="alert" 167 + accessible={true} 168 + accessibilityLabel={message} 169 + accessibilityHint="" 170 + onAccessibilityEscape={hideAndDestroyImmediately} 71 171 style={[ 72 - a.flex_shrink_0, 73 - a.rounded_full, 74 - {width: 32, height: 32}, 75 - t.atoms.bg_contrast_25, 76 - a.align_center, 77 - a.justify_center, 172 + a.flex_1, 173 + t.atoms.bg, 174 + a.shadow_lg, 175 + t.atoms.border_contrast_medium, 176 + a.rounded_sm, 177 + a.border, 178 + animatedStyle, 78 179 ]}> 79 - <FontAwesomeIcon 80 - icon={icon} 81 - size={16} 82 - style={t.atoms.text_contrast_low} 83 - /> 84 - </View> 85 - <View style={[a.h_full, a.justify_center, a.flex_1]}> 86 - <Text style={a.text_md}>{message}</Text> 87 - </View> 180 + <GestureDetector gesture={panGesture}> 181 + <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> 182 + <View 183 + style={[ 184 + a.flex_shrink_0, 185 + a.rounded_full, 186 + {width: 32, height: 32}, 187 + {backgroundColor: t.palette.primary_50}, 188 + a.align_center, 189 + a.justify_center, 190 + ]}> 191 + <FontAwesomeIcon 192 + icon={icon} 193 + size={16} 194 + style={t.atoms.text_contrast_medium} 195 + /> 196 + </View> 197 + <View style={[a.h_full, a.justify_center, a.flex_1]}> 198 + <Text style={a.text_md}>{message}</Text> 199 + </View> 200 + </View> 201 + </GestureDetector> 202 + </Animated.View> 88 203 </Animated.View> 89 204 )} 90 - </View> 205 + </GestureHandlerRootView> 91 206 ) 92 207 }
+16 -1
src/view/com/util/Toast.web.tsx
··· 3 3 */ 4 4 5 5 import React, {useEffect, useState} from 'react' 6 - import {StyleSheet, Text, View} from 'react-native' 6 + import {Pressable, StyleSheet, Text, View} from 'react-native' 7 7 import { 8 8 FontAwesomeIcon, 9 9 FontAwesomeIconStyle, ··· 43 43 style={styles.icon as FontAwesomeIconStyle} 44 44 /> 45 45 <Text style={styles.text}>{activeToast.text}</Text> 46 + <Pressable 47 + style={styles.dismissBackdrop} 48 + accessibilityLabel="Dismiss" 49 + accessibilityHint="" 50 + onPress={() => { 51 + setActiveToast(undefined) 52 + }} 53 + /> 46 54 </View> 47 55 )} 48 56 </> ··· 76 84 alignItems: 'center', 77 85 backgroundColor: '#000c', 78 86 borderRadius: 10, 87 + }, 88 + dismissBackdrop: { 89 + position: 'absolute', 90 + top: 0, 91 + left: 0, 92 + bottom: 0, 93 + right: 0, 79 94 }, 80 95 icon: { 81 96 color: '#fff',