From 8bdaec64ee72eb7ba4c77a5238bd4bced9f7ce49 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 20 Oct 2025 18:13:07 -0500 Subject: [PATCH] Replace toast implementation on iOS --- src/components/Toast/Toast.tsx | 3 +- src/components/Toast/index.ios.tsx | 149 +++++++++++++++++++++++++++++ src/components/Toast/index.tsx | 1 + src/components/Toast/index.web.tsx | 1 + 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/components/Toast/index.ios.tsx diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index ac5bc4889a..1cbeaa9b3a 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -12,7 +12,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {type Props as SVGIconProps} from '#/components/icons/common' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' -import {dismiss} from '#/components/Toast/sonner' +import {dismiss} from '#/components/Toast' import {type ToastType} from '#/components/Toast/types' import {Text as BaseText} from '#/components/Typography' @@ -177,7 +177,6 @@ export function Action( }, [t, type]) const onPress = (e: GestureResponderEvent) => { - console.log('Toast Action pressed, dismissing toast', id) dismiss(id) props.onPress?.(e) } diff --git a/src/components/Toast/index.ios.tsx b/src/components/Toast/index.ios.tsx new file mode 100644 index 0000000000..041b088820 --- /dev/null +++ b/src/components/Toast/index.ios.tsx @@ -0,0 +1,149 @@ +import React, {useSyncExternalStore} from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {nanoid} from 'nanoid/non-secure' +import {toast as sonner} from 'sonner-native' + +import {atoms as a} from '#/alf' +import {DURATION} from '#/components/Toast/const' +import { + Icon as ToastIcon, + Outer as BaseOuter, + Text as ToastText, + ToastConfigProvider, +} from '#/components/Toast/Toast' +import {type BaseToastOptions} from '#/components/Toast/types' + +export {DURATION} from '#/components/Toast/const' +export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast' +export {type ToastType} from '#/components/Toast/types' + +type ToastConfig = { + id: string + options: BaseToastOptions + component: React.ReactNode +} + +class ToastsManager { + private toasts: ToastConfig[] = [] + private listeners = new Set<() => void>() + + getToasts = (): ToastConfig[] => { + return this.toasts + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + sync = () => { + this.listeners.forEach(listener => listener()) + } + + add = (toast: ToastConfig) => { + this.toasts = [...this.toasts, toast] + this.sync() + + setTimeout(() => { + this.remove(toast.id) + }, toast.options.duration || DURATION) + } + + remove = (id: string) => { + this.toasts = this.toasts.filter(t => t.id !== id) + this.sync() + } +} + +const manager = new ToastsManager() + +export const dismiss = (id: string) => { + manager.remove(id) +} + +/** + * Toasts are rendered in a global outlet, which is placed at the top of the + * component tree. + */ +export function ToastOutlet() { + const toasts = useSyncExternalStore(manager.subscribe, manager.getToasts) + const insets = useSafeAreaInsets() + return ( + + {toasts.map(({id, component}) => ( + {component} + ))} + + ) +} + +export function Outer({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} + +/** + * Access the full Sonner API + * @deprecated TBD + */ +export const api = sonner + +/** + * Our base toast API, using the `Toast` export of this file. + */ +export function show( + content: React.ReactNode, + {type = 'default', ...options}: BaseToastOptions = {}, +) { + const id = nanoid() + + if (typeof content === 'string') { + manager.add({ + id, + options: { + ...options, + duration: options?.duration ?? DURATION, + }, + component: ( + + + + {content} + + + ), + }) + } else if (React.isValidElement(content)) { + manager.add({ + id, + options: { + ...options, + duration: options?.duration ?? DURATION, + }, + component: ( + + {content} + + ), + }) + } else { + throw new Error( + `Toast can be a string or a React element, got ${typeof content}`, + ) + } +} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index d70a8ad164..54afeafb11 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -14,6 +14,7 @@ import { import {type BaseToastOptions} from '#/components/Toast/types' export {DURATION} from '#/components/Toast/const' +export {dismiss} from '#/components/Toast/sonner' export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast' export {type ToastType} from '#/components/Toast/types' diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index 8b2028db98..95a4ff996a 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -13,6 +13,7 @@ import { import {type BaseToastOptions} from '#/components/Toast/types' export {DURATION} from '#/components/Toast/const' +export {dismiss} from '#/components/Toast/sonner' export * from '#/components/Toast/Toast' export {type ToastType} from '#/components/Toast/types'