diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-13 14:33:54 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-03-13 14:43:52 +0000 |
| commit | a2ca7db052c0ce20b404f6f81b691850b3318b97 (patch) | |
| tree | d060d3a541f0305c1df7e88ac321e6166d8000d9 | |
| parent | 408984d9325f12937ac5747505370dec26e46d3f (diff) | |
| download | karakeep-a2ca7db052c0ce20b404f6f81b691850b3318b97.tar.zst | |
mobile: Optimistic UI updates on actions
| -rw-r--r-- | packages/mobile/components/bookmarks/BookmarkCard.tsx | 51 | ||||
| -rw-r--r-- | packages/mobile/components/bookmarks/BookmarkList.tsx | 23 | ||||
| -rw-r--r-- | packages/mobile/components/ui/ActionButton.tsx | 21 | ||||
| -rw-r--r-- | packages/mobile/components/ui/Toast.tsx | 183 | ||||
| -rw-r--r-- | packages/mobile/lib/providers.tsx | 6 |
5 files changed, 258 insertions, 26 deletions
diff --git a/packages/mobile/components/bookmarks/BookmarkCard.tsx b/packages/mobile/components/bookmarks/BookmarkCard.tsx index ae0d8a33..b3ddf302 100644 --- a/packages/mobile/components/bookmarks/BookmarkCard.tsx +++ b/packages/mobile/components/bookmarks/BookmarkCard.tsx @@ -1,10 +1,12 @@ import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import * as WebBrowser from "expo-web-browser"; -import { Star, Archive, Trash } from "lucide-react-native"; +import { Star, Archive, Trash, ArchiveRestore } from "lucide-react-native"; import { View, Text, Image, ScrollView, Pressable } from "react-native"; import Markdown from "react-native-markdown-display"; +import { ActionButton } from "../ui/ActionButton"; import { Skeleton } from "../ui/Skeleton"; +import { useToast } from "../ui/Toast"; import { api } from "@/lib/trpc"; @@ -30,20 +32,39 @@ export function isBookmarkStillLoading(bookmark: ZBookmark) { } function ActionBar({ bookmark }: { bookmark: ZBookmark }) { + const { toast } = useToast(); const apiUtils = api.useUtils(); - const { mutate: deleteBookmark } = api.bookmarks.deleteBookmark.useMutation({ - onSuccess: () => { - apiUtils.bookmarks.getBookmarks.invalidate(); - }, - }); - const { mutate: updateBookmark, variables } = - api.bookmarks.updateBookmark.useMutation({ + const { mutate: deleteBookmark, isPending: isDeletionPending } = + api.bookmarks.deleteBookmark.useMutation({ onSuccess: () => { apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id }); + }, + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + showProgress: false, + }); }, }); + const { + mutate: updateBookmark, + variables, + isPending: isUpdatePending, + } = api.bookmarks.updateBookmark.useMutation({ + onSuccess: () => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id }); + }, + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + showProgress: false, + }); + }, + }); return ( <View className="flex flex-row gap-4"> @@ -61,7 +82,8 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { <Star /> )} </Pressable> - <Pressable + <ActionButton + loading={isUpdatePending} onPress={() => updateBookmark({ bookmarkId: bookmark.id, @@ -69,9 +91,10 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { }) } > - <Archive /> - </Pressable> - <Pressable + {bookmark.archived ? <ArchiveRestore /> : <Archive />} + </ActionButton> + <ActionButton + loading={isDeletionPending} onPress={() => deleteBookmark({ bookmarkId: bookmark.id, @@ -79,7 +102,7 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { } > <Trash /> - </Pressable> + </ActionButton> </View> ); } diff --git a/packages/mobile/components/bookmarks/BookmarkList.tsx b/packages/mobile/components/bookmarks/BookmarkList.tsx index 519ec47c..af5c9de7 100644 --- a/packages/mobile/components/bookmarks/BookmarkList.tsx +++ b/packages/mobile/components/bookmarks/BookmarkList.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { FlatList, Text, View } from "react-native"; +import { Text, View } from "react-native"; +import Animated, { LinearTransition } from "react-native-reanimated"; import BookmarkCard from "./BookmarkCard"; @@ -37,25 +38,25 @@ export default function BookmarkList({ apiUtils.bookmarks.getBookmark.invalidate(); }; - if (!data.bookmarks.length) { - return ( - <View className="h-full items-center justify-center"> - <Text className="text-xl">No Bookmarks</Text> - </View> - ); - } - return ( - <FlatList + <Animated.FlatList + itemLayoutAnimation={LinearTransition} contentContainerStyle={{ gap: 15, marginVertical: 15, alignItems: "center", + height: "100%", }} - renderItem={(b) => <BookmarkCard key={b.item.id} bookmark={b.item} />} + renderItem={(b) => <BookmarkCard bookmark={b.item} />} + ListEmptyComponent={ + <View className="h-full items-center justify-center"> + <Text className="text-xl">No Bookmarks</Text> + </View> + } data={data.bookmarks} refreshing={refreshing} onRefresh={onRefresh} + keyExtractor={(b) => b.id} /> ); } diff --git a/packages/mobile/components/ui/ActionButton.tsx b/packages/mobile/components/ui/ActionButton.tsx new file mode 100644 index 00000000..c51eb332 --- /dev/null +++ b/packages/mobile/components/ui/ActionButton.tsx @@ -0,0 +1,21 @@ +import { ActivityIndicator, Pressable, PressableProps } from "react-native"; + +export function ActionButton({ + children, + loading, + disabled, + ...props +}: PressableProps & { + loading: boolean; +}) { + if (disabled !== undefined) { + disabled ||= loading; + } else if (loading) { + disabled = true; + } + return ( + <Pressable {...props} disabled={disabled}> + {loading ? <ActivityIndicator /> : children} + </Pressable> + ); +} diff --git a/packages/mobile/components/ui/Toast.tsx b/packages/mobile/components/ui/Toast.tsx new file mode 100644 index 00000000..fb319f84 --- /dev/null +++ b/packages/mobile/components/ui/Toast.tsx @@ -0,0 +1,183 @@ +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { Animated, Text, View } from "react-native"; + +import { cn } from "@/lib/utils"; + +const toastVariants = { + default: "bg-foreground", + destructive: "bg-destructive", + success: "bg-green-500", + info: "bg-blue-500", +}; + +interface ToastProps { + id: number; + message: string; + onHide: (id: number) => void; + variant?: keyof typeof toastVariants; + duration?: number; + showProgress?: boolean; +} +function Toast({ + id, + message, + onHide, + variant = "default", + duration = 3000, + showProgress = true, +}: ToastProps) { + const opacity = useRef(new Animated.Value(0)).current; + const progress = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(progress, { + toValue: 1, + duration: duration - 1000, + useNativeDriver: false, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + ]).start(() => onHide(id)); + }, [duration]); + + return ( + <Animated.View + className={` + ${toastVariants[variant]} + m-2 mb-1 transform rounded-lg p-4 shadow-md transition-all + `} + style={{ + opacity, + transform: [ + { + translateY: opacity.interpolate({ + inputRange: [0, 1], + outputRange: [-20, 0], + }), + }, + ], + }} + > + <Text className="text-background text-left font-semibold">{message}</Text> + {showProgress && ( + <View className="mt-2 rounded"> + <Animated.View + className="h-2 rounded bg-white opacity-30 dark:bg-black" + style={{ + width: progress.interpolate({ + inputRange: [0, 1], + outputRange: ["0%", "100%"], + }), + }} + /> + </View> + )} + </Animated.View> + ); +} + +type ToastVariant = keyof typeof toastVariants; + +interface ToastMessage { + id: number; + text: string; + variant: ToastVariant; + duration?: number; + position?: string; + showProgress?: boolean; +} +interface ToastContextProps { + toast: (t: { + message: string; + variant?: keyof typeof toastVariants; + duration?: number; + position?: "top" | "bottom"; + showProgress?: boolean; + }) => void; + removeToast: (id: number) => void; +} +const ToastContext = createContext<ToastContextProps | undefined>(undefined); + +// TODO: refactor to pass position to Toast instead of ToastProvider +function ToastProvider({ + children, + position = "top", +}: { + children: React.ReactNode; + position?: "top" | "bottom"; +}) { + const [messages, setMessages] = useState<ToastMessage[]>([]); + + const toast: ToastContextProps["toast"] = ({ + message, + variant = "default", + duration = 3000, + position = "top", + showProgress = true, + }: { + message: string; + variant?: ToastVariant; + duration?: number; + position?: "top" | "bottom"; + showProgress?: boolean; + }) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now(), + text: message, + variant, + duration, + position, + showProgress, + }, + ]); + }; + + const removeToast = (id: number) => { + setMessages((prev) => prev.filter((message) => message.id !== id)); + }; + + return ( + <ToastContext.Provider value={{ toast, removeToast }}> + {children} + <View + className={cn("absolute left-0 right-0", { + "top-[45px]": position === "top", + "bottom-0": position === "bottom", + })} + > + {messages.map((message) => ( + <Toast + key={message.id} + id={message.id} + message={message.text} + variant={message.variant} + duration={message.duration} + showProgress={message.showProgress} + onHide={removeToast} + /> + ))} + </View> + </ToastContext.Provider> + ); +} + +function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within ToastProvider"); + } + return context; +} + +export { ToastProvider, ToastVariant, Toast, toastVariants, useToast }; diff --git a/packages/mobile/lib/providers.tsx b/packages/mobile/lib/providers.tsx index 394bad28..1717afb2 100644 --- a/packages/mobile/lib/providers.tsx +++ b/packages/mobile/lib/providers.tsx @@ -6,6 +6,8 @@ import superjson from "superjson"; import useAppSettings, { getAppSettings } from "./settings"; import { api } from "./trpc"; +import { ToastProvider } from "@/components/ui/Toast"; + function getTRPCClient(address: string) { return api.createClient({ links: [ @@ -44,7 +46,9 @@ export function Providers({ children }: { children: React.ReactNode }) { client={trpcClient} queryClient={queryClient} > - <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + <QueryClientProvider client={queryClient}> + <ToastProvider>{children}</ToastProvider> + </QueryClientProvider> </api.Provider> ); } |
