From d347717086f652029e97336dbcd5a64d6723c4ec Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 12 Aug 2025 21:36:09 -0500 Subject: [PATCH] Rough sketch --- src/App.native.tsx | 109 ++++++++++--------- src/App.web.tsx | 90 +++++++-------- src/components/Toast/Context.tsx | 13 +++ src/components/Toast/Portal.tsx | 7 ++ src/components/Toast/index.tsx | 91 +++++++++++++++- src/components/Toast/index.web.tsx | 94 +++++++++++++++- src/components/Toast/types.ts | 44 ++++---- src/view/com/home/HomeHeaderLayoutMobile.tsx | 11 +- src/view/screens/Storybook/Toasts.tsx | 16 +++ src/view/screens/Storybook/index.tsx | 2 +- src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + 12 files changed, 355 insertions(+), 126 deletions(-) create mode 100644 src/components/Toast/Context.tsx create mode 100644 src/components/Toast/Portal.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index 4037eedd27..3aca0524a0 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -73,6 +73,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' import {Provider as PortalProvider} from '#/components/Portal' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ToastProvider} from '#/components/Toast' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -133,59 +134,61 @@ function InnerApp() { - - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 2897aa3f2f..8edeeb74d1 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -61,7 +61,7 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' -import {ToastContainer} from '#/components/Toast' +import {ToastContainer, ToastProvider} from '#/components/Toast' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' @@ -112,28 +112,28 @@ function InnerApp() { - - - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - + + + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + @@ -144,26 +144,26 @@ function InnerApp() { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -201,7 +201,9 @@ function App() { - + + + diff --git a/src/components/Toast/Context.tsx b/src/components/Toast/Context.tsx new file mode 100644 index 0000000000..1fd593fae2 --- /dev/null +++ b/src/components/Toast/Context.tsx @@ -0,0 +1,13 @@ +import {createContext, useContext} from 'react' + +import {type ToastApi} from '#/components/Toast/types' + +export type ToastContext = ToastApi + +export const Context = createContext({ + show() {}, +}) + +export function useToast() { + return useContext(Context) +} diff --git a/src/components/Toast/Portal.tsx b/src/components/Toast/Portal.tsx new file mode 100644 index 0000000000..31e0452fc4 --- /dev/null +++ b/src/components/Toast/Portal.tsx @@ -0,0 +1,7 @@ +import {createPortalGroup} from '#/components/Portal' + +const portal = createPortalGroup() + +export const Provider = portal.Provider +export const Outlet = portal.Outlet +export const Portal = portal.Portal diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 844d7f900f..57bb56e30c 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,5 +1,5 @@ -import {useEffect, useMemo, useRef, useState} from 'react' -import {AccessibilityInfo} from 'react-native' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {AccessibilityInfo, View} from 'react-native' import { Gesture, GestureDetector, @@ -18,15 +18,100 @@ import Animated, { } from 'react-native-reanimated' import RootSiblings from 'react-native-root-siblings' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {Context} from '#/components/Toast/Context' +import * as Portal from '#/components/Toast/Portal' import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' +import { + type ToastApi, + type ToastProps, + type ToastType, +} from '#/components/Toast/types' + +export {useToast} from '#/components/Toast/Context' +export {Outlet} from '#/components/Toast/Portal' const TOAST_ANIMATION_DURATION = 300 +export function ToastProvider({children}: {children: React.ReactNode}) { + const {top} = useSafeAreaInsets() + const [toasts, setToasts] = useState< + { + id: string + toast: ToastProps + timeout: NodeJS.Timeout + }[] + >([]) + + const show = useCallback(props => { + AccessibilityInfo.announceForAccessibility(props.a11yLabel) + + setToasts(prevToasts => { + const id = nanoid() + return [ + ...prevToasts, + { + id, + toast: props, + timeout: setTimeout(() => { + setToasts(currentToasts => currentToasts.filter(t => t.id !== id)) + }, props.duration || DEFAULT_TOAST_DURATION), + }, + ] + }) + }, []) + + useEffect(() => { + return () => { + setToasts(toasts => { + toasts.map(({timeout}) => { + clearTimeout(timeout) + }) + return toasts + }) + } + }, []) + + const ctx = useMemo( + () => ({ + show, + }), + [show], + ) + + return ( + + + {children} + + {toasts.length ? ( + + + {toasts.map(({id, toast}) => ( + + ))} + + + ) : null} + + + ) +} + export function ToastContainer() { return null } diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index f2517e28da..51a4b6a1bb 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -2,15 +2,26 @@ * Note: relies on styles in #/styles.css */ -import {useEffect, useState} from 'react' +import {useCallback, useEffect, useMemo, useState} from 'react' import {AccessibilityInfo, Pressable, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' import {atoms as a, useBreakpoints} from '#/alf' import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {Context} from '#/components/Toast/Context' +import * as Portal from '#/components/Toast/Portal' import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' +import { + type ToastApi, + type ToastProps, + type ToastType, +} from '#/components/Toast/types' + +export {useToast} from '#/components/Toast/Context' +export {Outlet} from '#/components/Toast/Portal' const TOAST_ANIMATION_STYLES = { entering: { @@ -31,6 +42,85 @@ let globalSetActiveToast: GlobalSetActiveToast | undefined let toastTimeout: NodeJS.Timeout | undefined type ToastContainerProps = {} +export function ToastProvider({children}: {children: React.ReactNode}) { + const {bottom} = useSafeAreaInsets() + const {gtPhone} = useBreakpoints() + const [toasts, setToasts] = useState< + { + id: string + toast: ToastProps + timeout: NodeJS.Timeout + }[] + >([]) + + const show = useCallback(props => { + setToasts(prevToasts => { + const id = nanoid() + return [ + ...prevToasts, + { + id, + toast: props, + timeout: setTimeout(() => { + setToasts(currentToasts => currentToasts.filter(t => t.id !== id)) + }, props.duration || DEFAULT_TOAST_DURATION), + }, + ] + }) + }, []) + + useEffect(() => { + return () => { + setToasts(toasts => { + toasts.map(({timeout}) => { + clearTimeout(timeout) + }) + return toasts + }) + } + }, []) + + const ctx = useMemo( + () => ({ + show, + }), + [show], + ) + + return ( + + + {children} + + {toasts.length && ( + + + {toasts.map(({id, toast}) => ( + + ))} + + + )} + + + ) +} + export const ToastContainer: React.FC = ({}) => { const {_} = useLingui() const {gtPhone} = useBreakpoints() diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts index 9f1245fa22..062ac7ab2c 100644 --- a/src/components/Toast/types.ts +++ b/src/components/Toast/types.ts @@ -1,24 +1,26 @@ export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' -export type ToastApi = { - show: (props: { - /** - * The type of toast to show. This determines the styling and icon used. - */ - type: ToastType - /** - * A string, `Text`, or `Span` components to render inside the toast. This - * allows additional formatting of the content, but should not be used for - * interactive elements link links or buttons. - */ - content: React.ReactNode | string - /** - * Accessibility label for the toast, used for screen readers. - */ - a11yLabel: string - /** - * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. - */ - duration?: number - }) => void +export type ToastProps = { + /** + * The type of toast to show. This determines the styling and icon used. + */ + type: ToastType + /** + * A string, `Text`, or `Span` components to render inside the toast. This + * allows additional formatting of the content, but should not be used for + * interactive elements link links or buttons. + */ + content: React.ReactNode | string + /** + * Accessibility label for the toast, used for screen readers. + */ + a11yLabel: string + /** + * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. + */ + duration?: number +} + +export type ToastApi = { + show: (props: ToastProps) => void } diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index 7a40604f43..896538d397 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -2,6 +2,7 @@ import {View} from 'react-native' import Animated from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' import {HITSLOP_10} from '#/lib/constants' import {PressableScale} from '#/lib/custom-animations/PressableScale' @@ -16,6 +17,7 @@ import {ButtonIcon} from '#/components/Button' import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' +import {IS_DEV} from '#/env' export function HomeHeaderLayoutMobile({ children, @@ -29,6 +31,7 @@ export function HomeHeaderLayoutMobile({ const headerMinimalShellTransform = useMinimalShellHeaderTransform() const {hasSession} = useSession() const playHaptic = useHaptics() + const navigate = useNavigation() return ( { - playHaptic('Light') - emitSoftReset() + if (IS_DEV) { + navigate.navigate('Debug') + } else { + playHaptic('Light') + emitSoftReset() + } }}> diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 8fc6f095f7..bce8702db7 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -4,14 +4,30 @@ import {show as deprecatedShow} from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {toast} from '#/components/Toast' +import {useToast} from '#/components/Toast/Context' import {Toast} from '#/components/Toast/Toast' import {H1} from '#/components/Typography' export function Toasts() { + const {show} = useToast() return (

Toast Examples

+ + Dark +