mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor(native): replace custom toast implementation with burnt
Swap the hand-rolled `ToastView`/`ToastProvider`/queue system for the `burnt` library, which delegates to native iOS/Android toast APIs. - Add `burnt@0.13.0` dependency - Delete `toast.tsx` and `toast-provider.tsx`; remove `<ToastProvider />` from `_layout.tsx` - Rewrite `lib/toast.ts` to wrap `burnt` — maps success/error/info/warning to burnt presets and haptic types; duration converted from ms to seconds
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.90.24",
|
||||
"better-auth": "catalog:",
|
||||
"burnt": "0.13.0",
|
||||
"date-fns": "catalog:",
|
||||
"expo": "55.0.6",
|
||||
"expo-application": "55.0.9",
|
||||
|
||||
@@ -15,7 +15,6 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||
|
||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||
import { ToastProvider } from "@/components/ui/toast-provider";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryPersister } from "@/lib/mmkv";
|
||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||
@@ -91,7 +90,6 @@ function AppContent() {
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<OfflineBanner />
|
||||
<ToastProvider />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
import { ToastView } from "@/components/ui/toast";
|
||||
import { dismiss, getSnapshot, subscribe } from "@/lib/toast";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const hapticMap = {
|
||||
success: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
|
||||
error: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
|
||||
warning: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
|
||||
info: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
|
||||
} as const;
|
||||
|
||||
export function ToastProvider() {
|
||||
const queue = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const activeToast = queue[0];
|
||||
|
||||
const activeId = activeToast?.id;
|
||||
const activeType = activeToast?.type;
|
||||
const activeDuration = activeToast?.duration;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId || !activeType || activeDuration == null) return;
|
||||
|
||||
hapticMap[activeType]();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(activeId);
|
||||
}, activeDuration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeId, activeType, activeDuration]);
|
||||
|
||||
if (!activeToast) return null;
|
||||
|
||||
return (
|
||||
<ToastView key={activeToast.id} toast={activeToast} onDismiss={dismiss} />
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
IconAlertOctagon,
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { ToastItem } from "@/lib/toast";
|
||||
|
||||
const DISMISS_THRESHOLD = 50;
|
||||
|
||||
const iconMap = {
|
||||
success: IconCircleCheck,
|
||||
error: IconAlertOctagon,
|
||||
warning: IconAlertTriangle,
|
||||
info: IconInfoCircle,
|
||||
} as const;
|
||||
|
||||
const colorVarMap = {
|
||||
success: "--color-status-completed",
|
||||
error: "--color-destructive",
|
||||
warning: "--color-primary",
|
||||
info: "--color-muted-foreground",
|
||||
} as const;
|
||||
|
||||
interface ToastViewProps {
|
||||
toast: ToastItem;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastView({ toast, onDismiss }: ToastViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const Icon = iconMap[toast.type];
|
||||
const iconColor = useCSSVariable(colorVarMap[toast.type]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(toast.id);
|
||||
};
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.onUpdate((e) => {
|
||||
translateY.value = e.translationY;
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (Math.abs(e.translationY) > DISMISS_THRESHOLD) {
|
||||
const target = e.translationY > 0 ? 200 : -200;
|
||||
translateY.value = withTiming(target, { duration: 200 }, () => {
|
||||
scheduleOnRN(handleDismiss);
|
||||
});
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 15, stiffness: 300 });
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: interpolate(Math.abs(translateY.value), [0, 100], [1, 0]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(350)}
|
||||
exiting={SlideOutDown.duration(200)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<View
|
||||
className="mx-4 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Icon size={18} color={iconColor as string} />
|
||||
|
||||
<View className="flex-1 gap-0.5">
|
||||
<Text className="font-sans-medium text-[14px] text-foreground">
|
||||
{toast.message}
|
||||
</Text>
|
||||
{toast.description ? (
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
{toast.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{toast.action ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toast.action?.onPress();
|
||||
handleDismiss();
|
||||
}}
|
||||
className="rounded-lg bg-secondary px-3 py-1.5"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
{toast.action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,33 @@
|
||||
type ToastType = "success" | "error" | "info" | "warning";
|
||||
import { toast as burntToast } from "burnt";
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface ToastOptions {
|
||||
type ToastOptions = {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
};
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
let queue: ToastItem[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function getSnapshot(): ToastItem[] {
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function addToast(type: ToastType, message: string, options?: ToastOptions) {
|
||||
const item: ToastItem = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
type,
|
||||
message,
|
||||
description: options?.description,
|
||||
duration: options?.duration ?? 4000,
|
||||
action: options?.action,
|
||||
};
|
||||
queue = [...queue, item];
|
||||
emit();
|
||||
}
|
||||
|
||||
export function dismiss(id: string) {
|
||||
queue = queue.filter((t) => t.id !== id);
|
||||
emit();
|
||||
function show(
|
||||
preset: "done" | "error" | "none",
|
||||
haptic: "success" | "warning" | "error" | "none",
|
||||
message: string,
|
||||
options?: ToastOptions,
|
||||
) {
|
||||
burntToast({
|
||||
title: message,
|
||||
message: options?.description,
|
||||
preset,
|
||||
haptic,
|
||||
duration: options?.duration ? options.duration / 1000 : 4,
|
||||
from: "bottom",
|
||||
});
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, options?: ToastOptions) =>
|
||||
addToast("success", message, options),
|
||||
show("done", "success", message, options),
|
||||
error: (message: string, options?: ToastOptions) =>
|
||||
addToast("error", message, options),
|
||||
show("error", "error", message, options),
|
||||
info: (message: string, options?: ToastOptions) =>
|
||||
addToast("info", message, options),
|
||||
show("none", "none", message, options),
|
||||
warning: (message: string, options?: ToastOptions) =>
|
||||
addToast("warning", message, options),
|
||||
show("none", "warning", message, options),
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.90.24",
|
||||
"better-auth": "catalog:",
|
||||
"burnt": "0.13.0",
|
||||
"date-fns": "catalog:",
|
||||
"expo": "55.0.6",
|
||||
"expo-application": "55.0.9",
|
||||
@@ -1332,6 +1333,8 @@
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"burnt": ["burnt@0.13.0", "", { "dependencies": { "sf-symbols-typescript": "^1.0.0", "sonner": "^2.0.1" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-LjlQa7CLkGWUdz08YUIaGCJ8BLXib31/ztKqowgwqd7UH283A/kmdCj+1PYAQwDQEMPNmvSUfFHrjXbcwZibFQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@@ -2856,6 +2859,8 @@
|
||||
|
||||
"bl/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"burnt/sf-symbols-typescript": ["sf-symbols-typescript@1.0.0", "", {}, "sha512-DkS7q3nN68dEMb4E18HFPDAvyrjDZK9YAQQF2QxeFu9gp2xRDXFMF8qLJ1EmQ/qeEGQmop4lmMM1WtYJTIcCMw=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
Reference in New Issue
Block a user