diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 105afe13de..b57e676aea 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -1,6 +1,20 @@
-import {useEffect, useState} from 'react'
-import {View} from 'react-native'
-import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated'
+import {useEffect, useMemo, useRef, useState} from 'react'
+import {AccessibilityInfo, View} from 'react-native'
+import {
+ Gesture,
+ GestureDetector,
+ GestureHandlerRootView,
+} from 'react-native-gesture-handler'
+import Animated, {
+ FadeInUp,
+ FadeOutUp,
+ runOnJS,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withDecay,
+ withSpring,
+} from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
@@ -8,6 +22,7 @@ import {
Props as FontAwesomeProps,
} from '@fortawesome/react-native-fontawesome'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {IS_TEST} from '#/env'
@@ -19,74 +34,174 @@ export function show(
icon: FontAwesomeProps['icon'] = 'check',
) {
if (IS_TEST) return
- const item = new RootSiblings()
- // timeout has some leeway to account for the animation
- setTimeout(() => {
- item.destroy()
- }, TIMEOUT + 1e3)
+ AccessibilityInfo.announceForAccessibility(message)
+ const item = new RootSiblings(
+ item.destroy()} />,
+ )
}
function Toast({
message,
icon,
+ destroy,
}: {
message: string
icon: FontAwesomeProps['icon']
+ destroy: () => void
}) {
const t = useTheme()
const {top} = useSafeAreaInsets()
+ const isPanning = useSharedValue(false)
+ const dismissSwipeTranslateY = useSharedValue(0)
+ const [cardHeight, setCardHeight] = useState(0)
// for the exit animation to work on iOS the animated component
// must not be the root component
// so we need to wrap it in a view and unmount the toast ahead of time
const [alive, setAlive] = useState(true)
- useEffect(() => {
+ const hideAndDestroyImmediately = () => {
+ setAlive(false)
setTimeout(() => {
- setAlive(false)
- }, TIMEOUT)
- }, [])
+ destroy()
+ }, 1e3)
+ }
+
+ const destroyTimeoutRef = useRef>()
+ const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
+ clearTimeout(destroyTimeoutRef.current)
+ destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT)
+ })
+ const pauseDestroy = useNonReactiveCallback(() => {
+ clearTimeout(destroyTimeoutRef.current)
+ })
+
+ useEffect(() => {
+ hideAndDestroyAfterTimeout()
+ }, [hideAndDestroyAfterTimeout])
+
+ const panGesture = useMemo(() => {
+ return Gesture.Pan()
+ .activeOffsetY([-10, 10])
+ .failOffsetX([-10, 10])
+ .maxPointers(1)
+ .onStart(() => {
+ 'worklet'
+ if (!alive) return
+ isPanning.set(true)
+ runOnJS(pauseDestroy)()
+ })
+ .onUpdate(e => {
+ 'worklet'
+ if (!alive) return
+ dismissSwipeTranslateY.value = e.translationY
+ })
+ .onEnd(e => {
+ 'worklet'
+ if (!alive) return
+ runOnJS(hideAndDestroyAfterTimeout)()
+ isPanning.set(false)
+ if (e.velocityY < -100) {
+ if (dismissSwipeTranslateY.value === 0) {
+ // HACK: If the initial value is 0, withDecay() animation doesn't start.
+ // This is a bug in Reanimated, but for now we'll work around it like this.
+ dismissSwipeTranslateY.value = 1
+ }
+ dismissSwipeTranslateY.value = withDecay({
+ velocity: e.velocityY,
+ velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
+ deceleration: 1,
+ })
+ } else {
+ dismissSwipeTranslateY.value = withSpring(0, {
+ stiffness: 500,
+ damping: 50,
+ })
+ }
+ })
+ }, [
+ dismissSwipeTranslateY,
+ isPanning,
+ alive,
+ hideAndDestroyAfterTimeout,
+ pauseDestroy,
+ ])
+
+ const topOffset = top + 10
+
+ useAnimatedReaction(
+ () =>
+ !isPanning.get() &&
+ dismissSwipeTranslateY.get() < -topOffset - cardHeight,
+ (isSwipedAway, prevIsSwipedAway) => {
+ 'worklet'
+ if (isSwipedAway && !prevIsSwipedAway) {
+ runOnJS(destroy)()
+ }
+ },
+ )
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const translation = dismissSwipeTranslateY.get()
+ return {
+ transform: [
+ {
+ translateY: translation > 0 ? translation ** 0.7 : translation,
+ },
+ ],
+ }
+ })
return (
-
+
{alive && (
-
+ setCardHeight(evt.nativeEvent.layout.height)}
+ accessibilityRole="alert"
+ accessible={true}
+ accessibilityLabel={message}
+ accessibilityHint=""
+ onAccessibilityEscape={hideAndDestroyImmediately}
style={[
- a.flex_shrink_0,
- a.rounded_full,
- {width: 32, height: 32},
- t.atoms.bg_contrast_25,
- a.align_center,
- a.justify_center,
+ a.flex_1,
+ t.atoms.bg,
+ a.shadow_lg,
+ t.atoms.border_contrast_medium,
+ a.rounded_sm,
+ a.border,
+ animatedStyle,
]}>
-
-
-
- {message}
-
+
+
+
+
+
+
+ {message}
+
+
+
+
)}
-
+
)
}
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index 1f9eb479b6..96798e61c4 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -3,7 +3,7 @@
*/
import React, {useEffect, useState} from 'react'
-import {StyleSheet, Text, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@@ -43,6 +43,14 @@ export const ToastContainer: React.FC = ({}) => {
style={styles.icon as FontAwesomeIconStyle}
/>
{activeToast.text}
+ {
+ setActiveToast(undefined)
+ }}
+ />
)}
>
@@ -77,6 +85,13 @@ const styles = StyleSheet.create({
backgroundColor: '#000c',
borderRadius: 10,
},
+ dismissBackdrop: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ bottom: 0,
+ right: 0,
+ },
icon: {
color: '#fff',
flexShrink: 0,