Rough sketch

This commit is contained in:
Eric Bailey
2025-08-12 21:36:09 -05:00
parent cc00102f18
commit d347717086
12 changed files with 355 additions and 126 deletions
+56 -53
View File
@@ -73,6 +73,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastProvider} from '#/components/Toast'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet' import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -133,59 +134,61 @@ function InnerApp() {
<ContextMenuProvider> <ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}> <Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent> <RootSiblingParent>
<VideoVolumeProvider> <ToastProvider>
<React.Fragment <VideoVolumeProvider>
// Resets the entire tree below when it changes: <React.Fragment
key={currentAccount?.did}> // Resets the entire tree below when it changes:
<QueryProvider currentDid={currentAccount?.did}> key={currentAccount?.did}>
<PolicyUpdateOverlayProvider> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <PolicyUpdateOverlayProvider>
<AgeAssuranceProvider> <StatsigProvider>
<ComposerProvider> <AgeAssuranceProvider>
<MessagesProvider> <ComposerProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<HomeBadgeProvider> <HiddenRepliesProvider>
<UnreadNotifsProvider> <HomeBadgeProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<ProgressGuideProvider> <MutedThreadsProvider>
<ServiceAccountManager> <ProgressGuideProvider>
<HideBottomBarBorderProvider> <ServiceAccountManager>
<GestureHandlerRootView <HideBottomBarBorderProvider>
style={s.h100pct}> <GestureHandlerRootView
<GlobalGestureEventsProvider> style={s.h100pct}>
<IntentDialogProvider> <GlobalGestureEventsProvider>
<TestCtrls /> <IntentDialogProvider>
<Shell /> <TestCtrls />
<NuxDialogs /> <Shell />
</IntentDialogProvider> <NuxDialogs />
</GlobalGestureEventsProvider> </IntentDialogProvider>
</GestureHandlerRootView> </GlobalGestureEventsProvider>
</HideBottomBarBorderProvider> </GestureHandlerRootView>
</ServiceAccountManager> </HideBottomBarBorderProvider>
</ProgressGuideProvider> </ServiceAccountManager>
</MutedThreadsProvider> </ProgressGuideProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HomeBadgeProvider> </UnreadNotifsProvider>
</HiddenRepliesProvider> </HomeBadgeProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</ComposerProvider> </MessagesProvider>
</AgeAssuranceProvider> </ComposerProvider>
</StatsigProvider> </AgeAssuranceProvider>
</PolicyUpdateOverlayProvider> </StatsigProvider>
</QueryProvider> </PolicyUpdateOverlayProvider>
</React.Fragment> </QueryProvider>
</VideoVolumeProvider> </React.Fragment>
</VideoVolumeProvider>
</ToastProvider>
</RootSiblingParent> </RootSiblingParent>
</Splash> </Splash>
</ContextMenuProvider> </ContextMenuProvider>
+46 -44
View File
@@ -61,7 +61,7 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 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 {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
@@ -112,28 +112,28 @@ function InnerApp() {
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ContextMenuProvider> <ContextMenuProvider>
<RootSiblingParent> <RootSiblingParent>
<VideoVolumeProvider> <ToastProvider>
<ActiveVideoProvider> <VideoVolumeProvider>
<React.Fragment <ActiveVideoProvider>
// Resets the entire tree below when it changes: <React.Fragment
key={currentAccount?.did}> // Resets the entire tree below when it changes:
<QueryProvider currentDid={currentAccount?.did}> key={currentAccount?.did}>
<PolicyUpdateOverlayProvider> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <PolicyUpdateOverlayProvider>
<AgeAssuranceProvider> <StatsigProvider>
<ComposerProvider> <AgeAssuranceProvider>
<MessagesProvider> <ComposerProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<HomeBadgeProvider> <HiddenRepliesProvider>
<UnreadNotifsProvider> <HomeBadgeProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <ProgressGuideProvider>
<ServiceConfigProvider> <ServiceConfigProvider>
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
@@ -144,26 +144,26 @@ function InnerApp() {
</HideBottomBarBorderProvider> </HideBottomBarBorderProvider>
</ServiceConfigProvider> </ServiceConfigProvider>
</ProgressGuideProvider> </ProgressGuideProvider>
</SafeAreaProvider> </MutedThreadsProvider>
</MutedThreadsProvider> </BackgroundNotificationPreferencesProvider>
</BackgroundNotificationPreferencesProvider> </UnreadNotifsProvider>
</UnreadNotifsProvider> </HomeBadgeProvider>
</HomeBadgeProvider> </HiddenRepliesProvider>
</HiddenRepliesProvider> </SelectedFeedProvider>
</SelectedFeedProvider> </LoggedOutViewProvider>
</LoggedOutViewProvider> </ModerationOptsProvider>
</ModerationOptsProvider> </LabelDefsProvider>
</LabelDefsProvider> </MessagesProvider>
</MessagesProvider> </ComposerProvider>
</ComposerProvider> </AgeAssuranceProvider>
</AgeAssuranceProvider> </StatsigProvider>
</StatsigProvider> </PolicyUpdateOverlayProvider>
</PolicyUpdateOverlayProvider> </QueryProvider>
</QueryProvider> <ToastContainer />
<ToastContainer /> </React.Fragment>
</React.Fragment> </ActiveVideoProvider>
</ActiveVideoProvider> </VideoVolumeProvider>
</VideoVolumeProvider> </ToastProvider>
</RootSiblingParent> </RootSiblingParent>
</ContextMenuProvider> </ContextMenuProvider>
</ThemeProvider> </ThemeProvider>
@@ -201,7 +201,9 @@ function App() {
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
<StarterPackProvider> <StarterPackProvider>
<InnerApp /> <SafeAreaProvider>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider> </StarterPackProvider>
</PortalProvider> </PortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
+13
View File
@@ -0,0 +1,13 @@
import {createContext, useContext} from 'react'
import {type ToastApi} from '#/components/Toast/types'
export type ToastContext = ToastApi
export const Context = createContext<ToastContext>({
show() {},
})
export function useToast() {
return useContext(Context)
}
+7
View File
@@ -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
+88 -3
View File
@@ -1,5 +1,5 @@
import {useEffect, useMemo, useRef, useState} from 'react' import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {AccessibilityInfo} from 'react-native' import {AccessibilityInfo, View} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
@@ -18,15 +18,100 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings' import RootSiblings from 'react-native-root-siblings'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 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 {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 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<ToastApi['show']>(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 (
<Context.Provider value={ctx}>
<Portal.Provider>
{children}
{toasts.length ? (
<Portal.Portal>
<View
style={[
a.absolute,
a.gap_sm,
{
flexDirection: 'column-reverse',
top: top,
left: a.px_lg.paddingLeft,
right: a.px_lg.paddingLeft,
},
]}>
{toasts.map(({id, toast}) => (
<Toast key={id} type={toast.type} content={toast.content} />
))}
</View>
</Portal.Portal>
) : null}
</Portal.Provider>
</Context.Provider>
)
}
export function ToastContainer() { export function ToastContainer() {
return null return null
} }
+92 -2
View File
@@ -2,15 +2,26 @@
* Note: relies on styles in #/styles.css * 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 {AccessibilityInfo, Pressable, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 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 {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 = { const TOAST_ANIMATION_STYLES = {
entering: { entering: {
@@ -31,6 +42,85 @@ let globalSetActiveToast: GlobalSetActiveToast | undefined
let toastTimeout: NodeJS.Timeout | undefined let toastTimeout: NodeJS.Timeout | undefined
type ToastContainerProps = {} 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<ToastApi['show']>(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 (
<Context.Provider value={ctx}>
<Portal.Provider>
{children}
{toasts.length && (
<Portal.Portal>
<View
style={[
a.fixed,
a.gap_sm,
{
flexDirection: 'column-reverse',
left: a.px_xl.paddingLeft,
right: a.px_xl.paddingLeft,
bottom: Math.max(bottom, a.px_xl.paddingLeft),
},
gtPhone && [
{
maxWidth: 380,
},
],
]}>
{toasts.map(({id, toast}) => (
<Toast key={id} content={toast.content} type={toast.type} />
))}
</View>
</Portal.Portal>
)}
</Portal.Provider>
</Context.Provider>
)
}
export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
const {_} = useLingui() const {_} = useLingui()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
+23 -21
View File
@@ -1,24 +1,26 @@
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
export type ToastApi = { export type ToastProps = {
show: (props: { /**
/** * The type of toast to show. This determines the styling and icon used.
* The type of toast to show. This determines the styling and icon used. */
*/ type: ToastType
type: ToastType /**
/** * A string, `Text`, or `Span` components to render inside the toast. This
* A string, `Text`, or `Span` components to render inside the toast. This * allows additional formatting of the content, but should not be used for
* allows additional formatting of the content, but should not be used for * interactive elements link links or buttons.
* interactive elements link links or buttons. */
*/ content: React.ReactNode | string
content: React.ReactNode | string /**
/** * Accessibility label for the toast, used for screen readers.
* Accessibility label for the toast, used for screen readers. */
*/ a11yLabel: string
a11yLabel: string /**
/** * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`.
* Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. */
*/ duration?: number
duration?: number }
}) => void
export type ToastApi = {
show: (props: ToastProps) => void
} }
+9 -2
View File
@@ -2,6 +2,7 @@ import {View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {PressableScale} from '#/lib/custom-animations/PressableScale' 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 {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {IS_DEV} from '#/env'
export function HomeHeaderLayoutMobile({ export function HomeHeaderLayoutMobile({
children, children,
@@ -29,6 +31,7 @@ export function HomeHeaderLayoutMobile({
const headerMinimalShellTransform = useMinimalShellHeaderTransform() const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession() const {hasSession} = useSession()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const navigate = useNavigation()
return ( return (
<Animated.View <Animated.View
@@ -55,8 +58,12 @@ export function HomeHeaderLayoutMobile({
<PressableScale <PressableScale
targetScale={0.9} targetScale={0.9}
onPress={() => { onPress={() => {
playHaptic('Light') if (IS_DEV) {
emitSoftReset() navigate.navigate('Debug')
} else {
playHaptic('Light')
emitSoftReset()
}
}}> }}>
<Logo width={30} /> <Logo width={30} />
</PressableScale> </PressableScale>
+16
View File
@@ -4,14 +4,30 @@ import {show as deprecatedShow} from '#/view/com/util/Toast'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {toast} from '#/components/Toast' import {toast} from '#/components/Toast'
import {useToast} from '#/components/Toast/Context'
import {Toast} from '#/components/Toast/Toast' import {Toast} from '#/components/Toast/Toast'
import {H1} from '#/components/Typography' import {H1} from '#/components/Typography'
export function Toasts() { export function Toasts() {
const {show} = useToast()
return ( return (
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
<H1>Toast Examples</H1> <H1>Toast Examples</H1>
<Button
label="show toast"
size="large"
color="primary"
onPress={() => {
show({
type: 'default',
content: 'This is a toast example',
a11yLabel: 'This is a toast example',
})
}}>
<ButtonText>New Toast</ButtonText>
</Button>
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
+1 -1
View File
@@ -86,6 +86,7 @@ function StorybookInner() {
<ButtonText>Dark</ButtonText> <ButtonText>Dark</ButtonText>
</Button> </Button>
</View> </View>
<Toasts />
<Button <Button
color="primary" color="primary"
@@ -106,7 +107,6 @@ function StorybookInner() {
<Theming /> <Theming />
</ThemeProvider> </ThemeProvider>
<Toasts />
<Buttons /> <Buttons />
<Forms /> <Forms />
<Typography /> <Typography />
+2
View File
@@ -36,6 +36,7 @@ import {
usePolicyUpdateContext, usePolicyUpdateContext,
} from '#/components/PolicyUpdateOverlay' } from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {Outlet as ToastPortalOutlet} from '#/components/Toast/Portal'
import {RoutesContainer, TabsNavigator} from '#/Navigation' import {RoutesContainer, TabsNavigator} from '#/Navigation'
import {BottomSheetOutlet} from '../../../modules/bottom-sheet' import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
@@ -172,6 +173,7 @@ function ShellInner() {
<> <>
<PortalOutlet /> <PortalOutlet />
<BottomSheetOutlet /> <BottomSheetOutlet />
<ToastPortalOutlet />
</> </>
)} )}
+2
View File
@@ -27,6 +27,7 @@ import {
usePolicyUpdateContext, usePolicyUpdateContext,
} from '#/components/PolicyUpdateOverlay' } from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {Outlet as ToastPortalOutlet} from '#/components/Toast/Portal'
import {FlatNavigator, RoutesContainer} from '#/Navigation' import {FlatNavigator, RoutesContainer} from '#/Navigation'
import {Composer} from './Composer.web' import {Composer} from './Composer.web'
import {DrawerContent} from './Drawer' import {DrawerContent} from './Drawer'
@@ -84,6 +85,7 @@ function ShellInner() {
{policyUpdateState.completed && ( {policyUpdateState.completed && (
<> <>
<PortalOutlet /> <PortalOutlet />
<ToastPortalOutlet />
</> </>
)} )}