From 2e2fcaeeed527580a6c485718544b85e8b4f52b9 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 28 Apr 2025 16:17:56 +0300 Subject: [PATCH] completely rearchitect toasts --- src/App.native.tsx | 2 + src/App.web.tsx | 2 +- src/view/com/util/Toast.tsx | 192 ++++++++++++++++++-------------- src/view/com/util/Toast.web.tsx | 9 +- 4 files changed, 115 insertions(+), 90 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 5023c48bb5..af51525449 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -61,6 +61,7 @@ import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-repli import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' +import {ToastContainer} from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -157,6 +158,7 @@ function InnerApp() { + diff --git a/src/App.web.tsx b/src/App.web.tsx index bbe23e5a56..ac15c99503 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -51,7 +51,7 @@ import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' -import {ToastContainer} from '#/view/com/util/Toast.web' +import {ToastContainer} from '#/view/com/util/Toast' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index b18ea4b4ff..360c829cdb 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,13 +1,7 @@ import {useEffect, useMemo, useRef, useState} from 'react' import {AccessibilityInfo, View} from 'react-native' -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' import Animated, { - FadeInUp, - FadeOutUp, runOnJS, useAnimatedReaction, useAnimatedStyle, @@ -15,12 +9,13 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated' -import RootSiblings from 'react-native-root-siblings' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { FontAwesomeIcon, - Props as FontAwesomeProps, + type Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' +import EventEmitter from 'eventemitter3' +import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' @@ -28,6 +23,40 @@ import {Text} from '#/components/Typography' const TIMEOUT = 2e3 +type ToastProps = { + message: string + icon: FontAwesomeProps['icon'] +} + +const SHOW_TOAST = 'show-toast' + +const ToastEventEmitter = new EventEmitter() + +export function ToastContainer() { + const [toasts, setToasts] = useState<(ToastProps & {key: string})[]>([]) + + useEffect(() => { + const listener = ({message, icon}: ToastProps) => { + console.log('Adding toast', message) + setToasts(prev => [...prev, {message, icon, key: nanoid()}]) + } + ToastEventEmitter.on(SHOW_TOAST, listener) + return () => { + ToastEventEmitter.off(SHOW_TOAST, listener) + } + }, []) + + console.log('toasts', toasts.length) + + return toasts.map(toast => ( + setToasts(prev => prev.filter(t => t !== toast))} + /> + )) +} + export function show( message: string, icon: FontAwesomeProps['icon'] = 'check', @@ -36,9 +65,7 @@ export function show( return } AccessibilityInfo.announceForAccessibility(message) - const item = new RootSiblings( - item.destroy()} />, - ) + ToastEventEmitter.emit(SHOW_TOAST, {message, icon}) } function Toast({ @@ -53,19 +80,23 @@ function Toast({ const t = useTheme() const {top} = useSafeAreaInsets() const isPanning = useSharedValue(false) + const animationTranslateY = useSharedValue(-200) 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) - const hideAndDestroyImmediately = () => { - setAlive(false) - setTimeout(() => { - destroy() - }, 1e3) + 'worklet' + animationTranslateY.set( + withSpring( + -200, + { + damping: 100, + stiffness: 800, + restDisplacementThreshold: 0.01, + }, + () => runOnJS(destroy)(), + ), + ) } const destroyTimeoutRef = useRef>() @@ -78,8 +109,15 @@ function Toast({ }) useEffect(() => { + animationTranslateY.set( + withSpring(0, { + damping: 100, + stiffness: 800, + restDisplacementThreshold: 0.01, + }), + ) hideAndDestroyAfterTimeout() - }, [hideAndDestroyAfterTimeout]) + }, [hideAndDestroyAfterTimeout, animationTranslateY]) const panGesture = useMemo(() => { return Gesture.Pan() @@ -88,18 +126,15 @@ function Toast({ .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) { @@ -115,15 +150,15 @@ function Toast({ }) } else { dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 500, - damping: 50, + damping: 75, + stiffness: 1000, + restDisplacementThreshold: 0.01, }) } }) }, [ dismissSwipeTranslateY, isPanning, - alive, hideAndDestroyAfterTimeout, pauseDestroy, ]) @@ -143,7 +178,7 @@ function Toast({ ) const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() + const translation = dismissSwipeTranslateY.get() + animationTranslateY.get() return { transform: [ { @@ -154,62 +189,51 @@ function Toast({ }) return ( - - {alive && ( - - setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={message} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} + setCardHeight(evt.nativeEvent.layout.height)} + accessibilityRole="alert" + accessible={true} + accessibilityLabel={message} + accessibilityHint="" + onAccessibilityEscape={hideAndDestroyImmediately} + style={[ + a.absolute, + {top: topOffset, left: 16, right: 16}, + a.flex_1, + t.name === 'dark' ? t.atoms.bg_contrast_25 : 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 96798e61c4..4da1c5d235 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -2,12 +2,12 @@ * Note: the dataSet properties are used to leverage custom CSS in public/index.html */ -import React, {useEffect, useState} from 'react' +import {useEffect, useState} from 'react' import {Pressable, StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, - FontAwesomeIconStyle, - Props as FontAwesomeProps, + type FontAwesomeIconStyle, + type Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' const DURATION = 3500 @@ -25,8 +25,7 @@ let toastTimeout: NodeJS.Timeout | undefined // components // = -type ToastContainerProps = {} -export const ToastContainer: React.FC = ({}) => { +export function ToastContainer() { const [activeToast, setActiveToast] = useState() useEffect(() => { globalSetActiveToast = (t: ActiveToast | undefined) => {