diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx new file mode 100644 index 000000000..bb8f2c326 --- /dev/null +++ b/packages/app/src/components/toast-host.tsx @@ -0,0 +1,328 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { + Animated, + Easing, + Platform, + Text, + ToastAndroid, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; +import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; +import { + HEADER_INNER_HEIGHT, + HEADER_INNER_HEIGHT_MOBILE, + HEADER_TOP_PADDING_MOBILE, +} from "@/constants/layout"; + +export type ToastVariant = "default" | "success" | "error"; + +export type ToastShowOptions = { + icon?: ReactNode; + variant?: ToastVariant; + durationMs?: number; + nativeAndroid?: boolean; + testID?: string; +}; + +export type ToastState = { + id: number; + content: ReactNode; + nativeMessage: string | null; + icon?: ReactNode; + variant: ToastVariant; + durationMs: number; + testID?: string; +}; + +export type ToastApi = { + show: (content: ReactNode, options?: ToastShowOptions) => void; + copied: (label?: string) => void; + error: (message: string) => void; +}; + +type ToastViewportPlacement = "app-shell" | "panel"; + +const DEFAULT_DURATION_MS = 2200; + +export function useToastHost(): { + api: ToastApi; + toast: ToastState | null; + dismiss: () => void; +} { + const [toast, setToast] = useState(null); + const idRef = useRef(0); + + const show = useCallback( + (content: ReactNode, options?: ToastShowOptions) => { + const nativeMessage = + typeof content === "string" + ? content.trim() + : null; + if (!content || nativeMessage === "") { + return; + } + + const variant = options?.variant ?? "default"; + const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; + const nativeAndroid = options?.nativeAndroid ?? false; + + if (Platform.OS === "android" && nativeAndroid && nativeMessage) { + const duration = + durationMs <= 2500 + ? ToastAndroid.SHORT + : ToastAndroid.LONG; + ToastAndroid.showWithGravity( + nativeMessage, + duration, + ToastAndroid.TOP + ); + return; + } + + idRef.current += 1; + setToast({ + id: idRef.current, + content, + nativeMessage, + icon: options?.icon, + variant, + durationMs, + testID: options?.testID, + }); + }, + [] + ); + + const api = useMemo( + () => ({ + show, + copied: (label?: string) => + show(label ? `Copied ${label}` : "Copied", { + variant: "success", + icon: , + }), + error: (message: string) => + show(message, { variant: "error", durationMs: 3200 }), + }), + [show] + ); + + const dismiss = useCallback(() => { + setToast(null); + }, []); + + return { api, toast, dismiss }; +} + +export function ToastViewport({ + toast, + onDismiss, + placement = "app-shell", +}: { + toast: ToastState | null; + onDismiss: () => void; + placement?: ToastViewportPlacement; +}) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(-8)).current; + const timeoutRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const animateOut = useCallback(() => { + clearTimer(); + Animated.parallel([ + Animated.timing(opacity, { + toValue: 0, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: -8, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + onDismiss(); + } + }); + }, [clearTimer, onDismiss, opacity, translateY]); + + useEffect(() => { + if (!toast) { + clearTimer(); + opacity.setValue(0); + translateY.setValue(-8); + return; + } + + clearTimer(); + opacity.setValue(0); + translateY.setValue(-8); + + Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: 0, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + + timeoutRef.current = setTimeout(() => { + animateOut(); + }, toast.durationMs); + + return () => { + clearTimer(); + }; + }, [animateOut, clearTimer, opacity, toast, translateY]); + + if (!toast) { + return null; + } + + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; + const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; + const topOffset = + placement === "app-shell" + ? insets.top + headerTopPadding + headerHeight + theme.spacing[2] + : theme.spacing[3]; + + const icon = + toast.icon ?? ( + toast.variant === "success" ? ( + + ) : toast.variant === "error" ? ( + + ) : null + ); + + const content = ( + + + {icon ? {icon} : null} + {typeof toast.content === "string" ? ( + + {toast.content} + + ) : ( + + {toast.content} + + )} + + + ); + + if ( + placement === "app-shell" && + Platform.OS === "web" && + typeof document !== "undefined" + ) { + return createPortal(content, getOverlayRoot()); + } + + return content; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + position: "absolute", + left: theme.spacing[4], + right: theme.spacing[4], + top: 0, + zIndex: OVERLAY_Z.toast, + alignItems: "center", + }, + toast: { + alignSelf: "center", + maxWidth: "92%", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.surface0, + borderRadius: theme.borderRadius.full, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, + }, + toastSuccess: { + borderColor: theme.colors.border, + }, + toastError: { + borderColor: theme.colors.destructive, + }, + iconSlot: { + alignItems: "center", + justifyContent: "center", + }, + contentSlot: { + flexShrink: 1, + minWidth: 0, + }, + message: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + messageError: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx index 9a0b49596..fc4496207 100644 --- a/packages/app/src/contexts/toast-context.tsx +++ b/packages/app/src/contexts/toast-context.tsx @@ -1,62 +1,9 @@ +import { createContext, useContext, type ReactNode } from "react"; import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { createPortal } from "react-dom"; -import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; -import { - Animated, - Easing, - Platform, - Text, - ToastAndroid, - View, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { CheckCircle2, AlertTriangle } from "lucide-react-native"; -import { - HEADER_INNER_HEIGHT, - HEADER_INNER_HEIGHT_MOBILE, - HEADER_TOP_PADDING_MOBILE, -} from "@/constants/layout"; - -type ToastVariant = "default" | "success" | "error"; - -export type ToastShowOptions = { - icon?: ReactNode; - variant?: ToastVariant; - durationMs?: number; - /** - * Set to true to use OS toast on Android. - */ - nativeAndroid?: boolean; - testID?: string; -}; - -type ToastState = { - id: number; - content: ReactNode; - nativeMessage: string | null; - icon?: ReactNode; - variant: ToastVariant; - durationMs: number; - testID?: string; -}; - -export type ToastApi = { - show: (content: ReactNode, options?: ToastShowOptions) => void; - copied: (label?: string) => void; - error: (message: string) => void; -}; - -const DEFAULT_DURATION_MS = 2200; + ToastViewport, + useToastHost, + type ToastApi, +} from "@/components/toast-host"; const ToastContext = createContext(null); @@ -69,259 +16,12 @@ export function useToast(): ToastApi { } export function ToastProvider({ children }: { children: ReactNode }) { - const [toast, setToast] = useState(null); - const idRef = useRef(0); - - const show = useCallback( - (content: ReactNode, options?: ToastShowOptions) => { - const nativeMessage = - typeof content === "string" - ? content.trim() - : null; - if (!content || nativeMessage === "") return; - - const variant = options?.variant ?? "default"; - const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; - const nativeAndroid = options?.nativeAndroid ?? false; - - if (Platform.OS === "android" && nativeAndroid && nativeMessage) { - const duration = - durationMs <= 2500 - ? ToastAndroid.SHORT - : ToastAndroid.LONG; - ToastAndroid.showWithGravity( - nativeMessage, - duration, - ToastAndroid.TOP - ); - return; - } - - idRef.current += 1; - setToast({ - id: idRef.current, - content, - nativeMessage, - icon: options?.icon, - variant, - durationMs, - testID: options?.testID, - }); - }, - [] - ); - - const api = useMemo( - () => ({ - show, - copied: (label?: string) => - show(label ? `Copied ${label}` : "Copied", { - variant: "success", - icon: , - }), - error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), - }), - [show] - ); + const { api, toast, dismiss } = useToastHost(); return ( {children} - setToast(null)} /> + ); } - -function ToastViewport({ - toast, - onDismiss, -}: { - toast: ToastState | null; - onDismiss: () => void; -}) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const opacity = useRef(new Animated.Value(0)).current; - const translateY = useRef(new Animated.Value(-8)).current; - const timeoutRef = useRef | null>(null); - - const clearTimer = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - const animateOut = useCallback(() => { - clearTimer(); - Animated.parallel([ - Animated.timing(opacity, { - toValue: 0, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: -8, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(({ finished }) => { - if (finished) { - onDismiss(); - } - }); - }, [clearTimer, onDismiss, opacity, translateY]); - - useEffect(() => { - if (!toast) { - clearTimer(); - opacity.setValue(0); - translateY.setValue(-8); - return; - } - - clearTimer(); - opacity.setValue(0); - translateY.setValue(-8); - - Animated.parallel([ - Animated.timing(opacity, { - toValue: 1, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: 0, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(); - - timeoutRef.current = setTimeout(() => { - animateOut(); - }, toast.durationMs); - - return () => { - clearTimer(); - }; - }, [animateOut, clearTimer, opacity, toast, translateY]); - - if (!toast) { - return null; - } - - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; - const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - - const icon = - toast.icon ?? ( - toast.variant === "success" ? ( - - ) : toast.variant === "error" ? ( - - ) : null - ); - - const content = ( - - - {icon ? {icon} : null} - {typeof toast.content === "string" ? ( - - {toast.content} - - ) : ( - - {toast.content} - - )} - - - ); - - // On web, portal to overlay root to control stacking order - if (Platform.OS === "web" && typeof document !== "undefined") { - return createPortal(content, getOverlayRoot()); - } - - return content; -} - -const styles = StyleSheet.create((theme) => ({ - container: { - position: "absolute", - left: theme.spacing[4], - right: theme.spacing[4], - top: 0, - zIndex: OVERLAY_Z.toast, - alignItems: "center", - }, - toast: { - alignSelf: "center", - maxWidth: "92%", - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - backgroundColor: theme.colors.surface0, - borderRadius: theme.borderRadius.full, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 8, - }, - toastSuccess: { - borderColor: theme.colors.border, - }, - toastError: { - borderColor: theme.colors.destructive, - }, - iconSlot: { - alignItems: "center", - justifyContent: "center", - }, - contentSlot: { - flexShrink: 1, - minWidth: 0, - }, - message: { - flexShrink: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - messageError: { - color: theme.colors.foreground, - }, -})); diff --git a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx index ec04586ed..967af3ab8 100644 --- a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx +++ b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { ActivityIndicator } from "react-native"; -import { useToast } from "@/contexts/toast-context"; +import type { ToastShowOptions } from "@/components/toast-host"; const HISTORY_REFRESH_TOAST_DELAY_MS = 1000; const HISTORY_REFRESH_TOAST_DURATION_MS = 2200; @@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200; interface UseDelayedHistoryRefreshToastParams { isCatchingUp: boolean; indicatorColor: string; + showToast: (content: ReactNode, options?: ToastShowOptions) => void; } export function useDelayedHistoryRefreshToast({ isCatchingUp, indicatorColor, + showToast, }: UseDelayedHistoryRefreshToastParams): void { - const toast = useToast(); const timerRef = useRef | null>(null); const wasCatchingUpRef = useRef(false); const isCatchingUpRef = useRef(false); - const toastRef = useRef(toast); + const showToastRef = useRef(showToast); const indicatorColorRef = useRef(indicatorColor); useEffect(() => { - toastRef.current = toast; - }, [toast]); + showToastRef.current = showToast; + }, [showToast]); useEffect(() => { indicatorColorRef.current = indicatorColor; @@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({ if (!isCatchingUpRef.current) { return; } - toastRef.current.show("Refreshing", { + showToastRef.current("Refreshing", { icon: ( { @@ -833,14 +833,15 @@ function AgentScreenContent({ useDelayedHistoryRefreshToast({ isCatchingUp: isHistoryRefreshCatchingUp, indicatorColor: theme.colors.primary, + showToast: panelToast.api.show, }); useEffect(() => { if (!shouldEmitSyncErrorToast) { return; } - toast.error("Failed to refresh agent. Retrying in background."); - }, [shouldEmitSyncErrorToast, toast]); + panelToast.api.error("Failed to refresh agent. Retrying in background."); + }, [panelToast.api, shouldEmitSyncErrorToast]); if (viewState.tag === "not_found") { return ( @@ -935,6 +936,12 @@ function AgentScreenContent({ ) : null} + +