about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/Toast.tsx203
-rw-r--r--src/view/com/util/Toast.web.tsx17
2 files changed, 175 insertions, 45 deletions
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 105afe13d..b57e676ae 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(<Toast message={message} icon={icon} />)
-  // timeout has some leeway to account for the animation
-  setTimeout(() => {
-    item.destroy()
-  }, TIMEOUT + 1e3)
+  AccessibilityInfo.announceForAccessibility(message)
+  const item = new RootSiblings(
+    <Toast message={message} icon={icon} destroy={() => 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<ReturnType<typeof setTimeout>>()
+  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 (
-    <View
-      style={[a.absolute, {top: top + 15, left: 16, right: 16}]}
-      pointerEvents="none">
+    <GestureHandlerRootView
+      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
+      pointerEvents="box-none">
       {alive && (
         <Animated.View
           entering={FadeInUp}
           exiting={FadeOutUp}
-          style={[
-            a.flex_1,
-            t.atoms.bg,
-            a.shadow_lg,
-            t.atoms.border_contrast_medium,
-            a.rounded_sm,
-            a.px_md,
-            a.py_lg,
-            a.border,
-            a.flex_row,
-            a.gap_md,
-          ]}>
-          <View
+          style={[a.flex_1]}>
+          <Animated.View
+            onLayout={evt => 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,
             ]}>
-            <FontAwesomeIcon
-              icon={icon}
-              size={16}
-              style={t.atoms.text_contrast_low}
-            />
-          </View>
-          <View style={[a.h_full, a.justify_center, a.flex_1]}>
-            <Text style={a.text_md}>{message}</Text>
-          </View>
+            <GestureDetector gesture={panGesture}>
+              <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
+                <View
+                  style={[
+                    a.flex_shrink_0,
+                    a.rounded_full,
+                    {width: 32, height: 32},
+                    {backgroundColor: t.palette.primary_50},
+                    a.align_center,
+                    a.justify_center,
+                  ]}>
+                  <FontAwesomeIcon
+                    icon={icon}
+                    size={16}
+                    style={t.atoms.text_contrast_medium}
+                  />
+                </View>
+                <View style={[a.h_full, a.justify_center, a.flex_1]}>
+                  <Text style={a.text_md}>{message}</Text>
+                </View>
+              </View>
+            </GestureDetector>
+          </Animated.View>
         </Animated.View>
       )}
-    </View>
+    </GestureHandlerRootView>
   )
 }
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index 1f9eb479b..96798e61c 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<ToastContainerProps> = ({}) => {
             style={styles.icon as FontAwesomeIconStyle}
           />
           <Text style={styles.text}>{activeToast.text}</Text>
+          <Pressable
+            style={styles.dismissBackdrop}
+            accessibilityLabel="Dismiss"
+            accessibilityHint=""
+            onPress={() => {
+              setActiveToast(undefined)
+            }}
+          />
         </View>
       )}
     </>
@@ -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,