aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/mobile/components/bookmarks/BookmarkCard.tsx51
-rw-r--r--packages/mobile/components/bookmarks/BookmarkList.tsx23
-rw-r--r--packages/mobile/components/ui/ActionButton.tsx21
-rw-r--r--packages/mobile/components/ui/Toast.tsx183
-rw-r--r--packages/mobile/lib/providers.tsx6
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>
);
}