diff options
Diffstat (limited to 'apps/mobile')
68 files changed, 3060 insertions, 1924 deletions
diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index c6b92bff..43167fef 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -3,7 +3,7 @@ export default { name: "Karakeep", slug: "hoarder", scheme: "karakeep", - version: "1.8.3", + version: "1.8.5", orientation: "portrait", icon: { light: "./assets/icon.png", @@ -35,7 +35,7 @@ export default { NSAllowsArbitraryLoads: true, }, }, - buildNumber: "30", + buildNumber: "32", }, android: { adaptiveIcon: { @@ -54,7 +54,7 @@ export default { }, }, package: "app.hoarder.hoardermobile", - versionCode: 30, + versionCode: 32, }, plugins: [ "./plugins/trust-local-certs.js", diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 1e6128c7..ab0f9c52 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -2,13 +2,16 @@ import "@/globals.css"; import "expo-dev-client"; import { useEffect } from "react"; +import { Platform } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { useRouter } from "expo-router"; import { Stack } from "expo-router/stack"; import { ShareIntentProvider, useShareIntent } from "expo-share-intent"; import { StatusBar } from "expo-status-bar"; import { StyledStack } from "@/components/navigation/stack"; +import SplashScreenController from "@/components/SplashScreenController"; import { Providers } from "@/lib/providers"; import { useColorScheme, useInitialAndroidBarSync } from "@/lib/useColorScheme"; import { cn } from "@/lib/utils"; @@ -30,9 +33,13 @@ export default function RootLayout() { }, [hasShareIntent]); return ( - <> - <KeyboardProvider statusBarTranslucent navigationBarTranslucent> + <SafeAreaProvider> + <KeyboardProvider + statusBarTranslucent={Platform.OS !== "android" ? true : undefined} + navigationBarTranslucent={Platform.OS !== "android" ? true : undefined} + > <NavThemeProvider value={NAV_THEME[colorScheme]}> + <SplashScreenController /> <StyledStack layout={(props) => { return ( @@ -64,6 +71,14 @@ export default function RootLayout() { /> <Stack.Screen name="sharing" /> <Stack.Screen + name="server-address" + options={{ + title: "Server Address", + headerShown: true, + presentation: "modal", + }} + /> + <Stack.Screen name="test-connection" options={{ title: "Test Connection", @@ -78,6 +93,6 @@ export default function RootLayout() { key={`root-status-bar-${isDarkColorScheme ? "light" : "dark"}`} style={isDarkColorScheme ? "light" : "dark"} /> - </> + </SafeAreaProvider> ); } diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx new file mode 100644 index 00000000..961df836 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Highlights" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx new file mode 100644 index 00000000..48a190c1 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx @@ -0,0 +1,50 @@ +import FullPageError from "@/components/FullPageError"; +import HighlightList from "@/components/highlights/HighlightList"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +export default function Highlights() { + const api = useTRPC(); + const queryClient = useQueryClient(); + const { + data, + isPending, + isPlaceholderData, + error, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), + ); + + if (error) { + return <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (isPending || !data) { + return <FullPageSpinner />; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.highlights.getAll.pathFilter()); + }; + + return ( + <HighlightList + highlights={data.pages.flatMap((p) => p.highlights)} + onRefresh={onRefresh} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + isRefreshing={isPending || isPlaceholderData} + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx new file mode 100644 index 00000000..1ba65211 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Home" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx index 0a51b817..65034419 100644 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx @@ -1,11 +1,9 @@ import { Platform, Pressable, View } from "react-native"; import * as Haptics from "expo-haptics"; import * as ImagePicker from "expo-image-picker"; -import { router } from "expo-router"; +import { router, Stack } from "expo-router"; import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; import { TailwindResolver } from "@/components/TailwindResolver"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import PageTitle from "@/components/ui/PageTitle"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; import useAppSettings from "@/lib/settings"; @@ -76,34 +74,35 @@ function HeaderRight({ export default function Home() { return ( - <CustomSafeAreaView> + <> + <Stack.Screen + options={{ + headerRight: () => ( + <HeaderRight + openNewBookmarkModal={() => + router.push("/dashboard/bookmarks/new") + } + /> + ), + }} + /> <UpdatingBookmarkList query={{ archived: false }} header={ - <View className="flex flex-col gap-1"> - <View className="flex flex-row justify-between"> - <PageTitle title="Home" className="pb-2" /> - <HeaderRight - openNewBookmarkModal={() => - router.push("/dashboard/bookmarks/new") - } - /> - </View> - <Pressable - className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1" - onPress={() => router.push("/dashboard/search")} - > - <TailwindResolver - className="text-muted" - comp={(styles) => ( - <Search size={16} color={styles?.color?.toString()} /> - )} - /> - <Text className="text-muted">Search</Text> - </Pressable> - </View> + <Pressable + className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1" + onPress={() => router.push("/dashboard/search")} + > + <TailwindResolver + className="text-muted" + comp={(styles) => ( + <Search size={16} color={styles?.color?.toString()} /> + )} + /> + <Text className="text-muted">Search</Text> + </Pressable> } /> - </CustomSafeAreaView> + </> ); } diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx new file mode 100644 index 00000000..398ba650 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Lists" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx new file mode 100644 index 00000000..4c98ef2c --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx @@ -0,0 +1,284 @@ +import { useEffect, useMemo, useState } from "react"; +import { FlatList, Pressable, View } from "react-native"; +import * as Haptics from "expo-haptics"; +import { Link, router, Stack } from "expo-router"; +import FullPageError from "@/components/FullPageError"; +import ChevronRight from "@/components/ui/ChevronRight"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Text } from "@/components/ui/Text"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { condProps } from "@/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react-native"; + +import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; + +function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) { + return ( + <Pressable + className="my-auto px-4" + onPress={() => { + Haptics.selectionAsync(); + openNewListModal(); + }} + > + <Plus color="rgb(0, 122, 255)" /> + </Pressable> + ); +} + +interface ListLink { + id: string; + logo: string; + name: string; + href: string; + level: number; + parent?: string; + numChildren: number; + collapsed: boolean; + isSharedSection?: boolean; + numBookmarks?: number; +} + +function traverseTree( + node: ZBookmarkListTreeNode, + links: ListLink[], + showChildrenOf: Record<string, boolean>, + listStats?: Map<string, number>, + parent?: string, + level = 0, +) { + links.push({ + id: node.item.id, + logo: node.item.icon, + name: node.item.name, + href: `/dashboard/lists/${node.item.id}`, + level, + parent, + numChildren: node.children?.length ?? 0, + collapsed: !showChildrenOf[node.item.id], + numBookmarks: listStats?.get(node.item.id), + }); + + if (node.children && showChildrenOf[node.item.id]) { + node.children.forEach((child) => + traverseTree( + child, + links, + showChildrenOf, + listStats, + node.item.id, + level + 1, + ), + ); + } +} + +export default function Lists() { + const { colors } = useColorScheme(); + const [refreshing, setRefreshing] = useState(false); + const { data: lists, isPending, error, refetch } = useBookmarkLists(); + const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>( + {}, + ); + const api = useTRPC(); + const queryClient = useQueryClient(); + const { data: listStats } = useQuery(api.lists.stats.queryOptions()); + + // Check if there are any shared lists + const hasSharedLists = useMemo(() => { + return lists?.data.some((list) => list.userRole !== "owner") ?? false; + }, [lists?.data]); + + // Check if any list has children to determine if we need chevron spacing + const hasAnyListsWithChildren = useMemo(() => { + const checkForChildren = (node: ZBookmarkListTreeNode): boolean => { + if (node.children && node.children.length > 0) return true; + return false; + }; + return ( + Object.values(lists?.root ?? {}).some(checkForChildren) || hasSharedLists + ); + }, [lists?.root, hasSharedLists]); + + useEffect(() => { + setRefreshing(isPending); + }, [isPending]); + + if (error) { + return <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (!lists) { + return <FullPageSpinner />; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.lists.list.pathFilter()); + queryClient.invalidateQueries(api.lists.stats.pathFilter()); + }; + + const links: ListLink[] = [ + { + id: "fav", + logo: "⭐️", + name: "Favourites", + href: "/dashboard/favourites", + level: 0, + numChildren: 0, + collapsed: false, + }, + { + id: "arch", + logo: "🗄️", + name: "Archive", + href: "/dashboard/archive", + level: 0, + numChildren: 0, + collapsed: false, + }, + ]; + + // Add shared lists section if there are any + if (hasSharedLists) { + // Count shared lists to determine if section has children + const sharedListsCount = Object.values(lists.root).filter( + (list) => list.item.userRole !== "owner", + ).length; + + links.push({ + id: "shared-section", + logo: "👥", + name: "Shared Lists", + href: "#", + level: 0, + numChildren: sharedListsCount, + collapsed: !showChildrenOf["shared-section"], + isSharedSection: true, + }); + + // Add shared lists as children if section is expanded + if (showChildrenOf["shared-section"]) { + Object.values(lists.root).forEach((list) => { + if (list.item.userRole !== "owner") { + traverseTree( + list, + links, + showChildrenOf, + listStats?.stats, + "shared-section", + 1, + ); + } + }); + } + } + + // Add owned lists only + Object.values(lists.root).forEach((list) => { + if (list.item.userRole === "owner") { + traverseTree(list, links, showChildrenOf, listStats?.stats); + } + }); + + return ( + <> + <Stack.Screen + options={{ + headerRight: () => ( + <HeaderRight + openNewListModal={() => router.push("/dashboard/lists/new")} + /> + ), + }} + /> + <FlatList + className="h-full" + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + gap: 6, + paddingBottom: 20, + }} + renderItem={(l) => ( + <View + className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2" + style={{ + borderCurve: "continuous", + ...condProps({ + condition: l.item.level > 0, + props: { marginLeft: l.item.level * 20 }, + }), + }} + > + {hasAnyListsWithChildren && ( + <View style={{ width: 32 }}> + {l.item.numChildren > 0 && ( + <Pressable + className="pr-2" + onPress={() => { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + <ChevronRight + color={colors.foreground} + style={{ + transform: [ + { rotate: l.item.collapsed ? "0deg" : "90deg" }, + ], + }} + /> + </Pressable> + )} + </View> + )} + + {l.item.isSharedSection ? ( + <Pressable + className="flex flex-1 flex-row items-center justify-between" + onPress={() => { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + <Text> + {l.item.logo} {l.item.name} + </Text> + </Pressable> + ) : ( + <Link + asChild + key={l.item.id} + href={l.item.href} + className="flex-1" + > + <Pressable className="flex flex-row items-center justify-between"> + <Text className="shrink"> + {l.item.logo} {l.item.name} + </Text> + <View className="flex flex-row items-center"> + {l.item.numBookmarks !== undefined && ( + <Text className="mr-2 text-xs text-muted-foreground"> + {l.item.numBookmarks} + </Text> + )} + <ChevronRight /> + </View> + </Pressable> + </Link> + )} + </View> + )} + data={links} + refreshing={refreshing} + onRefresh={onRefresh} + /> + </> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx new file mode 100644 index 00000000..8c51d5a3 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Settings" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx new file mode 100644 index 00000000..de17ff5a --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx @@ -0,0 +1,225 @@ +import { useEffect } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + Switch, + View, +} from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { useSharedValue } from "react-native-reanimated"; +import Constants from "expo-constants"; +import { Link } from "expo-router"; +import { UserProfileHeader } from "@/components/settings/UserProfileHeader"; +import ChevronRight from "@/components/ui/ChevronRight"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { useServerVersion } from "@/lib/hooks"; +import { useSession } from "@/lib/session"; +import useAppSettings from "@/lib/settings"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +function SectionHeader({ title }: { title: string }) { + return ( + <Text className="px-4 pb-1 pt-4 text-xs uppercase tracking-wide text-muted-foreground"> + {title} + </Text> + ); +} + +export default function Settings() { + const { logout } = useSession(); + const { + settings, + setSettings, + isLoading: isSettingsLoading, + } = useAppSettings(); + const api = useTRPC(); + + const imageQuality = useSharedValue(0); + const imageQualityMin = useSharedValue(0); + const imageQualityMax = useSharedValue(100); + + useEffect(() => { + imageQuality.value = settings.imageQuality * 100; + }, [settings]); + + const { data, error } = useQuery(api.users.whoami.queryOptions()); + const { + data: serverVersion, + isLoading: isServerVersionLoading, + error: serverVersionError, + } = useServerVersion(); + + if (error?.data?.code === "UNAUTHORIZED") { + logout(); + } + + return ( + <ScrollView + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }} + > + <UserProfileHeader + image={data?.image} + name={data?.name} + email={data?.email} + /> + + <SectionHeader title="Appearance" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link asChild href="/dashboard/settings/theme" className="flex-1"> + <Pressable className="flex flex-row justify-between"> + <Text>Theme</Text> + <View className="flex flex-row items-center gap-2"> + <Text className="text-muted-foreground"> + { + { light: "Light", dark: "Dark", system: "System" }[ + settings.theme + ] + } + </Text> + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/bookmark-default-view" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Default Bookmark View</Text> + <View className="flex flex-row items-center gap-2"> + {isSettingsLoading ? ( + <ActivityIndicator size="small" /> + ) : ( + <Text className="text-muted-foreground"> + {settings.defaultBookmarkView === "reader" + ? "Reader" + : "Browser"} + </Text> + )} + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + </View> + + <SectionHeader title="Reading" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/reader-settings" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Reader Text Settings</Text> + <ChevronRight /> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Text className="flex-1" numberOfLines={1}> + Show notes in bookmark card + </Text> + <Switch + className="shrink-0" + value={settings.showNotes} + onValueChange={(value) => + setSettings({ + ...settings, + showNotes: value, + }) + } + /> + </View> + </View> + + <SectionHeader title="Media" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex w-full flex-row items-center justify-between gap-8 px-4 py-1"> + <Text>Upload Image Quality</Text> + <View className="flex flex-1 flex-row items-center justify-center gap-2"> + <Text className="text-foreground"> + {Math.round(settings.imageQuality * 100)}% + </Text> + <Slider + onSlidingComplete={(value) => + setSettings({ + ...settings, + imageQuality: Math.round(value) / 100, + }) + } + progress={imageQuality} + minimumValue={imageQualityMin} + maximumValue={imageQualityMax} + /> + </View> + </View> + </View> + + <SectionHeader title="Account" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <Pressable + className="flex flex-row items-center px-4 py-1" + onPress={logout} + > + <Text className="text-destructive">Log Out</Text> + </Pressable> + </View> + + <SectionHeader title="About" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">Server</Text> + <Text className="text-sm text-muted-foreground"> + {isSettingsLoading ? "Loading..." : settings.address} + </Text> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">App Version</Text> + <Text className="text-sm text-muted-foreground"> + {Constants.expoConfig?.version ?? "unknown"} + </Text> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">Server Version</Text> + <Text className="text-sm text-muted-foreground"> + {isServerVersionLoading + ? "Loading..." + : serverVersionError + ? "unavailable" + : (serverVersion ?? "unknown")} + </Text> + </View> + </View> + </ScrollView> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx new file mode 100644 index 00000000..3b56548f --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Tags" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx new file mode 100644 index 00000000..4903d681 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { FlatList, Pressable, View } from "react-native"; +import { Link } from "expo-router"; +import FullPageError from "@/components/FullPageError"; +import ChevronRight from "@/components/ui/ChevronRight"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { SearchInput } from "@/components/ui/SearchInput"; +import { Text } from "@/components/ui/Text"; +import { useQueryClient } from "@tanstack/react-query"; + +import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + +interface TagItem { + id: string; + name: string; + numBookmarks: number; + href: string; +} + +export default function Tags() { + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const api = useTRPC(); + const queryClient = useQueryClient(); + + // Debounce search query to avoid too many API calls + const debouncedSearch = useDebounce(searchQuery, 300); + + // Fetch tags sorted by usage (most used first) + const { + data, + isPending, + error, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePaginatedSearchTags({ + limit: 50, + sortBy: debouncedSearch ? "relevance" : "usage", + nameContains: debouncedSearch, + }); + + useEffect(() => { + setRefreshing(isPending); + }, [isPending]); + + if (error) { + return <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (!data) { + return <FullPageSpinner />; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.tags.list.pathFilter()); + }; + + const tags: TagItem[] = data.tags.map((tag) => ({ + id: tag.id, + name: tag.name, + numBookmarks: tag.numBookmarks, + href: `/dashboard/tags/${tag.id}`, + })); + + const handleLoadMore = () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + return ( + <FlatList + className="h-full" + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={ + <SearchInput + containerClassName="mx-2 mb-2" + placeholder="Search tags..." + value={searchQuery} + onChangeText={setSearchQuery} + /> + } + contentContainerStyle={{ + gap: 6, + paddingBottom: 20, + }} + renderItem={(item) => ( + <View + className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2" + style={{ borderCurve: "continuous" }} + > + <Link + asChild + key={item.item.id} + href={item.item.href} + className="flex-1" + > + <Pressable className="flex flex-row items-center justify-between"> + <View className="flex-1"> + <Text className="font-medium">{item.item.name}</Text> + <Text className="text-sm text-muted-foreground"> + {item.item.numBookmarks}{" "} + {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} + </Text> + </View> + <ChevronRight /> + </Pressable> + </Link> + </View> + )} + data={tags} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isFetchingNextPage ? ( + <View className="py-4"> + <Text className="text-center text-muted-foreground"> + Loading more... + </Text> + </View> + ) : null + } + ListEmptyComponent={ + !isPending ? ( + <View className="py-8"> + <Text className="text-center text-muted-foreground"> + No tags yet + </Text> + </View> + ) : null + } + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx index 5cc6aa92..fd5798b9 100644 --- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx +++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx @@ -1,69 +1,62 @@ -import React, { useLayoutEffect } from "react"; -import { Tabs, useNavigation } from "expo-router"; -import { StyledTabs } from "@/components/navigation/tabs"; -import { useColorScheme } from "@/lib/useColorScheme"; +import React from "react"; import { - ClipboardList, - Highlighter, - Home, - Settings, - Tag, -} from "lucide-react-native"; + Icon, + Label, + NativeTabs, + VectorIcon, +} from "expo-router/unstable-native-tabs"; +import { useColorScheme } from "@/lib/useColorScheme"; +import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; export default function TabLayout() { const { colors } = useColorScheme(); - const navigation = useNavigation(); - // Hide the header on the parent screen - useLayoutEffect(() => { - navigation.setOptions({ - headerShown: false, - }); - }, [navigation]); - return ( - <StyledTabs - tabBarClassName="bg-gray-100 dark:bg-background" - sceneClassName="bg-gray-100 dark:bg-background" - screenOptions={{ - headerShown: false, - tabBarActiveTintColor: colors.foreground, - }} - > - <Tabs.Screen - name="index" - options={{ - title: "Home", - tabBarIcon: ({ color }) => <Home color={color} />, - }} - /> - <Tabs.Screen - name="lists" - options={{ - title: "Lists", - tabBarIcon: ({ color }) => <ClipboardList color={color} />, - }} - /> - <Tabs.Screen - name="tags" - options={{ - title: "Tags", - tabBarIcon: ({ color }) => <Tag color={color} />, - }} - /> - <Tabs.Screen - name="highlights" - options={{ - title: "Highlights", - tabBarIcon: ({ color }) => <Highlighter color={color} />, - }} - /> - <Tabs.Screen - name="settings" - options={{ - title: "Settings", - tabBarIcon: ({ color }) => <Settings color={color} />, - }} - /> - </StyledTabs> + <NativeTabs backgroundColor={colors.grey6} minimizeBehavior="onScrollDown"> + <NativeTabs.Trigger name="(home)"> + <Icon + sf="house.fill" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="home" /> + } + /> + <Label>Home</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(lists)"> + <Icon + sf="list.clipboard.fill" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="clipboard-list" /> + } + /> + <Label>Lists</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(tags)"> + <Icon + sf="tag.fill" + androidSrc={<VectorIcon family={MaterialCommunityIcons} name="tag" />} + /> + <Label>Tags</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(highlights)"> + <Icon + sf="highlighter" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="marker" /> + } + /> + <Label>Highlights</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(settings)"> + <Icon + sf="gearshape.fill" + androidSrc={<VectorIcon family={MaterialCommunityIcons} name="cog" />} + /> + <Label>Settings</Label> + </NativeTabs.Trigger> + </NativeTabs> ); } diff --git a/apps/mobile/app/dashboard/(tabs)/highlights.tsx b/apps/mobile/app/dashboard/(tabs)/highlights.tsx deleted file mode 100644 index 7879081b..00000000 --- a/apps/mobile/app/dashboard/(tabs)/highlights.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { View } from "react-native"; -import FullPageError from "@/components/FullPageError"; -import HighlightList from "@/components/highlights/HighlightList"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; - -import { api } from "@karakeep/shared-react/trpc"; - -export default function Highlights() { - const apiUtils = api.useUtils(); - const { - data, - isPending, - isPlaceholderData, - error, - fetchNextPage, - isFetchingNextPage, - refetch, - } = api.highlights.getAll.useInfiniteQuery( - {}, - { - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); - - if (error) { - return <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (isPending || !data) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.highlights.getAll.invalidate(); - }; - - return ( - <CustomSafeAreaView> - <HighlightList - highlights={data.pages.flatMap((p) => p.highlights)} - header={ - <View className="flex flex-row justify-between"> - <PageTitle title="Highlights" /> - </View> - } - onRefresh={onRefresh} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - isRefreshing={isPending || isPlaceholderData} - /> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx deleted file mode 100644 index e40be1a5..00000000 --- a/apps/mobile/app/dashboard/(tabs)/lists.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect, useState } from "react"; -import { FlatList, Pressable, View } from "react-native"; -import * as Haptics from "expo-haptics"; -import { Link, router } from "expo-router"; -import FullPageError from "@/components/FullPageError"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; -import { Text } from "@/components/ui/Text"; -import { api } from "@/lib/trpc"; -import { useColorScheme } from "@/lib/useColorScheme"; -import { condProps } from "@/lib/utils"; -import { Plus } from "lucide-react-native"; - -import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; -import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; - -function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) { - return ( - <Pressable - className="my-auto px-4" - onPress={() => { - Haptics.selectionAsync(); - openNewListModal(); - }} - > - <Plus color="rgb(0, 122, 255)" /> - </Pressable> - ); -} - -interface ListLink { - id: string; - logo: string; - name: string; - href: string; - level: number; - parent?: string; - numChildren: number; - collapsed: boolean; -} - -function traverseTree( - node: ZBookmarkListTreeNode, - links: ListLink[], - showChildrenOf: Record<string, boolean>, - parent?: string, - level = 0, -) { - links.push({ - id: node.item.id, - logo: node.item.icon, - name: node.item.name, - href: `/dashboard/lists/${node.item.id}`, - level, - parent, - numChildren: node.children?.length ?? 0, - collapsed: !showChildrenOf[node.item.id], - }); - - if (node.children && showChildrenOf[node.item.id]) { - node.children.forEach((child) => - traverseTree(child, links, showChildrenOf, node.item.id, level + 1), - ); - } -} - -export default function Lists() { - const { colors } = useColorScheme(); - const [refreshing, setRefreshing] = useState(false); - const { data: lists, isPending, error, refetch } = useBookmarkLists(); - const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>( - {}, - ); - const apiUtils = api.useUtils(); - - useEffect(() => { - setRefreshing(isPending); - }, [isPending]); - - if (error) { - return <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (!lists) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.lists.list.invalidate(); - }; - - const links: ListLink[] = [ - { - id: "fav", - logo: "⭐️", - name: "Favourites", - href: "/dashboard/favourites", - level: 0, - numChildren: 0, - collapsed: false, - }, - { - id: "arch", - logo: "🗄️", - name: "Archive", - href: "/dashboard/archive", - level: 0, - numChildren: 0, - collapsed: false, - }, - ]; - - Object.values(lists.root).forEach((list) => - traverseTree(list, links, showChildrenOf), - ); - - return ( - <CustomSafeAreaView> - <FlatList - className="h-full" - ListHeaderComponent={ - <View className="flex flex-row justify-between"> - <PageTitle title="Lists" /> - <HeaderRight - openNewListModal={() => router.push("/dashboard/lists/new")} - /> - </View> - } - contentContainerStyle={{ - gap: 5, - }} - renderItem={(l) => ( - <View - className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2" - style={condProps({ - condition: l.item.level > 0, - props: { marginLeft: l.item.level * 20 }, - })} - > - {l.item.numChildren > 0 && ( - <Pressable - className="pr-2" - onPress={() => { - setShowChildrenOf((prev) => ({ - ...prev, - [l.item.id]: !prev[l.item.id], - })); - }} - > - <ChevronRight - color={colors.foreground} - style={{ - transform: [ - { rotate: l.item.collapsed ? "0deg" : "90deg" }, - ], - }} - /> - </Pressable> - )} - - <Link asChild key={l.item.id} href={l.item.href} className="flex-1"> - <Pressable className="flex flex-row items-center justify-between"> - <Text> - {l.item.logo} {l.item.name} - </Text> - <ChevronRight /> - </Pressable> - </Link> - </View> - )} - data={links} - refreshing={refreshing} - onRefresh={onRefresh} - /> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx deleted file mode 100644 index 76216e00..00000000 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect } from "react"; -import { ActivityIndicator, Pressable, Switch, View } from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { useSharedValue } from "react-native-reanimated"; -import { Link } from "expo-router"; -import { Button } from "@/components/ui/Button"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import { Divider } from "@/components/ui/Divider"; -import PageTitle from "@/components/ui/PageTitle"; -import { Text } from "@/components/ui/Text"; -import { useSession } from "@/lib/session"; -import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; - -export default function Dashboard() { - const { logout } = useSession(); - const { - settings, - setSettings, - isLoading: isSettingsLoading, - } = useAppSettings(); - - const imageQuality = useSharedValue(0); - const imageQualityMin = useSharedValue(0); - const imageQualityMax = useSharedValue(100); - - useEffect(() => { - imageQuality.value = settings.imageQuality * 100; - }, [settings]); - - const { data, error, isLoading } = api.users.whoami.useQuery(); - - if (error?.data?.code === "UNAUTHORIZED") { - logout(); - } - - return ( - <CustomSafeAreaView> - <PageTitle title="Settings" /> - <View className="flex h-full w-full items-center gap-3 px-4 py-2"> - <View className="flex w-full gap-3 rounded-lg bg-card px-4 py-2"> - <Text>{isSettingsLoading ? "Loading ..." : settings.address}</Text> - <Divider orientation="horizontal" /> - <Text>{isLoading ? "Loading ..." : data?.email}</Text> - </View> - <Text className="w-full p-1 text-2xl font-bold text-foreground"> - App Settings - </Text> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Link asChild href="/dashboard/settings/theme" className="flex-1"> - <Pressable className="flex flex-row justify-between"> - <Text>Theme</Text> - <View className="flex flex-row items-center gap-2"> - <Text className="text-muted-foreground"> - { - { light: "Light", dark: "Dark", system: "System" }[ - settings.theme - ] - } - </Text> - <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Link - asChild - href="/dashboard/settings/bookmark-default-view" - className="flex-1" - > - <Pressable className="flex flex-row justify-between"> - <Text>Default Bookmark View</Text> - <View className="flex flex-row items-center gap-2"> - {isSettingsLoading ? ( - <ActivityIndicator size="small" /> - ) : ( - <Text className="text-muted-foreground"> - {settings.defaultBookmarkView === "reader" - ? "Reader" - : "Browser"} - </Text> - )} - <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Text>Show note preview in bookmark</Text> - <Switch - value={settings.showNotes} - onValueChange={(value) => - setSettings({ - ...settings, - showNotes: value, - }) - } - /> - </View> - <Text className="w-full p-1 text-2xl font-bold text-foreground"> - Upload Settings - </Text> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Text>Image Quality</Text> - <View className="flex flex-1 flex-row items-center justify-center gap-2"> - <Text className="text-foreground"> - {Math.round(settings.imageQuality * 100)}% - </Text> - <Slider - onSlidingComplete={(value) => - setSettings({ - ...settings, - imageQuality: Math.round(value) / 100, - }) - } - progress={imageQuality} - minimumValue={imageQualityMin} - maximumValue={imageQualityMax} - /> - </View> - </View> - <Divider orientation="horizontal" /> - <Button - androidRootClassName="w-full" - onPress={logout} - variant="destructive" - > - <Text>Log Out</Text> - </Button> - </View> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/tags.tsx b/apps/mobile/app/dashboard/(tabs)/tags.tsx deleted file mode 100644 index 7f3e4ac7..00000000 --- a/apps/mobile/app/dashboard/(tabs)/tags.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useEffect, useState } from "react"; -import { FlatList, Pressable, View } from "react-native"; -import { Link } from "expo-router"; -import FullPageError from "@/components/FullPageError"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; -import { SearchInput } from "@/components/ui/SearchInput"; -import { Text } from "@/components/ui/Text"; -import { api } from "@/lib/trpc"; - -import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags"; -import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; - -interface TagItem { - id: string; - name: string; - numBookmarks: number; - href: string; -} - -export default function Tags() { - const [refreshing, setRefreshing] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const apiUtils = api.useUtils(); - - // Debounce search query to avoid too many API calls - const debouncedSearch = useDebounce(searchQuery, 300); - - // Fetch tags sorted by usage (most used first) - const { - data, - isPending, - error, - refetch, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = usePaginatedSearchTags({ - limit: 50, - sortBy: debouncedSearch ? "relevance" : "usage", - nameContains: debouncedSearch, - }); - - useEffect(() => { - setRefreshing(isPending); - }, [isPending]); - - if (error) { - return <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (!data) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.tags.list.invalidate(); - }; - - const tags: TagItem[] = data.tags.map((tag) => ({ - id: tag.id, - name: tag.name, - numBookmarks: tag.numBookmarks, - href: `/dashboard/tags/${tag.id}`, - })); - - const handleLoadMore = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - - return ( - <CustomSafeAreaView> - <FlatList - className="h-full" - ListHeaderComponent={ - <View> - <PageTitle title="Tags" /> - <SearchInput - containerClassName="mx-2 mb-2" - placeholder="Search tags..." - value={searchQuery} - onChangeText={setSearchQuery} - /> - </View> - } - contentContainerStyle={{ - gap: 5, - }} - renderItem={(item) => ( - <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"> - <Link - asChild - key={item.item.id} - href={item.item.href} - className="flex-1" - > - <Pressable className="flex flex-row justify-between"> - <View className="flex-1"> - <Text className="font-medium">{item.item.name}</Text> - <Text className="text-sm text-muted-foreground"> - {item.item.numBookmarks}{" "} - {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} - </Text> - </View> - <ChevronRight /> - </Pressable> - </Link> - </View> - )} - data={tags} - refreshing={refreshing} - onRefresh={onRefresh} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={ - isFetchingNextPage ? ( - <View className="py-4"> - <Text className="text-center text-muted-foreground"> - Loading more... - </Text> - </View> - ) : null - } - ListEmptyComponent={ - !isPending ? ( - <View className="py-8"> - <Text className="text-center text-muted-foreground"> - No tags yet - </Text> - </View> - ) : null - } - /> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx index eb1cbe4b..78fd7c60 100644 --- a/apps/mobile/app/dashboard/_layout.tsx +++ b/apps/mobile/app/dashboard/_layout.tsx @@ -70,8 +70,10 @@ export default function Dashboard() { options={{ headerTitle: "New Bookmark", headerBackTitle: "Back", - headerTransparent: true, - presentation: "modal", + headerTransparent: false, + presentation: "formSheet", + sheetGrabberVisible: true, + sheetAllowedDetents: [0.35, 0.7], }} /> <Stack.Screen @@ -110,6 +112,15 @@ export default function Dashboard() { }} /> <Stack.Screen + name="lists/[slug]/edit" + options={{ + headerTitle: "Edit List", + headerBackTitle: "Back", + headerTransparent: true, + presentation: "modal", + }} + /> + <Stack.Screen name="archive" options={{ headerTitle: "", @@ -144,6 +155,14 @@ export default function Dashboard() { headerBackTitle: "Back", }} /> + <Stack.Screen + name="settings/reader-settings" + options={{ + title: "Reader Settings", + headerTitle: "Reader Settings", + headerBackTitle: "Back", + }} + /> </StyledStack> ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..efb82b1e 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -12,17 +12,21 @@ import BottomActions from "@/components/bookmarks/BottomActions"; import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const { settings } = useAppSettings(); + const api = useTRPC(); const [bookmarkLinkType, setBookmarkLinkType] = useState<BookmarkLinkType>( settings.defaultBookmarkView, @@ -36,10 +40,12 @@ export default function BookmarkView() { data: bookmark, error, refetch, - } = api.bookmarks.getBookmark.useQuery({ - bookmarkId: slug, - includeContent: false, - }); + } = useQuery( + api.bookmarks.getBookmark.queryOptions({ + bookmarkId: slug, + includeContent: false, + }), + ); if (error) { return <FullPageError error={error.message} onRetry={refetch} />; @@ -87,11 +93,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - <BookmarkLinkTypeSelector - type={bookmarkLinkType} - onChange={(type) => setBookmarkLinkType(type)} - bookmark={bookmark} - /> + <View className="flex-row items-center gap-3"> + {bookmarkLinkType === "reader" && ( + <Pressable + onPress={() => + router.push("/dashboard/settings/reader-settings") + } + > + <Settings size={20} color="gray" /> + </Pressable> + )} + <BookmarkLinkTypeSelector + type={bookmarkLinkType} + onChange={(type) => setBookmarkLinkType(type)} + bookmark={bookmark} + /> + </View> ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx index c4b76aef..744b7f7d 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx @@ -477,14 +477,14 @@ const ViewBookmarkPage = () => { </Button> </View> )} - <View className="gap-2"> - <Text className="items-center text-center"> + <View className="gap-1"> + <Text className="text-center text-xs text-muted-foreground"> Created {bookmark.createdAt.toLocaleString()} </Text> {bookmark.modifiedAt && bookmark.modifiedAt.getTime() !== bookmark.createdAt.getTime() && ( - <Text className="items-center text-center"> + <Text className="text-center text-xs text-muted-foreground"> Modified {bookmark.modifiedAt.toLocaleString()} </Text> )} diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx index c502c07f..1070207b 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx @@ -1,19 +1,22 @@ import React from "react"; -import { FlatList, Pressable, View } from "react-native"; +import { ActivityIndicator, FlatList, Pressable, View } from "react-native"; import Checkbox from "expo-checkbox"; import { useLocalSearchParams } from "expo-router"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; +import { useQuery } from "@tanstack/react-query"; +import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { useAddBookmarkToList, useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const ListPickerPage = () => { + const api = useTRPC(); const { slug: bookmarkId } = useLocalSearchParams(); if (typeof bookmarkId !== "string") { throw new Error("Unexpected param type"); @@ -26,17 +29,24 @@ const ListPickerPage = () => { showProgress: false, }); }; - const { data: existingLists } = api.lists.getListsOfBookmark.useQuery( - { - bookmarkId, - }, - { - select: (data) => new Set(data.lists.map((l) => l.id)), - }, + const { data: existingLists } = useQuery( + api.lists.getListsOfBookmark.queryOptions( + { + bookmarkId, + }, + { + select: (data: { lists: ZBookmarkList[] }) => + new Set(data.lists.map((l) => l.id)), + }, + ), ); const { data } = useBookmarkLists(); - const { mutate: addToList } = useAddBookmarkToList({ + const { + mutate: addToList, + isPending: isAddingToList, + variables: addVariables, + } = useAddBookmarkToList({ onSuccess: () => { toast({ message: `The bookmark has been added to the list!`, @@ -46,7 +56,11 @@ const ListPickerPage = () => { onError, }); - const { mutate: removeToList } = useRemoveBookmarkFromList({ + const { + mutate: removeToList, + isPending: isRemovingFromList, + variables: removeVariables, + } = useRemoveBookmarkFromList({ onSuccess: () => { toast({ message: `The bookmark has been removed from the list!`, @@ -67,6 +81,13 @@ const ListPickerPage = () => { } }; + const isListLoading = (listId: string) => { + return ( + (isAddingToList && addVariables?.listId === listId) || + (isRemovingFromList && removeVariables?.listId === listId) + ); + }; + const { allPaths } = data ?? {}; // Filter out lists where user is a viewer (can't add/remove bookmarks) const filteredPaths = allPaths?.filter( @@ -77,30 +98,41 @@ const ListPickerPage = () => { <FlatList className="h-full" contentContainerStyle={{ - gap: 5, + gap: 6, + }} + renderItem={(l) => { + const listId = l.item[l.item.length - 1].id; + const isLoading = isListLoading(listId); + const isChecked = existingLists && existingLists.has(listId); + + return ( + <View className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"> + <Pressable + key={listId} + onPress={() => !isLoading && toggleList(listId)} + disabled={isLoading} + className="flex w-full flex-row items-center justify-between" + > + <Text className="shrink"> + {l.item + .map((item) => `${item.icon} ${item.name}`) + .join(" / ")} + </Text> + {isLoading ? ( + <ActivityIndicator size="small" /> + ) : ( + <Checkbox + value={isChecked} + onValueChange={() => { + toggleList(listId); + }} + disabled={isLoading} + /> + )} + </Pressable> + </View> + ); }} - renderItem={(l) => ( - <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"> - <Pressable - key={l.item[l.item.length - 1].id} - onPress={() => toggleList(l.item[l.item.length - 1].id)} - className="flex w-full flex-row justify-between" - > - <Text> - {l.item.map((item) => `${item.icon} ${item.name}`).join(" / ")} - </Text> - <Checkbox - value={ - existingLists && - existingLists.has(l.item[l.item.length - 1].id) - } - onValueChange={() => { - toggleList(l.item[l.item.length - 1].id); - }} - /> - </Pressable> - </View> - )} data={filteredPaths} /> </CustomSafeAreaView> diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx index a4575b27..64d057f2 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx @@ -6,17 +6,19 @@ import FullPageSpinner from "@/components/ui/FullPageSpinner"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; import { useColorScheme } from "@/lib/useColorScheme"; +import { useQuery } from "@tanstack/react-query"; import { Check, Plus } from "lucide-react-native"; import { useAutoRefreshingBookmarkQuery, useUpdateBookmarkTags, } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const NEW_TAG_ID = "new-tag"; const ListPickerPage = () => { + const api = useTRPC(); const { colors } = useColorScheme(); const { slug: bookmarkId } = useLocalSearchParams(); @@ -34,22 +36,24 @@ const ListPickerPage = () => { }); }; - const { data: allTags, isPending: isAllTagsPending } = api.tags.list.useQuery( - {}, - { - select: React.useCallback( - (data: { tags: { id: string; name: string }[] }) => { - return data.tags - .map((t) => ({ - id: t.id, - name: t.name, - lowered: t.name.toLowerCase(), - })) - .sort((a, b) => a.lowered.localeCompare(b.lowered)); - }, - [], - ), - }, + const { data: allTags, isPending: isAllTagsPending } = useQuery( + api.tags.list.queryOptions( + {}, + { + select: React.useCallback( + (data: { tags: { id: string; name: string }[] }) => { + return data.tags + .map((t) => ({ + id: t.id, + name: t.name, + lowered: t.name.toLowerCase(), + })) + .sort((a, b) => a.lowered.localeCompare(b.lowered)); + }, + [], + ), + }, + ), ); const { data: existingTags } = useAutoRefreshingBookmarkQuery({ bookmarkId, @@ -165,7 +169,7 @@ const ListPickerPage = () => { contentInsetAdjustmentBehavior="automatic" keyExtractor={(t) => t.id} contentContainerStyle={{ - gap: 5, + gap: 6, }} SectionSeparatorComponent={() => <View className="h-1" />} sections={[ @@ -207,7 +211,7 @@ const ListPickerPage = () => { }) } > - <View className="mx-2 flex flex-row items-center gap-2 rounded-xl border border-input bg-card px-4 py-2"> + <View className="mx-2 flex flex-row items-center gap-2 rounded-xl bg-card px-4 py-2"> {t.section.title == "Existing Tags" && ( <Check color={colors.foreground} /> )} diff --git a/apps/mobile/app/dashboard/bookmarks/new.tsx b/apps/mobile/app/dashboard/bookmarks/new.tsx index 25882d7f..f7be22e1 100644 --- a/apps/mobile/app/dashboard/bookmarks/new.tsx +++ b/apps/mobile/app/dashboard/bookmarks/new.tsx @@ -2,7 +2,6 @@ import React, { useState } from "react"; import { View } from "react-native"; import { router } from "expo-router"; import { Button } from "@/components/ui/Button"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import { Input } from "@/components/ui/Input"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; @@ -59,25 +58,23 @@ const NoteEditorPage = () => { }; return ( - <CustomSafeAreaView> - <View className="gap-2 px-4"> - {error && ( - <Text className="w-full text-center text-red-500">{error}</Text> - )} - <Input - onChangeText={setText} - className="bg-card" - multiline - placeholder="What's on your mind?" - autoFocus - autoCapitalize={"none"} - textAlignVertical="top" - /> - <Button onPress={onSubmit} disabled={isPending}> - <Text>Save</Text> - </Button> - </View> - </CustomSafeAreaView> + <View className="flex-1 gap-2 px-4 pt-4"> + {error && ( + <Text className="w-full text-center text-red-500">{error}</Text> + )} + <Input + onChangeText={setText} + className="bg-card" + multiline + placeholder="What's on your mind?" + autoFocus + autoCapitalize={"none"} + textAlignVertical="top" + /> + <Button onPress={onSubmit} disabled={isPending}> + <Text>Save</Text> + </Button> + </View> ); }; diff --git a/apps/mobile/app/dashboard/lists/[slug]/edit.tsx b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx new file mode 100644 index 00000000..c1103b4d --- /dev/null +++ b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { Button } from "@/components/ui/Button"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Input } from "@/components/ui/Input"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import { useQuery } from "@tanstack/react-query"; + +import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + +const EditListPage = () => { + const { slug: listId } = useLocalSearchParams<{ slug?: string | string[] }>(); + const [text, setText] = useState(""); + const [query, setQuery] = useState(""); + const { toast } = useToast(); + const api = useTRPC(); + const { mutate, isPending: editIsPending } = useEditBookmarkList({ + onSuccess: () => { + dismiss(); + }, + onError: (error) => { + // Extract error message from the error object + let errorMessage = "Something went wrong"; + if (error.data?.zodError) { + errorMessage = Object.values(error.data.zodError.fieldErrors) + .flat() + .join("\n"); + } else if (error.message) { + errorMessage = error.message; + } + toast({ + message: errorMessage, + variant: "destructive", + }); + }, + }); + + if (typeof listId !== "string") { + throw new Error("Unexpected param type"); + } + + const { data: list, isLoading: fetchIsPending } = useQuery( + api.lists.get.queryOptions({ + listId, + }), + ); + + const dismiss = () => { + router.back(); + }; + + useEffect(() => { + if (!list) return; + setText(list.name ?? ""); + setQuery(list.query ?? ""); + }, [list?.id, list?.query, list?.name]); + + const onSubmit = () => { + if (!text.trim()) { + toast({ message: "List name can't be empty", variant: "destructive" }); + return; + } + + if (list?.type === "smart" && !query.trim()) { + toast({ + message: "Smart lists must have a search query", + variant: "destructive", + }); + return; + } + + mutate({ + listId, + name: text.trim(), + query: list?.type === "smart" ? query.trim() : undefined, + }); + }; + + const isPending = fetchIsPending || editIsPending; + + return ( + <CustomSafeAreaView> + {isPending ? ( + <FullPageSpinner /> + ) : ( + <View className="gap-3 px-4"> + {/* List Type Info - not editable */} + <View className="gap-2"> + <Text className="text-sm text-muted-foreground">List Type</Text> + <View className="flex flex-row gap-2"> + <View className="flex-1"> + <Button + variant={list?.type === "manual" ? "primary" : "secondary"} + disabled + > + <Text>Manual</Text> + </Button> + </View> + <View className="flex-1"> + <Button + variant={list?.type === "smart" ? "primary" : "secondary"} + disabled + > + <Text>Smart</Text> + </Button> + </View> + </View> + </View> + + {/* List Name */} + <View className="flex flex-row items-center gap-1"> + <Text className="shrink p-2">{list?.icon || "🚀"}</Text> + <Input + className="flex-1 bg-card" + onChangeText={setText} + value={text} + placeholder="List Name" + autoFocus + autoCapitalize={"none"} + /> + </View> + + {/* Smart List Query Input */} + {list?.type === "smart" && ( + <View className="gap-2"> + <Text className="text-sm text-muted-foreground"> + Search Query + </Text> + <Input + className="bg-card" + onChangeText={setQuery} + value={query} + placeholder="e.g., #important OR list:work" + autoCapitalize={"none"} + /> + <Text className="text-xs italic text-muted-foreground"> + Smart lists automatically show bookmarks matching your search + query + </Text> + </View> + )} + + <Button disabled={isPending} onPress={onSubmit}> + <Text>Save</Text> + </Button> + </View> + )} + </CustomSafeAreaView> + ); +}; + +export default EditListPage; diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx index e7aab443..763df65e 100644 --- a/apps/mobile/app/dashboard/lists/[slug].tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx @@ -5,14 +5,16 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; import FullPageError from "@/components/FullPageError"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { api } from "@/lib/trpc"; import { MenuView } from "@react-native-menu/menu"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Ellipsis } from "lucide-react-native"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export default function ListView() { const { slug } = useLocalSearchParams(); + const api = useTRPC(); if (typeof slug !== "string") { throw new Error("Unexpected param type"); } @@ -20,7 +22,7 @@ export default function ListView() { data: list, error, refetch, - } = api.lists.get.useQuery({ listId: slug }); + } = useQuery(api.lists.get.queryOptions({ listId: slug })); return ( <CustomSafeAreaView> @@ -58,17 +60,22 @@ function ListActionsMenu({ listId: string; role: ZBookmarkList["userRole"]; }) { - const { mutate: deleteList } = api.lists.delete.useMutation({ - onSuccess: () => { - router.replace("/dashboard/lists"); - }, - }); + const api = useTRPC(); + const { mutate: deleteList } = useMutation( + api.lists.delete.mutationOptions({ + onSuccess: () => { + router.replace("/dashboard/lists"); + }, + }), + ); - const { mutate: leaveList } = api.lists.leaveList.useMutation({ - onSuccess: () => { - router.replace("/dashboard/lists"); - }, - }); + const { mutate: leaveList } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + router.replace("/dashboard/lists"); + }, + }), + ); const handleDelete = () => { Alert.alert("Delete List", "Are you sure you want to delete this list?", [ @@ -96,10 +103,24 @@ function ListActionsMenu({ ]); }; + const handleEdit = () => { + router.push({ + pathname: "/dashboard/lists/[slug]/edit", + params: { slug: listId }, + }); + }; + return ( <MenuView actions={[ { + id: "edit", + title: "Edit List", + attributes: { + hidden: role !== "owner", + }, + }, + { id: "delete", title: "Delete List", attributes: { @@ -122,9 +143,10 @@ function ListActionsMenu({ onPressAction={({ nativeEvent }) => { if (nativeEvent.event === "delete") { handleDelete(); - } - if (nativeEvent.event === "leave") { + } else if (nativeEvent.event === "leave") { handleLeave(); + } else if (nativeEvent.event === "edit") { + handleEdit(); } }} shouldOpenOnLongPress={false} diff --git a/apps/mobile/app/dashboard/lists/new.tsx b/apps/mobile/app/dashboard/lists/new.tsx index af51ed15..bada46f2 100644 --- a/apps/mobile/app/dashboard/lists/new.tsx +++ b/apps/mobile/app/dashboard/lists/new.tsx @@ -66,20 +66,22 @@ const NewListPage = () => { <View className="gap-2"> <Text className="text-sm text-muted-foreground">List Type</Text> <View className="flex flex-row gap-2"> - <Button - variant={listType === "manual" ? "primary" : "secondary"} - onPress={() => setListType("manual")} - className="flex-1" - > - <Text>Manual</Text> - </Button> - <Button - variant={listType === "smart" ? "primary" : "secondary"} - onPress={() => setListType("smart")} - className="flex-1" - > - <Text>Smart</Text> - </Button> + <View className="flex-1"> + <Button + variant={listType === "manual" ? "primary" : "secondary"} + onPress={() => setListType("manual")} + > + <Text>Manual</Text> + </Button> + </View> + <View className="flex-1"> + <Button + variant={listType === "smart" ? "primary" : "secondary"} + onPress={() => setListType("smart")} + > + <Text>Smart</Text> + </Button> + </View> </View> </View> diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx index ab89ce8d..d43f1aef 100644 --- a/apps/mobile/app/dashboard/search.tsx +++ b/apps/mobile/app/dashboard/search.tsx @@ -7,12 +7,16 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import { SearchInput } from "@/components/ui/SearchInput"; import { Text } from "@/components/ui/Text"; -import { api } from "@/lib/trpc"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { keepPreviousData } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -29,7 +33,12 @@ export default function Search() { removeItem: (k: string) => AsyncStorage.removeItem(k), }); - const onRefresh = api.useUtils().bookmarks.searchBookmarks.invalidate; + const api = useTRPC(); + const queryClient = useQueryClient(); + + const onRefresh = () => { + queryClient.invalidateQueries(api.bookmarks.searchBookmarks.pathFilter()); + }; const { data, @@ -39,14 +48,16 @@ export default function Search() { isFetching, fetchNextPage, isFetchingNextPage, - } = api.bookmarks.searchBookmarks.useInfiniteQuery( - { text: query }, - { - placeholderData: keepPreviousData, - gcTime: 0, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.bookmarks.searchBookmarks.infiniteQueryOptions( + { text: query }, + { + placeholderData: keepPreviousData, + gcTime: 0, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const filteredHistory = useMemo(() => { diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..30ad54b9 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import { + ReaderPreview, + ReaderPreviewRef, +} from "@/components/reader/ReaderPreview"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue<number>(effectiveFontSize); + const fontSizeMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue<number>(effectiveLineHeight); + const lineHeightMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Refs to track latest display values (avoids stale closures in callbacks) + const displayFontSizeRef = useRef(displayFontSize); + displayFontSizeRef.current = displayFontSize; + const displayLineHeightRef = useRef(displayLineHeight); + displayLineHeightRef.current = displayLineHeight; + + // Ref for the WebView preview component + const previewRef = useRef<ReaderPreviewRef>(null); + + // Functions to update preview styles via IPC (called from worklets via runOnJS) + const updatePreviewFontSize = useCallback( + (fontSize: number) => { + setDisplayFontSize(fontSize); + previewRef.current?.updateStyles( + effectiveFontFamily, + fontSize, + displayLineHeightRef.current, + ); + }, + [effectiveFontFamily], + ); + + const updatePreviewLineHeight = useCallback( + (lineHeight: number) => { + setDisplayLineHeight(lineHeight); + previewRef.current?.updateStyles( + effectiveFontFamily, + displayFontSizeRef.current, + lineHeight, + ); + }, + [effectiveFontFamily], + ); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + // Update preview immediately with new font family + previewRef.current?.updateStyles( + fontFamily, + displayFontSize, + displayLineHeight, + ); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + <CustomSafeAreaView> + <ScrollView + className="w-full" + contentContainerClassName="items-center gap-4 px-4 py-2" + > + {/* Font Family Selection */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Family + {localOverrides.fontFamily !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="w-full rounded-lg bg-card px-4 py-2"> + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + <View key={fontFamily}> + <Pressable + onPress={() => handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[fontFamily], + }} + > + {formatFontFamily(fontFamily)} + </Text> + {isChecked && <Check color="rgb(0, 122, 255)" />} + </Pressable> + {index < fontFamilyOptions.length - 1 && ( + <Divider orientation="horizontal" className="h-0.5" /> + )} + </View> + ); + })} + </View> + </View> + + {/* Font Size */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.min} + </Text> + <View className="flex-1"> + <Slider + progress={fontSizeProgress} + minimumValue={fontSizeMin} + maximumValue={fontSizeMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(updatePreviewFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.max} + </Text> + </View> + </View> + + {/* Line Height */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.min} + </Text> + <View className="flex-1"> + <Slider + progress={lineHeightProgress} + minimumValue={lineHeightMin} + maximumValue={lineHeightMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(updatePreviewLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.max} + </Text> + </View> + </View> + + {/* Preview */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Preview + </Text> + <ReaderPreview + ref={previewRef} + initialFontFamily={effectiveFontFamily} + initialFontSize={effectiveFontSize} + initialLineHeight={effectiveLineHeight} + /> + </View> + + <Divider orientation="horizontal" className="my-2 w-full" /> + + {/* Save as Default */} + <Pressable + onPress={handleSaveAsDefault} + disabled={!hasLocalOverrides} + className="w-full rounded-lg bg-card px-4 py-3" + > + <Text + className={`text-center ${hasLocalOverrides ? "text-blue-500" : "text-muted-foreground"}`} + > + Save as Default (All Devices) + </Text> + </Pressable> + + {/* Clear Local */} + {hasLocalOverrides && ( + <Pressable + onPress={handleClearLocalOverrides} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Local Overrides</Text> + </Pressable> + )} + + {/* Clear Server */} + {hasServerDefaults && ( + <Pressable + onPress={handleClearServerDefaults} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Server Defaults</Text> + </Pressable> + )} + </ScrollView> + </CustomSafeAreaView> + ); +} diff --git a/apps/mobile/app/dashboard/tags/[slug].tsx b/apps/mobile/app/dashboard/tags/[slug].tsx index 3f294328..328c65d0 100644 --- a/apps/mobile/app/dashboard/tags/[slug].tsx +++ b/apps/mobile/app/dashboard/tags/[slug].tsx @@ -4,15 +4,22 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; import FullPageError from "@/components/FullPageError"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function TagView() { const { slug } = useLocalSearchParams(); + const api = useTRPC(); if (typeof slug !== "string") { throw new Error("Unexpected param type"); } - const { data: tag, error, refetch } = api.tags.get.useQuery({ tagId: slug }); + const { + data: tag, + error, + refetch, + } = useQuery(api.tags.get.queryOptions({ tagId: slug })); return ( <CustomSafeAreaView> diff --git a/apps/mobile/app/server-address.tsx b/apps/mobile/app/server-address.tsx new file mode 100644 index 00000000..3b7b01d4 --- /dev/null +++ b/apps/mobile/app/server-address.tsx @@ -0,0 +1,231 @@ +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; +import { Stack, useRouter } from "expo-router"; +import { Button } from "@/components/ui/Button"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Input } from "@/components/ui/Input"; +import PageTitle from "@/components/ui/PageTitle"; +import { Text } from "@/components/ui/Text"; +import useAppSettings from "@/lib/settings"; +import { Plus, Trash2 } from "lucide-react-native"; +import { useColorScheme } from "nativewind"; + +export default function ServerAddress() { + const router = useRouter(); + const { colorScheme } = useColorScheme(); + const iconColor = colorScheme === "dark" ? "#d1d5db" : "#374151"; + const { settings, setSettings } = useAppSettings(); + const [address, setAddress] = useState( + settings.address ?? "https://cloud.karakeep.app", + ); + const [error, setError] = useState<string | undefined>(); + + // Custom headers state + const [headers, setHeaders] = useState<{ key: string; value: string }[]>( + Object.entries(settings.customHeaders || {}).map(([key, value]) => ({ + key, + value, + })), + ); + const [newHeaderKey, setNewHeaderKey] = useState(""); + const [newHeaderValue, setNewHeaderValue] = useState(""); + + const handleAddHeader = () => { + if (!newHeaderKey.trim() || !newHeaderValue.trim()) { + return; + } + + // Check if header already exists + const existingIndex = headers.findIndex((h) => h.key === newHeaderKey); + if (existingIndex >= 0) { + // Update existing header + const updatedHeaders = [...headers]; + updatedHeaders[existingIndex].value = newHeaderValue; + setHeaders(updatedHeaders); + } else { + // Add new header + setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]); + } + + setNewHeaderKey(""); + setNewHeaderValue(""); + }; + + const handleRemoveHeader = (index: number) => { + setHeaders(headers.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + // Validate the address + if (!address.trim()) { + setError("Server address is required"); + return; + } + + if (!address.startsWith("http://") && !address.startsWith("https://")) { + setError("Server address must start with http:// or https://"); + return; + } + + // Convert headers array to object + const headersObject = headers.reduce( + (acc, { key, value }) => { + if (key.trim() && value.trim()) { + acc[key] = value; + } + return acc; + }, + {} as Record<string, string>, + ); + + // Remove trailing slash and save + const cleanedAddress = address.trim().replace(/\/$/, ""); + setSettings({ + ...settings, + address: cleanedAddress, + customHeaders: headersObject, + }); + router.back(); + }; + + return ( + <CustomSafeAreaView> + <Stack.Screen + options={{ + title: "Server Address", + headerTransparent: true, + }} + /> + <PageTitle title="Server Address" /> + <KeyboardAwareScrollView + className="w-full flex-1" + contentContainerClassName="items-center gap-4 px-4 py-4" + bottomOffset={20} + keyboardShouldPersistTaps="handled" + > + {/* Error Message */} + {error && ( + <View className="w-full rounded-lg bg-red-50 p-3 dark:bg-red-950"> + <Text className="text-center text-sm text-red-600 dark:text-red-400"> + {error} + </Text> + </View> + )} + + {/* Server Address Section */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Server URL + </Text> + <View className="w-full gap-3 rounded-lg bg-card px-4 py-4"> + <Text className="text-sm text-muted-foreground"> + Enter the URL of your Karakeep server + </Text> + <Input + placeholder="https://cloud.karakeep.app" + value={address} + onChangeText={(text) => { + setAddress(text); + setError(undefined); + }} + autoCapitalize="none" + keyboardType="url" + autoFocus + inputClasses="bg-background" + /> + <Text className="text-xs text-muted-foreground"> + Must start with http:// or https:// + </Text> + </View> + </View> + + {/* Custom Headers Section */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Custom Headers + {headers.length > 0 && ( + <Text className="text-muted-foreground"> ({headers.length})</Text> + )} + </Text> + <View className="w-full gap-3 rounded-lg bg-card px-4 py-4"> + <Text className="text-sm text-muted-foreground"> + Add custom HTTP headers for API requests + </Text> + + {/* Existing Headers List */} + {headers.length === 0 ? ( + <View className="py-4"> + <Text className="text-center text-sm text-muted-foreground"> + No custom headers configured + </Text> + </View> + ) : ( + <View className="gap-2"> + {headers.map((header, index) => ( + <View + key={index} + className="flex-row items-center gap-3 rounded-lg border border-border bg-background p-3" + > + <View className="flex-1 gap-1"> + <Text className="text-sm font-semibold"> + {header.key} + </Text> + <Text + className="text-xs text-muted-foreground" + numberOfLines={1} + > + {header.value} + </Text> + </View> + <Pressable + onPress={() => handleRemoveHeader(index)} + className="rounded-md p-2" + hitSlop={8} + > + <Trash2 size={18} color="#ef4444" /> + </Pressable> + </View> + ))} + </View> + )} + + {/* Add New Header Form */} + <View className="gap-2 border-t border-border pt-4"> + <Text className="text-sm font-medium">Add New Header</Text> + <Input + placeholder="Header Name (e.g., X-Custom-Header)" + value={newHeaderKey} + onChangeText={setNewHeaderKey} + autoCapitalize="none" + inputClasses="bg-background" + /> + <Input + placeholder="Header Value" + value={newHeaderValue} + onChangeText={setNewHeaderValue} + autoCapitalize="none" + inputClasses="bg-background" + /> + <Button + variant="secondary" + onPress={handleAddHeader} + disabled={!newHeaderKey.trim() || !newHeaderValue.trim()} + > + <Plus size={16} color={iconColor} /> + <Text className="text-sm">Add Header</Text> + </Button> + </View> + </View> + </View> + </KeyboardAwareScrollView> + + {/* Fixed Save Button */} + <View className="border-t border-border bg-background px-4 py-3"> + <Button onPress={handleSave} className="w-full"> + <Text className="font-semibold">Save</Text> + </Button> + </View> + </CustomSafeAreaView> + ); +} diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx index 3e2b6bfb..355f32ef 100644 --- a/apps/mobile/app/sharing.tsx +++ b/apps/mobile/app/sharing.tsx @@ -1,14 +1,19 @@ import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; +import Animated, { FadeIn } from "react-native-reanimated"; import { useRouter } from "expo-router"; import { useShareIntentContext } from "expo-share-intent"; +import ErrorAnimation from "@/components/sharing/ErrorAnimation"; +import LoadingAnimation from "@/components/sharing/LoadingAnimation"; +import SuccessAnimation from "@/components/sharing/SuccessAnimation"; import { Button } from "@/components/ui/Button"; import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; import { useUploadAsset } from "@/lib/upload"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; type Mode = @@ -18,8 +23,11 @@ type Mode = | { type: "error" }; function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { + const api = useTRPC(); + const queryClient = useQueryClient(); + const onSaved = (d: ZBookmark & { alreadyExists: boolean }) => { - invalidateAllBookmarks(); + queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter()); setMode({ type: d.alreadyExists ? "alreadyExists" : "success", bookmarkId: d.id, @@ -36,9 +44,6 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { }, }); - const invalidateAllBookmarks = - api.useUtils().bookmarks.getBookmarks.invalidate; - useEffect(() => { if (isLoading) { return; @@ -77,62 +82,23 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { } }, [isLoading]); - const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({ - onSuccess: onSaved, - onError: () => { - setMode({ type: "error" }); - }, - }); - - return ( - <View className="flex flex-row gap-3"> - <Text variant="largeTitle">Hoarding</Text> - <ActivityIndicator /> - </View> + const { mutate, isPending } = useMutation( + api.bookmarks.createBookmark.mutationOptions({ + onSuccess: onSaved, + onError: () => { + setMode({ type: "error" }); + }, + }), ); + + return null; } export default function Sharing() { const router = useRouter(); const [mode, setMode] = useState<Mode>({ type: "idle" }); - const autoCloseTimeoutId = useRef<number | null>(null); - - let comp; - switch (mode.type) { - case "idle": { - comp = <SaveBookmark setMode={setMode} />; - break; - } - case "alreadyExists": - case "success": { - comp = ( - <View className="items-center gap-4"> - <Text variant="largeTitle"> - {mode.type === "alreadyExists" ? "Already Hoarded!" : "Hoarded!"} - </Text> - <Button - onPress={() => { - router.replace(`/dashboard/bookmarks/${mode.bookmarkId}/info`); - if (autoCloseTimeoutId.current) { - clearTimeout(autoCloseTimeoutId.current); - } - }} - > - <Text>Manage</Text> - </Button> - <Pressable onPress={() => router.replace("dashboard")}> - <Text className="text-muted-foreground">Dismiss</Text> - </Pressable> - </View> - ); - break; - } - case "error": { - comp = <Text variant="largeTitle">Error!</Text>; - break; - } - } + const autoCloseTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null); // Auto dismiss the modal after saving. useEffect(() => { @@ -140,14 +106,118 @@ export default function Sharing() { return; } - autoCloseTimeoutId.current = setTimeout(() => { - router.replace("dashboard"); - }, 2000); + autoCloseTimeoutId.current = setTimeout( + () => { + router.replace("dashboard"); + }, + mode.type === "error" ? 3000 : 2500, + ); - return () => clearTimeout(autoCloseTimeoutId.current!); + return () => { + if (autoCloseTimeoutId.current) { + clearTimeout(autoCloseTimeoutId.current); + } + }; }, [mode.type]); + const handleManage = () => { + if (mode.type === "success" || mode.type === "alreadyExists") { + router.replace(`/dashboard/bookmarks/${mode.bookmarkId}/info`); + if (autoCloseTimeoutId.current) { + clearTimeout(autoCloseTimeoutId.current); + } + } + }; + + const handleDismiss = () => { + if (autoCloseTimeoutId.current) { + clearTimeout(autoCloseTimeoutId.current); + } + router.replace("dashboard"); + }; + return ( - <View className="flex-1 items-center justify-center gap-4">{comp}</View> + <View className="flex-1 items-center justify-center bg-background"> + {/* Hidden component that handles the save logic */} + {mode.type === "idle" && <SaveBookmark setMode={setMode} />} + + {/* Loading State */} + {mode.type === "idle" && <LoadingAnimation />} + + {/* Success State */} + {(mode.type === "success" || mode.type === "alreadyExists") && ( + <Animated.View + entering={FadeIn.duration(200)} + className="items-center gap-6" + > + <SuccessAnimation isAlreadyExists={mode.type === "alreadyExists"} /> + + <Animated.View + entering={FadeIn.delay(400).duration(300)} + className="items-center gap-2" + > + <Text variant="title1" className="font-semibold text-foreground"> + {mode.type === "alreadyExists" ? "Already Hoarded!" : "Hoarded!"} + </Text> + <Text variant="body" className="text-muted-foreground"> + {mode.type === "alreadyExists" + ? "This item was saved before" + : "Saved to your collection"} + </Text> + </Animated.View> + + <Animated.View + entering={FadeIn.delay(600).duration(300)} + className="items-center gap-3 pt-2" + > + <Button onPress={handleManage} variant="primary" size="lg"> + <Text className="font-medium text-primary-foreground"> + Manage + </Text> + </Button> + <Pressable + onPress={handleDismiss} + className="px-4 py-2 active:opacity-60" + > + <Text className="text-muted-foreground">Dismiss</Text> + </Pressable> + </Animated.View> + </Animated.View> + )} + + {/* Error State */} + {mode.type === "error" && ( + <Animated.View + entering={FadeIn.duration(200)} + className="items-center gap-6" + > + <ErrorAnimation /> + + <Animated.View + entering={FadeIn.delay(300).duration(300)} + className="items-center gap-2" + > + <Text variant="title1" className="font-semibold text-foreground"> + Oops! + </Text> + <Text variant="body" className="text-muted-foreground"> + Something went wrong + </Text> + </Animated.View> + + <Animated.View + entering={FadeIn.delay(500).duration(300)} + className="items-center gap-3 pt-2" + > + <Pressable + onPress={handleDismiss} + className="px-4 py-2 active:opacity-60" + > + <Text className="text-muted-foreground">Dismiss</Text> + </Pressable> + </Animated.View> + </Animated.View> + )} + </View> ); } diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx index 6a554f89..94a57822 100644 --- a/apps/mobile/app/signin.tsx +++ b/apps/mobile/app/signin.tsx @@ -7,15 +7,17 @@ import { View, } from "react-native"; import { Redirect, useRouter } from "expo-router"; -import { CustomHeadersModal } from "@/components/CustomHeadersModal"; +import * as WebBrowser from "expo-web-browser"; import Logo from "@/components/Logo"; import { TailwindResolver } from "@/components/TailwindResolver"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; -import { Bug, Check, Edit3 } from "lucide-react-native"; +import { useMutation } from "@tanstack/react-query"; +import { Bug, Edit3 } from "lucide-react-native"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; enum LoginType { Password, @@ -25,15 +27,9 @@ enum LoginType { export default function Signin() { const { settings, setSettings } = useAppSettings(); const router = useRouter(); - + const api = useTRPC(); const [error, setError] = useState<string | undefined>(); const [loginType, setLoginType] = useState<LoginType>(LoginType.Password); - const [isEditingServerAddress, setIsEditingServerAddress] = useState(false); - const [tempServerAddress, setTempServerAddress] = useState( - settings.address ?? "https://cloud.karakeep.app", - ); - const [isCustomHeadersModalVisible, setIsCustomHeadersModalVisible] = - useState(false); const emailRef = useRef<string>(""); const passwordRef = useRef<string>(""); @@ -50,51 +46,58 @@ export default function Signin() { }; const { mutate: login, isPending: userNamePasswordRequestIsPending } = - api.apiKeys.exchange.useMutation({ - onSuccess: (resp) => { - setSettings({ ...settings, apiKey: resp.key, apiKeyId: resp.id }); - }, - onError: (e) => { - if (e.data?.code === "UNAUTHORIZED") { - setError("Wrong username or password"); - } else { - setError(`${e.message}`); - } - }, - }); + useMutation( + api.apiKeys.exchange.mutationOptions({ + onSuccess: (resp) => { + setSettings({ ...settings, apiKey: resp.key, apiKeyId: resp.id }); + }, + onError: (e) => { + if (e.data?.code === "UNAUTHORIZED") { + setError("Wrong username or password"); + } else { + setError(`${e.message}`); + } + }, + }), + ); const { mutate: validateApiKey, isPending: apiKeyValueRequestIsPending } = - api.apiKeys.validate.useMutation({ - onSuccess: () => { - const apiKey = apiKeyRef.current; - setSettings({ ...settings, apiKey: apiKey }); - }, - onError: (e) => { - if (e.data?.code === "UNAUTHORIZED") { - setError("Invalid API key"); - } else { - setError(`${e.message}`); - } - }, - }); + useMutation( + api.apiKeys.validate.mutationOptions({ + onSuccess: () => { + const apiKey = apiKeyRef.current; + setSettings({ ...settings, apiKey: apiKey }); + }, + onError: (e) => { + if (e.data?.code === "UNAUTHORIZED") { + setError("Invalid API key"); + } else { + setError(`${e.message}`); + } + }, + }), + ); if (settings.apiKey) { return <Redirect href="dashboard" />; } - const handleSaveCustomHeaders = (headers: Record<string, string>) => { - setSettings({ ...settings, customHeaders: headers }); + const onSignUp = async () => { + const serverAddress = settings.address ?? "https://cloud.karakeep.app"; + const signupUrl = `${serverAddress}/signup?redirectUrl=${encodeURIComponent("karakeep://signin")}`; + + await WebBrowser.openAuthSessionAsync(signupUrl, "karakeep://signin"); }; const onSignin = () => { - if (!tempServerAddress) { + if (!settings.address) { setError("Server address is required"); return; } if ( - !tempServerAddress.startsWith("http://") && - !tempServerAddress.startsWith("https://") + !settings.address.startsWith("http://") && + !settings.address.startsWith("https://") ) { setError("Server address must start with http:// or https://"); return; @@ -137,71 +140,23 @@ export default function Signin() { )} <View className="gap-2"> <Text className="font-bold">Server Address</Text> - {!isEditingServerAddress ? ( - <View className="flex-row items-center gap-2"> - <View className="flex-1 rounded-md border border-border bg-card px-3 py-2"> - <Text>{tempServerAddress}</Text> - </View> - <Button - size="icon" - variant="secondary" - onPress={() => { - setIsEditingServerAddress(true); - }} - > - <TailwindResolver - comp={(styles) => ( - <Edit3 size={16} color={styles?.color?.toString()} /> - )} - className="color-foreground" - /> - </Button> + <View className="flex-row items-center gap-2"> + <View className="flex-1 rounded-md border border-border bg-card px-3 py-2"> + <Text>{settings.address ?? "https://cloud.karakeep.app"}</Text> </View> - ) : ( - <View className="flex-row items-center gap-2"> - <Input - className="flex-1" - inputClasses="bg-card" - placeholder="Server Address" - value={tempServerAddress} - autoCapitalize="none" - keyboardType="url" - onChangeText={setTempServerAddress} - autoFocus + <Button + size="icon" + variant="secondary" + onPress={() => router.push("/server-address")} + > + <TailwindResolver + comp={(styles) => ( + <Edit3 size={16} color={styles?.color?.toString()} /> + )} + className="color-foreground" /> - <Button - size="icon" - variant="primary" - onPress={() => { - if (tempServerAddress.trim()) { - setSettings({ - ...settings, - address: tempServerAddress.trim().replace(/\/$/, ""), - }); - } - setIsEditingServerAddress(false); - }} - > - <TailwindResolver - comp={(styles) => ( - <Check size={16} color={styles?.color?.toString()} /> - )} - className="text-white" - /> - </Button> - </View> - )} - <Pressable - onPress={() => setIsCustomHeadersModalVisible(true)} - className="mt-1" - > - <Text className="text-xs text-gray-500 underline"> - Configure Custom Headers{" "} - {settings.customHeaders && - Object.keys(settings.customHeaders).length > 0 && - `(${Object.keys(settings.customHeaders).length})`} - </Text> - </Pressable> + </Button> + </View> </View> {loginType === LoginType.Password && ( <> @@ -280,14 +235,14 @@ export default function Signin() { : "Use password instead?"} </Text> </Pressable> + <Pressable onPress={onSignUp}> + <Text className="mt-4 text-center text-gray-500"> + Don't have an account?{" "} + <Text className="text-foreground underline">Sign Up</Text> + </Text> + </Pressable> </View> </TouchableWithoutFeedback> - <CustomHeadersModal - visible={isCustomHeadersModalVisible} - customHeaders={settings.customHeaders || {}} - onClose={() => setIsCustomHeadersModalVisible(false)} - onSave={handleSaveCustomHeaders} - /> </KeyboardAvoidingView> ); } diff --git a/apps/mobile/app/test-connection.tsx b/apps/mobile/app/test-connection.tsx index 4cf69fcf..7e1d5779 100644 --- a/apps/mobile/app/test-connection.tsx +++ b/apps/mobile/app/test-connection.tsx @@ -1,9 +1,8 @@ import React from "react"; -import { Platform, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { Button } from "@/components/ui/Button"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import { Input } from "@/components/ui/Input"; import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; import { buildApiHeaders, cn } from "@/lib/utils"; @@ -81,7 +80,7 @@ export default function TestConnection() { return ( <CustomSafeAreaView> - <View className="m-4 flex flex-col gap-2 p-2"> + <View className="m-4 flex flex-1 flex-col gap-2 p-2"> <Button className="w-full" onPress={async () => { @@ -121,17 +120,15 @@ export default function TestConnection() { {status === "error" && "Connection test failed"} </Text> </View> - <Input - className="h-fit leading-6" - style={{ - fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace", - }} - multiline={true} - scrollEnabled={true} - value={text} - onChangeText={setText} - editable={false} - /> + <ScrollView className="border-1 border-md h-64 flex-1 border-border bg-input p-2 leading-6"> + <Text + style={{ + fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace", + }} + > + {text} + </Text> + </ScrollView> </View> </CustomSafeAreaView> ); diff --git a/apps/mobile/components/SplashScreenController.tsx b/apps/mobile/components/SplashScreenController.tsx new file mode 100644 index 00000000..52c80415 --- /dev/null +++ b/apps/mobile/components/SplashScreenController.tsx @@ -0,0 +1,14 @@ +import { SplashScreen } from "expo-router"; +import useAppSettings from "@/lib/settings"; + +SplashScreen.preventAutoHideAsync(); + +export default function SplashScreenController() { + const { isLoading } = useAppSettings(); + + if (!isLoading) { + SplashScreen.hide(); + } + + return null; +} diff --git a/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx b/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx index 8fa88c8b..35726e4b 100644 --- a/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx +++ b/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx @@ -1,14 +1,25 @@ -import { Image } from "react-native"; +import { View } from "react-native"; +import { Image, ImageContentFit } from "expo-image"; import { useAssetUrl } from "@/lib/hooks"; export default function BookmarkAssetImage({ assetId, className, + contentFit = "cover", }: { assetId: string; className: string; + contentFit?: ImageContentFit; }) { const assetSource = useAssetUrl(assetId); - return <Image source={assetSource} className={className} />; + return ( + <View className={className}> + <Image + source={assetSource} + style={{ width: "100%", height: "100%" }} + contentFit={contentFit} + /> + </View> + ); } diff --git a/apps/mobile/components/bookmarks/BookmarkAssetView.tsx b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx index 5fe2f470..e009a027 100644 --- a/apps/mobile/components/bookmarks/BookmarkAssetView.tsx +++ b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx @@ -48,7 +48,7 @@ export default function BookmarkAssetView({ <Pressable onPress={() => setImageZoom(true)}> <BookmarkAssetImage assetId={bookmark.content.assetId} - className="h-56 min-h-56 w-full object-cover" + className="h-56 min-h-56 w-full" /> </Pressable> </View> diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index 922951e5..060aada9 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -1,7 +1,6 @@ import { ActivityIndicator, Alert, - Image, Platform, Pressable, ScrollView, @@ -9,14 +8,16 @@ import { View, } from "react-native"; import * as Clipboard from "expo-clipboard"; -import * as FileSystem from "expo-file-system"; +import * as FileSystem from "expo-file-system/legacy"; import * as Haptics from "expo-haptics"; +import { Image } from "expo-image"; import { router, useRouter } from "expo-router"; import * as Sharing from "expo-sharing"; import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; +import { buildApiHeaders } from "@/lib/utils"; import { MenuView } from "@react-native-menu/menu"; +import { useQuery } from "@tanstack/react-query"; import { Ellipsis, ShareIcon, Star } from "lucide-react-native"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -25,6 +26,7 @@ import { useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { getBookmarkLinkImageUrl, @@ -124,9 +126,10 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { assetUrl, fileUri, { - headers: { - Authorization: `Bearer ${settings.apiKey}`, - }, + headers: buildApiHeaders( + settings.apiKey, + settings.customHeaders, + ), }, ); @@ -314,29 +317,36 @@ function LinkCard({ let imageComp; if (imageUrl) { imageComp = ( - <Image - source={ - imageUrl.localAsset - ? { - uri: `${settings.address}${imageUrl.url}`, - headers: { - Authorization: `Bearer ${settings.apiKey}`, - }, - } - : { - uri: imageUrl.url, - } - } - className="h-56 min-h-56 w-full object-cover" - /> + <View className="h-56 min-h-56 w-full"> + <Image + source={ + imageUrl.localAsset + ? { + uri: `${settings.address}${imageUrl.url}`, + headers: buildApiHeaders( + settings.apiKey, + settings.customHeaders, + ), + } + : { + uri: imageUrl.url, + } + } + style={{ width: "100%", height: "100%" }} + contentFit="cover" + /> + </View> ); } else { imageComp = ( - <Image - // oxlint-disable-next-line no-require-imports - source={require("@/assets/blur.jpeg")} - className="h-56 w-full rounded-t-lg" - /> + <View className="h-56 w-full overflow-hidden rounded-t-lg"> + <Image + // oxlint-disable-next-line no-require-imports + source={require("@/assets/blur.jpeg")} + style={{ width: "100%", height: "100%" }} + contentFit="cover" + /> + </View> ); } @@ -345,7 +355,8 @@ function LinkCard({ <Pressable onPress={onOpenBookmark}>{imageComp}</Pressable> <View className="flex gap-2 p-2"> <Text - className="line-clamp-2 text-xl font-bold text-foreground" + className="text-xl font-bold text-foreground" + numberOfLines={2} onPress={onOpenBookmark} > {bookmark.title ?? bookmark.content.title ?? parsedUrl.host} @@ -360,7 +371,9 @@ function LinkCard({ <TagList bookmark={bookmark} /> <Divider orientation="vertical" className="mt-2 h-0.5 w-full" /> <View className="mt-2 flex flex-row justify-between px-2 pb-2"> - <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text> + <Text className="my-auto" numberOfLines={1}> + {parsedUrl.host} + </Text> <ActionBar bookmark={bookmark} /> </View> </View> @@ -388,7 +401,7 @@ function TextCard({ <View className="flex max-h-96 gap-2 p-2"> <Pressable onPress={onOpenBookmark}> {bookmark.title && ( - <Text className="line-clamp-2 text-xl font-bold"> + <Text className="text-xl font-bold" numberOfLines={2}> {bookmark.title} </Text> )} @@ -437,13 +450,15 @@ function AssetCard({ <Pressable onPress={onOpenBookmark}> <BookmarkAssetImage assetId={assetImage} - className="h-56 min-h-56 w-full object-cover" + className="h-56 min-h-56 w-full" /> </Pressable> <View className="flex gap-2 p-2"> <Pressable onPress={onOpenBookmark}> {title && ( - <Text className="line-clamp-2 text-xl font-bold">{title}</Text> + <Text numberOfLines={2} className="text-xl font-bold"> + {title} + </Text> )} </Pressable> {note && ( @@ -469,20 +484,23 @@ export default function BookmarkCard({ }: { bookmark: ZBookmark; }) { - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: initialData.id, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const api = useTRPC(); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, }, - }, + ), ); const router = useRouter(); @@ -521,5 +539,12 @@ export default function BookmarkCard({ break; } - return <View className="overflow-hidden rounded-xl bg-card">{comp}</View>; + return ( + <View + className="overflow-hidden rounded-xl bg-card" + style={{ borderCurve: "continuous" }} + > + {comp} + </View> + ); } diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index 730bcd08..57e00c24 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -5,14 +5,17 @@ import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; -import { api } from "@/lib/trpc"; +import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; import { useColorScheme } from "@/lib/useColorScheme"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import FullPageError from "../FullPageError"; import FullPageSpinner from "../ui/FullPageSpinner"; import BookmarkAssetImage from "./BookmarkAssetImage"; +import { PDFViewer } from "./PDFViewer"; export function BookmarkLinkBrowserPreview({ bookmark, @@ -32,22 +35,50 @@ export function BookmarkLinkBrowserPreview({ ); } +export function BookmarkLinkPdfPreview({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + throw new Error("Wrong content type rendered"); + } + + const asset = bookmark.assets.find((r) => r.assetType == "pdf"); + + const assetSource = useAssetUrl(asset?.id ?? ""); + + if (!asset) { + return ( + <View className="flex-1 bg-background"> + <Text>Asset has no PDF</Text> + </View> + ); + } + + return ( + <View className="flex flex-1"> + <PDFViewer source={assetSource.uri ?? ""} headers={assetSource.headers} /> + </View> + ); +} + export function BookmarkLinkReaderPreview({ bookmark, }: { bookmark: ZBookmark; }) { const { isDarkColorScheme: isDark } = useColorScheme(); + const { settings: readerSettings } = useReaderSettings(); + const api = useTRPC(); const { data: bookmarkWithContent, error, isLoading, refetch, - } = api.bookmarks.getBookmark.useQuery({ - bookmarkId: bookmark.id, - includeContent: true, - }); + } = useQuery( + api.bookmarks.getBookmark.queryOptions({ + bookmarkId: bookmark.id, + includeContent: true, + }), + ); if (isLoading) { return <FullPageSpinner />; @@ -61,6 +92,10 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } + const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily]; + const fontSize = readerSettings.fontSize; + const lineHeight = readerSettings.lineHeight; + return ( <View className="flex-1 bg-background"> <WebView @@ -73,8 +108,9 @@ export function BookmarkLinkReaderPreview({ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - line-height: 1.6; + font-family: ${fontFamily}; + font-size: ${fontSize}px; + line-height: ${lineHeight}; color: ${isDark ? "#e5e7eb" : "#374151"}; margin: 0; padding: 16px; @@ -85,17 +121,29 @@ export function BookmarkLinkReaderPreview({ img { max-width: 100%; height: auto; border-radius: 8px; } a { color: #3b82f6; text-decoration: none; } a:hover { text-decoration: underline; } - blockquote { - border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; - margin: 1em 0; - padding-left: 1em; - color: ${isDark ? "#9ca3af" : "#6b7280"}; + blockquote { + border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; + margin: 1em 0; + padding-left: 1em; + color: ${isDark ? "#9ca3af" : "#6b7280"}; + } + pre, code { + font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace; + background: ${isDark ? "#1f2937" : "#f3f4f6"}; + } + pre { + padding: 1em; + border-radius: 6px; + overflow-x: auto; + } + code { + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; } - pre { - background: ${isDark ? "#1f2937" : "#f3f4f6"}; - padding: 1em; - border-radius: 6px; - overflow-x: auto; + pre code { + padding: 0; + background: none; } </style> </head> @@ -180,7 +228,8 @@ export function BookmarkLinkScreenshotPreview({ <Pressable onPress={() => setImageZoom(true)}> <BookmarkAssetImage assetId={asset.id} - className="h-full w-full object-contain" + className="h-full w-full" + contentFit="contain" /> </Pressable> </View> diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index 58cbcc8d..5c9955bd 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -4,7 +4,12 @@ import { ChevronDown } from "lucide-react-native"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; -export type BookmarkLinkType = "browser" | "reader" | "screenshot" | "archive"; +export type BookmarkLinkType = + | "browser" + | "reader" + | "screenshot" + | "archive" + | "pdf"; function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] { if (bookmark.content.type !== BookmarkTypes.LINK) { @@ -26,6 +31,9 @@ function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] { ) { availableTypes.push("archive"); } + if (bookmark.assets.some((asset) => asset.assetType === "pdf")) { + availableTypes.push("pdf"); + } return availableTypes; } @@ -43,7 +51,7 @@ export default function BookmarkLinkTypeSelector({ }: BookmarkLinkTypeSelectorProps) { const availableTypes = getAvailableViewTypes(bookmark); - const allActions = [ + const viewActions = [ { id: "reader" as const, title: "Reader View", @@ -64,9 +72,14 @@ export default function BookmarkLinkTypeSelector({ title: "Archived Page", state: type === "archive" ? ("on" as const) : undefined, }, + { + id: "pdf" as const, + title: "PDF", + state: type === "pdf" ? ("on" as const) : undefined, + }, ]; - const availableActions = allActions.filter((action) => + const availableViewActions = viewActions.filter((action) => availableTypes.includes(action.id), ); @@ -76,7 +89,7 @@ export default function BookmarkLinkTypeSelector({ Haptics.selectionAsync(); onChange(nativeEvent.event as BookmarkLinkType); }} - actions={availableActions} + actions={availableViewActions} shouldOpenOnLongPress={false} > <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> diff --git a/apps/mobile/components/bookmarks/BookmarkLinkView.tsx b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx index e8a78029..ba4d5b0c 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkView.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx @@ -1,6 +1,7 @@ import { BookmarkLinkArchivePreview, BookmarkLinkBrowserPreview, + BookmarkLinkPdfPreview, BookmarkLinkReaderPreview, BookmarkLinkScreenshotPreview, } from "@/components/bookmarks/BookmarkLinkPreview"; @@ -31,5 +32,7 @@ export default function BookmarkLinkView({ return <BookmarkLinkScreenshotPreview bookmark={bookmark} />; case "archive": return <BookmarkLinkArchivePreview bookmark={bookmark} />; + case "pdf": + return <BookmarkLinkPdfPreview bookmark={bookmark} />; } } diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx index adcf12e0..b3ac13e0 100644 --- a/apps/mobile/components/bookmarks/BookmarkList.tsx +++ b/apps/mobile/components/bookmarks/BookmarkList.tsx @@ -30,6 +30,7 @@ export default function BookmarkList({ <Animated.FlatList ref={flatListRef} itemLayoutAnimation={LinearTransition} + contentInsetAdjustmentBehavior="automatic" ListHeaderComponent={header} contentContainerStyle={{ gap: 15, diff --git a/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx b/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx index e627ee16..25be7c2d 100644 --- a/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx +++ b/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx @@ -1,6 +1,7 @@ -import { api } from "@/lib/trpc"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import type { ZGetBookmarksRequest } from "@karakeep/shared/types/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import FullPageError from "../FullPageError"; @@ -14,7 +15,8 @@ export default function UpdatingBookmarkList({ query: Omit<ZGetBookmarksRequest, "sortOrder" | "includeContent">; // Sort order is not supported in mobile yet header?: React.ReactElement; }) { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); const { data, isPending, @@ -23,12 +25,14 @@ export default function UpdatingBookmarkList({ fetchNextPage, isFetchingNextPage, refetch, - } = api.bookmarks.getBookmarks.useInfiniteQuery( - { ...query, useCursorV2: true, includeContent: false }, - { - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.bookmarks.getBookmarks.infiniteQueryOptions( + { ...query, useCursorV2: true, includeContent: false }, + { + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); if (error) { @@ -40,8 +44,8 @@ export default function UpdatingBookmarkList({ } const onRefresh = () => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate(); + queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter()); + queryClient.invalidateQueries(api.bookmarks.getBookmark.pathFilter()); }; return ( diff --git a/apps/mobile/components/highlights/HighlightCard.tsx b/apps/mobile/components/highlights/HighlightCard.tsx index 7e0b4a2b..ec4278c5 100644 --- a/apps/mobile/components/highlights/HighlightCard.tsx +++ b/apps/mobile/components/highlights/HighlightCard.tsx @@ -2,18 +2,16 @@ import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import * as Haptics from "expo-haptics"; import { useRouter } from "expo-router"; import { Text } from "@/components/ui/Text"; -import { api } from "@/lib/trpc"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; import { ExternalLink, Trash2 } from "lucide-react-native"; import type { ZHighlight } from "@karakeep/shared/types/highlights"; import { useDeleteHighlight } from "@karakeep/shared-react/hooks/highlights"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { useToast } from "../ui/Toast"; -dayjs.extend(relativeTime); - // Color map for highlights (mapped to Tailwind CSS classes used in NativeWind) const HIGHLIGHT_COLOR_MAP = { red: "#fecaca", // bg-red-200 @@ -29,6 +27,7 @@ export default function HighlightCard({ }) { const { toast } = useToast(); const router = useRouter(); + const api = useTRPC(); const onError = () => { toast({ @@ -64,13 +63,15 @@ export default function HighlightCard({ ], ); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: highlight.bookmarkId, - }, - { - retry: false, - }, + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: highlight.bookmarkId, + }, + { + retry: false, + }, + ), ); const handleBookmarkPress = () => { @@ -79,7 +80,10 @@ export default function HighlightCard({ }; return ( - <View className="overflow-hidden rounded-xl bg-card p-4"> + <View + className="overflow-hidden rounded-xl bg-card p-4" + style={{ borderCurve: "continuous" }} + > <View className="flex gap-3"> {/* Highlight text with colored border */} <View @@ -104,7 +108,7 @@ export default function HighlightCard({ <View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center gap-2"> <Text className="text-xs text-muted-foreground"> - {dayjs(highlight.createdAt).fromNow()} + {formatDistanceToNow(highlight.createdAt, { addSuffix: true })} </Text> {bookmark && ( <> diff --git a/apps/mobile/components/highlights/HighlightList.tsx b/apps/mobile/components/highlights/HighlightList.tsx index 865add2a..7d7bb1d4 100644 --- a/apps/mobile/components/highlights/HighlightList.tsx +++ b/apps/mobile/components/highlights/HighlightList.tsx @@ -30,6 +30,7 @@ export default function HighlightList({ <Animated.FlatList ref={flatListRef} itemLayoutAnimation={LinearTransition} + contentInsetAdjustmentBehavior="automatic" ListHeaderComponent={header} contentContainerStyle={{ gap: 15, diff --git a/apps/mobile/components/navigation/stack.tsx b/apps/mobile/components/navigation/stack.tsx index f53b3652..145c591f 100644 --- a/apps/mobile/components/navigation/stack.tsx +++ b/apps/mobile/components/navigation/stack.tsx @@ -1,4 +1,4 @@ -import { TextStyle, ViewStyle } from "react-native"; +import { Platform, TextStyle, ViewStyle } from "react-native"; import { Stack } from "expo-router/stack"; import { cssInterop } from "nativewind"; @@ -14,7 +14,10 @@ function StackImpl({ contentStyle, headerStyle, ...props }: StackProps) { headerStyle: { backgroundColor: headerStyle?.backgroundColor?.toString(), }, - navigationBarColor: contentStyle?.backgroundColor?.toString(), + navigationBarColor: + Platform.OS === "android" + ? undefined + : contentStyle?.backgroundColor?.toString(), headerTintColor: headerStyle?.color?.toString(), }; return <Stack {...props} />; diff --git a/apps/mobile/components/navigation/tabs.tsx b/apps/mobile/components/navigation/tabs.tsx deleted file mode 100644 index 83b1c6a7..00000000 --- a/apps/mobile/components/navigation/tabs.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ViewStyle } from "react-native"; -import { Tabs } from "expo-router"; -import { cssInterop } from "nativewind"; - -function StyledTabsImpl({ - tabBarStyle, - headerStyle, - sceneStyle, - ...props -}: React.ComponentProps<typeof Tabs> & { - tabBarStyle?: ViewStyle; - headerStyle?: ViewStyle; - sceneStyle?: ViewStyle; -}) { - props.screenOptions = { - ...props.screenOptions, - tabBarStyle, - headerStyle, - sceneStyle, - }; - return <Tabs {...props} />; -} - -export const StyledTabs = cssInterop(StyledTabsImpl, { - tabBarClassName: "tabBarStyle", - headerClassName: "headerStyle", - sceneClassName: "sceneStyle", -}); diff --git a/apps/mobile/components/reader/ReaderPreview.tsx b/apps/mobile/components/reader/ReaderPreview.tsx new file mode 100644 index 00000000..c091bdbc --- /dev/null +++ b/apps/mobile/components/reader/ReaderPreview.tsx @@ -0,0 +1,117 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { View } from "react-native"; +import WebView from "react-native-webview"; +import { WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; + +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +const PREVIEW_TEXT = + "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump!"; + +export interface ReaderPreviewRef { + updateStyles: ( + fontFamily: ZReaderFontFamily, + fontSize: number, + lineHeight: number, + ) => void; +} + +interface ReaderPreviewProps { + initialFontFamily: ZReaderFontFamily; + initialFontSize: number; + initialLineHeight: number; +} + +export const ReaderPreview = forwardRef<ReaderPreviewRef, ReaderPreviewProps>( + ({ initialFontFamily, initialFontSize, initialLineHeight }, ref) => { + const webViewRef = useRef<WebView>(null); + const { isDarkColorScheme: isDark } = useColorScheme(); + + const fontFamily = WEBVIEW_FONT_FAMILIES[initialFontFamily]; + const textColor = isDark ? "#e5e7eb" : "#374151"; + const bgColor = isDark ? "#000000" : "#ffffff"; + + useImperativeHandle(ref, () => ({ + updateStyles: ( + newFontFamily: ZReaderFontFamily, + newFontSize: number, + newLineHeight: number, + ) => { + const cssFontFamily = WEBVIEW_FONT_FAMILIES[newFontFamily]; + webViewRef.current?.injectJavaScript(` + window.updateStyles("${cssFontFamily}", ${newFontSize}, ${newLineHeight}); + true; + `); + }, + })); + + // Update colors when theme changes + useEffect(() => { + webViewRef.current?.injectJavaScript(` + document.body.style.color = "${textColor}"; + document.body.style.background = "${bgColor}"; + true; + `); + }, [isDark, textColor, bgColor]); + + const html = ` + <!DOCTYPE html> + <html> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + html, body { + height: 100%; + overflow: hidden; + } + body { + font-family: ${fontFamily}; + font-size: ${initialFontSize}px; + line-height: ${initialLineHeight}; + color: ${textColor}; + background: ${bgColor}; + padding: 16px; + word-wrap: break-word; + overflow-wrap: break-word; + } + </style> + <script> + window.updateStyles = function(fontFamily, fontSize, lineHeight) { + document.body.style.fontFamily = fontFamily; + document.body.style.fontSize = fontSize + 'px'; + document.body.style.lineHeight = lineHeight; + }; + </script> + </head> + <body> + ${PREVIEW_TEXT} + </body> + </html> + `; + + return ( + <View className="h-32 w-full overflow-hidden rounded-lg"> + <WebView + ref={webViewRef} + originWhitelist={["*"]} + source={{ html }} + style={{ + flex: 1, + backgroundColor: bgColor, + }} + scrollEnabled={false} + showsVerticalScrollIndicator={false} + showsHorizontalScrollIndicator={false} + /> + </View> + ); + }, +); + +ReaderPreview.displayName = "ReaderPreview"; diff --git a/apps/mobile/components/settings/UserProfileHeader.tsx b/apps/mobile/components/settings/UserProfileHeader.tsx new file mode 100644 index 00000000..6e389877 --- /dev/null +++ b/apps/mobile/components/settings/UserProfileHeader.tsx @@ -0,0 +1,27 @@ +import { View } from "react-native"; +import { Avatar } from "@/components/ui/Avatar"; +import { Text } from "@/components/ui/Text"; + +interface UserProfileHeaderProps { + image?: string | null; + name?: string | null; + email?: string | null; +} + +export function UserProfileHeader({ + image, + name, + email, +}: UserProfileHeaderProps) { + return ( + <View className="w-full items-center gap-2 py-6"> + <Avatar image={image} name={name} size={88} /> + <View className="items-center gap-1"> + <Text className="text-xl font-semibold">{name || "User"}</Text> + {email && ( + <Text className="text-sm text-muted-foreground">{email}</Text> + )} + </View> + </View> + ); +} diff --git a/apps/mobile/components/sharing/ErrorAnimation.tsx b/apps/mobile/components/sharing/ErrorAnimation.tsx new file mode 100644 index 00000000..c5cc743a --- /dev/null +++ b/apps/mobile/components/sharing/ErrorAnimation.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, + withTiming, +} from "react-native-reanimated"; +import * as Haptics from "expo-haptics"; +import { AlertCircle } from "lucide-react-native"; + +export default function ErrorAnimation() { + const scale = useSharedValue(0); + const shake = useSharedValue(0); + + useEffect(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + + scale.value = withSpring(1, { damping: 12, stiffness: 200 }); + shake.value = withSequence( + withTiming(-10, { duration: 50 }), + withTiming(10, { duration: 100 }), + withTiming(-10, { duration: 100 }), + withTiming(10, { duration: 100 }), + withTiming(0, { duration: 50 }), + ); + }, []); + + const style = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }, { translateX: shake.value }], + })); + + return ( + <Animated.View style={style} className="items-center gap-4"> + <View className="h-24 w-24 items-center justify-center rounded-full bg-destructive"> + <AlertCircle size={48} color="white" strokeWidth={2} /> + </View> + </Animated.View> + ); +} diff --git a/apps/mobile/components/sharing/LoadingAnimation.tsx b/apps/mobile/components/sharing/LoadingAnimation.tsx new file mode 100644 index 00000000..a8838915 --- /dev/null +++ b/apps/mobile/components/sharing/LoadingAnimation.tsx @@ -0,0 +1,120 @@ +import { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + Easing, + FadeIn, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/ui/Text"; +import { Archive } from "lucide-react-native"; + +export default function LoadingAnimation() { + const scale = useSharedValue(1); + const rotation = useSharedValue(0); + const opacity = useSharedValue(0.6); + const dotOpacity1 = useSharedValue(0); + const dotOpacity2 = useSharedValue(0); + const dotOpacity3 = useSharedValue(0); + + useEffect(() => { + scale.value = withRepeat( + withSequence( + withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }), + ), + -1, + false, + ); + + rotation.value = withRepeat( + withSequence( + withTiming(-5, { duration: 400, easing: Easing.inOut(Easing.ease) }), + withTiming(5, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 400, easing: Easing.inOut(Easing.ease) }), + ), + -1, + false, + ); + + opacity.value = withRepeat( + withSequence( + withTiming(1, { duration: 800 }), + withTiming(0.6, { duration: 800 }), + ), + -1, + false, + ); + + dotOpacity1.value = withRepeat( + withSequence( + withTiming(1, { duration: 300 }), + withDelay(900, withTiming(0, { duration: 0 })), + ), + -1, + ); + dotOpacity2.value = withDelay( + 300, + withRepeat( + withSequence( + withTiming(1, { duration: 300 }), + withDelay(600, withTiming(0, { duration: 0 })), + ), + -1, + ), + ); + dotOpacity3.value = withDelay( + 600, + withRepeat( + withSequence( + withTiming(1, { duration: 300 }), + withDelay(300, withTiming(0, { duration: 0 })), + ), + -1, + ), + ); + }, []); + + const iconStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }, { rotate: `${rotation.value}deg` }], + opacity: opacity.value, + })); + + const dot1Style = useAnimatedStyle(() => ({ opacity: dotOpacity1.value })); + const dot2Style = useAnimatedStyle(() => ({ opacity: dotOpacity2.value })); + const dot3Style = useAnimatedStyle(() => ({ opacity: dotOpacity3.value })); + + return ( + <Animated.View + entering={FadeIn.duration(300)} + className="items-center gap-6" + > + <Animated.View + style={iconStyle} + className="h-24 w-24 items-center justify-center rounded-full bg-primary/10" + > + <Archive size={48} className="text-primary" strokeWidth={1.5} /> + </Animated.View> + <View className="flex-row items-baseline"> + <Text variant="title1" className="font-semibold text-foreground"> + Hoarding + </Text> + <View className="w-8 flex-row"> + <Animated.Text style={dot1Style} className="text-xl text-foreground"> + . + </Animated.Text> + <Animated.Text style={dot2Style} className="text-xl text-foreground"> + . + </Animated.Text> + <Animated.Text style={dot3Style} className="text-xl text-foreground"> + . + </Animated.Text> + </View> + </View> + </Animated.View> + ); +} diff --git a/apps/mobile/components/sharing/SuccessAnimation.tsx b/apps/mobile/components/sharing/SuccessAnimation.tsx new file mode 100644 index 00000000..fa0aaf3a --- /dev/null +++ b/apps/mobile/components/sharing/SuccessAnimation.tsx @@ -0,0 +1,140 @@ +import { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withSequence, + withSpring, + withTiming, +} from "react-native-reanimated"; +import * as Haptics from "expo-haptics"; +import { Check } from "lucide-react-native"; + +interface ParticleProps { + angle: number; + delay: number; + color: string; +} + +function Particle({ angle, delay, color }: ParticleProps) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withDelay( + 200 + delay, + withSequence( + withTiming(1, { duration: 400, easing: Easing.out(Easing.ease) }), + withTiming(0, { duration: 300 }), + ), + ); + }, []); + + const particleStyle = useAnimatedStyle(() => { + const distance = interpolate(progress.value, [0, 1], [0, 60]); + const opacity = interpolate(progress.value, [0, 0.5, 1], [0, 1, 0]); + const scale = interpolate(progress.value, [0, 0.5, 1], [0, 1, 0]); + const angleRad = (angle * Math.PI) / 180; + + return { + position: "absolute" as const, + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: color, + opacity, + transform: [ + { translateX: Math.cos(angleRad) * distance }, + { translateY: Math.sin(angleRad) * distance }, + { scale }, + ], + }; + }); + + return <Animated.View style={particleStyle} />; +} + +interface SuccessAnimationProps { + isAlreadyExists: boolean; +} + +export default function SuccessAnimation({ + isAlreadyExists, +}: SuccessAnimationProps) { + const checkScale = useSharedValue(0); + const checkOpacity = useSharedValue(0); + const ringScale = useSharedValue(0.8); + const ringOpacity = useSharedValue(0); + + const particleColor = isAlreadyExists + ? "rgb(255, 180, 0)" + : "rgb(0, 200, 100)"; + + useEffect(() => { + Haptics.notificationAsync( + isAlreadyExists + ? Haptics.NotificationFeedbackType.Warning + : Haptics.NotificationFeedbackType.Success, + ); + + ringScale.value = withSequence( + withTiming(1.2, { duration: 400, easing: Easing.out(Easing.ease) }), + withTiming(1, { duration: 200 }), + ); + ringOpacity.value = withSequence( + withTiming(1, { duration: 200 }), + withDelay(300, withTiming(0.3, { duration: 300 })), + ); + + checkScale.value = withDelay( + 150, + withSpring(1, { + damping: 12, + stiffness: 200, + mass: 0.8, + }), + ); + checkOpacity.value = withDelay(150, withTiming(1, { duration: 200 })); + }, [isAlreadyExists]); + + const ringStyle = useAnimatedStyle(() => ({ + transform: [{ scale: ringScale.value }], + opacity: ringOpacity.value, + })); + + const checkStyle = useAnimatedStyle(() => ({ + transform: [{ scale: checkScale.value }], + opacity: checkOpacity.value, + })); + + return ( + <View className="items-center justify-center"> + {Array.from({ length: 8 }, (_, i) => ( + <Particle + key={i} + angle={(i * 360) / 8} + delay={i * 50} + color={particleColor} + /> + ))} + + <Animated.View + style={ringStyle} + className={`absolute h-28 w-28 rounded-full ${ + isAlreadyExists ? "bg-yellow-500/20" : "bg-green-500/20" + }`} + /> + + <Animated.View + style={checkStyle} + className={`h-24 w-24 items-center justify-center rounded-full ${ + isAlreadyExists ? "bg-yellow-500" : "bg-green-500" + }`} + > + <Check size={48} color="white" strokeWidth={3} /> + </Animated.View> + </View> + ); +} diff --git a/apps/mobile/components/ui/Avatar.tsx b/apps/mobile/components/ui/Avatar.tsx new file mode 100644 index 00000000..239eaba8 --- /dev/null +++ b/apps/mobile/components/ui/Avatar.tsx @@ -0,0 +1,112 @@ +import * as React from "react"; +import { View } from "react-native"; +import { Image } from "expo-image"; +import { Text } from "@/components/ui/Text"; +import { useAssetUrl } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; + +interface AvatarProps { + image?: string | null; + name?: string | null; + size?: number; + className?: string; + fallbackClassName?: string; +} + +const AVATAR_COLORS = [ + "#f87171", // red-400 + "#fb923c", // orange-400 + "#fbbf24", // amber-400 + "#a3e635", // lime-400 + "#34d399", // emerald-400 + "#22d3ee", // cyan-400 + "#60a5fa", // blue-400 + "#818cf8", // indigo-400 + "#a78bfa", // violet-400 + "#e879f9", // fuchsia-400 +]; + +function nameToColor(name: string | null | undefined): string { + if (!name) return AVATAR_COLORS[0]; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} + +function isExternalUrl(url: string) { + return url.startsWith("http://") || url.startsWith("https://"); +} + +export function Avatar({ + image, + name, + size = 40, + className, + fallbackClassName, +}: AvatarProps) { + const [imageError, setImageError] = React.useState(false); + const assetUrl = useAssetUrl(image ?? ""); + + const imageUrl = React.useMemo(() => { + if (!image) return null; + return isExternalUrl(image) + ? { + uri: image, + } + : assetUrl; + }, [image]); + + React.useEffect(() => { + setImageError(false); + }, [image]); + + const initials = React.useMemo(() => { + if (!name) return "U"; + return name.charAt(0).toUpperCase(); + }, [name]); + + const showFallback = !imageUrl || imageError; + const avatarColor = nameToColor(name); + + return ( + <View + className={cn("overflow-hidden", className)} + style={{ + width: size, + height: size, + borderRadius: size / 2, + backgroundColor: showFallback ? avatarColor : undefined, + }} + > + {showFallback ? ( + <View + className={cn( + "flex h-full w-full items-center justify-center", + fallbackClassName, + )} + style={{ backgroundColor: avatarColor }} + > + <Text + className="text-white" + style={{ + fontSize: size * 0.4, + lineHeight: size * 0.4, + textAlign: "center", + }} + > + {initials} + </Text> + </View> + ) : ( + <Image + source={imageUrl} + style={{ width: "100%", height: "100%" }} + contentFit="cover" + onError={() => setImageError(true)} + /> + )} + </View> + ); +} diff --git a/apps/mobile/components/ui/CustomSafeAreaView.tsx b/apps/mobile/components/ui/CustomSafeAreaView.tsx index fdf6520d..8e7755c2 100644 --- a/apps/mobile/components/ui/CustomSafeAreaView.tsx +++ b/apps/mobile/components/ui/CustomSafeAreaView.tsx @@ -1,5 +1,5 @@ -import { Platform, SafeAreaView } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useColorScheme } from "@/lib/useColorScheme"; import { useHeaderHeight } from "@react-navigation/elements"; export default function CustomSafeAreaView({ @@ -9,20 +9,19 @@ export default function CustomSafeAreaView({ children: React.ReactNode; edges?: ("top" | "bottom")[]; }) { - const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); + const { colors } = useColorScheme(); return ( <SafeAreaView style={{ - paddingTop: - // Some ugly hacks to make the app look the same on both android and ios - Platform.OS == "android" && edges.includes("top") - ? headerHeight > 0 - ? headerHeight - : insets.top - : undefined, - paddingBottom: edges.includes("bottom") ? insets.bottom : undefined, + flex: 1, + backgroundColor: colors.background, + paddingTop: edges.includes("top") + ? headerHeight > 0 + ? headerHeight + : undefined + : undefined, }} > {children} diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx deleted file mode 100644 index 52ff5779..00000000 --- a/apps/mobile/components/ui/List.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import type { - FlashListProps, - ListRenderItem as FlashListRenderItem, - ListRenderItemInfo, -} from "@shopify/flash-list"; -import * as React from "react"; -import { - Platform, - PressableProps, - StyleProp, - TextStyle, - View, - ViewProps, - ViewStyle, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Button } from "@/components/ui/Button"; -import { Text, TextClassContext } from "@/components/ui/Text"; -import { cn } from "@/lib/utils"; -import { FlashList } from "@shopify/flash-list"; -import { cva } from "class-variance-authority"; -import { cssInterop } from "nativewind"; - -cssInterop(FlashList, { - className: "style", - contentContainerClassName: "contentContainerStyle", -}); - -type ListDataItem = string | { title: string; subTitle?: string }; -type ListVariant = "insets" | "full-width"; - -type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>; - -type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & { - variant?: ListVariant; - isFirstInSection?: boolean; - isLastInSection?: boolean; - sectionHeaderAsGap?: boolean; -}; - -type ListProps<T extends ListDataItem> = Omit< - FlashListProps<T>, - "renderItem" -> & { - renderItem?: ListRenderItem<T>; - variant?: ListVariant; - sectionHeaderAsGap?: boolean; - rootClassName?: string; - rootStyle?: StyleProp<ViewStyle>; -}; -type ListRenderItem<T extends ListDataItem> = ( - props: ListRenderItemProps<T>, -) => ReturnType<FlashListRenderItem<T>>; - -const rootVariants = cva("min-h-2 flex-1", { - variants: { - variant: { - insets: "ios:px-4", - "full-width": "ios:bg-card ios:dark:bg-background", - }, - sectionHeaderAsGap: { - true: "", - false: "", - }, - }, - compoundVariants: [ - { - variant: "full-width", - sectionHeaderAsGap: true, - className: "bg-card dark:bg-background", - }, - ], - defaultVariants: { - variant: "full-width", - sectionHeaderAsGap: false, - }, -}); - -function ListComponent<T extends ListDataItem>({ - variant = "full-width", - rootClassName, - rootStyle, - contentContainerClassName, - renderItem, - data, - sectionHeaderAsGap = false, - contentInsetAdjustmentBehavior = "automatic", - ...props -}: ListProps<T>) { - const insets = useSafeAreaInsets(); - return ( - <View - className={cn( - rootVariants({ - variant, - sectionHeaderAsGap, - }), - rootClassName, - )} - style={rootStyle} - > - <FlashList - data={data} - contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} - renderItem={renderItemWithVariant( - renderItem, - variant, - data, - sectionHeaderAsGap, - )} - contentContainerClassName={cn( - variant === "insets" && - (!data || (typeof data?.[0] !== "string" && "pt-4")), - contentContainerClassName, - )} - contentContainerStyle={{ - paddingBottom: Platform.select({ - ios: - !contentInsetAdjustmentBehavior || - contentInsetAdjustmentBehavior === "never" - ? insets.bottom + 16 - : 0, - default: insets.bottom, - }), - }} - getItemType={getItemType} - showsVerticalScrollIndicator={false} - {...props} - /> - </View> - ); -} - -function getItemType<T>(item: T) { - return typeof item === "string" ? "sectioHeader" : "row"; -} - -function renderItemWithVariant<T extends ListDataItem>( - renderItem: ListRenderItem<T> | null | undefined, - variant: ListVariant, - data: readonly T[] | null | undefined, - sectionHeaderAsGap?: boolean, -) { - return (args: ListRenderItemProps<T>) => { - const previousItem = data?.[args.index - 1]; - const nextItem = data?.[args.index + 1]; - return renderItem - ? renderItem({ - ...args, - variant, - isFirstInSection: !previousItem || typeof previousItem === "string", - isLastInSection: !nextItem || typeof nextItem === "string", - sectionHeaderAsGap, - }) - : null; - }; -} - -const List = React.forwardRef(ListComponent) as <T extends ListDataItem>( - props: ListProps<T> & { ref?: ListRef<T> }, -) => React.ReactElement; - -function isPressable(props: PressableProps) { - return ( - ("onPress" in props && props.onPress) || - ("onLongPress" in props && props.onLongPress) || - ("onPressIn" in props && props.onPressIn) || - ("onPressOut" in props && props.onPressOut) || - ("onLongPress" in props && props.onLongPress) - ); -} - -type ListItemProps<T extends ListDataItem> = PressableProps & - ListRenderItemProps<T> & { - androidRootClassName?: string; - titleClassName?: string; - titleStyle?: StyleProp<TextStyle>; - textNumberOfLines?: number; - subTitleClassName?: string; - subTitleStyle?: StyleProp<TextStyle>; - subTitleNumberOfLines?: number; - textContentClassName?: string; - leftView?: React.ReactNode; - rightView?: React.ReactNode; - removeSeparator?: boolean; - }; -type ListItemRef = React.Ref<View>; - -const itemVariants = cva("ios:gap-0 flex-row gap-0 bg-card", { - variants: { - variant: { - insets: "ios:bg-card bg-card/70", - "full-width": "bg-card dark:bg-background", - }, - sectionHeaderAsGap: { - true: "", - false: "", - }, - isFirstItem: { - true: "", - false: "", - }, - isFirstInSection: { - true: "", - false: "", - }, - removeSeparator: { - true: "", - false: "", - }, - isLastInSection: { - true: "", - false: "", - }, - disabled: { - true: "opacity-70", - false: "opacity-100", - }, - }, - compoundVariants: [ - { - variant: "insets", - sectionHeaderAsGap: true, - className: "ios:dark:bg-card dark:bg-card/70", - }, - { - variant: "insets", - isFirstInSection: true, - className: "ios:rounded-t-[10px]", - }, - { - variant: "insets", - isLastInSection: true, - className: "ios:rounded-b-[10px]", - }, - { - removeSeparator: false, - isLastInSection: true, - className: - "ios:border-b-0 border-b border-border/25 dark:border-border/80", - }, - { - variant: "insets", - isFirstItem: true, - className: "border-t border-border/40", - }, - ], - defaultVariants: { - variant: "insets", - sectionHeaderAsGap: false, - isFirstInSection: false, - isLastInSection: false, - disabled: false, - }, -}); - -function ListItemComponent<T extends ListDataItem>( - { - item, - isFirstInSection, - isLastInSection, - index: _index, - variant, - className, - androidRootClassName, - titleClassName, - titleStyle, - textNumberOfLines, - subTitleStyle, - subTitleClassName, - subTitleNumberOfLines, - textContentClassName, - sectionHeaderAsGap, - removeSeparator = false, - leftView, - rightView, - disabled, - ...props - }: ListItemProps<T>, - ref: ListItemRef, -) { - if (typeof item === "string") { - console.log( - "List.tsx", - "ListItemComponent", - "Invalid item of type 'string' was provided. Use ListSectionHeader instead.", - ); - return null; - } - return ( - <> - <Button - disabled={disabled || !isPressable(props)} - variant="plain" - size="none" - unstable_pressDelay={100} - androidRootClassName={androidRootClassName} - className={itemVariants({ - variant, - sectionHeaderAsGap, - isFirstInSection, - isLastInSection, - disabled, - className, - removeSeparator, - })} - {...props} - ref={ref} - > - <TextClassContext.Provider value="font-normal leading-5"> - {!!leftView && <View>{leftView}</View>} - <View - className={cn( - "h-full flex-1 flex-row", - !item.subTitle ? "ios:py-3 py-[18px]" : "ios:py-2 py-2", - !leftView && "ml-4", - !rightView && "pr-4", - !removeSeparator && - (!isLastInSection || variant === "full-width") && - "ios:border-b ios:border-border/80", - !removeSeparator && - isFirstInSection && - variant === "full-width" && - "ios:border-t ios:border-border/80", - )} - > - <View className={cn("flex-1", textContentClassName)}> - <Text - numberOfLines={textNumberOfLines} - style={titleStyle} - className={titleClassName} - > - {item.title} - </Text> - {!!item.subTitle && ( - <Text - numberOfLines={subTitleNumberOfLines} - variant="subhead" - style={subTitleStyle} - className={cn("text-muted-foreground", subTitleClassName)} - > - {item.subTitle} - </Text> - )} - </View> - {!!rightView && <View>{rightView}</View>} - </View> - </TextClassContext.Provider> - </Button> - {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && ( - <View className={cn(variant === "insets" && "px-4")}> - <View className="h-px bg-border/25 dark:bg-border/80" /> - </View> - )} - </> - ); -} - -const ListItem = React.forwardRef(ListItemComponent) as < - T extends ListDataItem, ->( - props: ListItemProps<T> & { ref?: ListItemRef }, -) => React.ReactElement; - -type ListSectionHeaderProps<T extends ListDataItem> = ViewProps & - ListRenderItemProps<T> & { - textClassName?: string; - }; -type ListSectionHeaderRef = React.Ref<View>; - -function ListSectionHeaderComponent<T extends ListDataItem>( - { - item, - isFirstInSection: _isFirstInSection, - isLastInSection: _isLastInSection, - index: _index, - variant, - className, - textClassName, - sectionHeaderAsGap, - ...props - }: ListSectionHeaderProps<T>, - ref: ListSectionHeaderRef, -) { - if (typeof item !== "string") { - console.log( - "List.tsx", - "ListSectionHeaderComponent", - "Invalid item provided. Expected type 'string'. Use ListItem instead.", - ); - return null; - } - - if (sectionHeaderAsGap) { - return ( - <View - className={cn( - "bg-background", - Platform.OS !== "ios" && - "border-b border-border/25 dark:border-border/80", - className, - )} - {...props} - ref={ref} - > - <View className="h-8" /> - </View> - ); - } - return ( - <View - className={cn( - "ios:pb-1 pb-4 pl-4 pt-4", - Platform.OS !== "ios" && - "border-b border-border/25 dark:border-border/80", - variant === "full-width" - ? "bg-card dark:bg-background" - : "bg-background", - className, - )} - {...props} - ref={ref} - > - <Text - variant={Platform.select({ ios: "footnote", default: "body" })} - className={cn("ios:uppercase ios:text-muted-foreground", textClassName)} - > - {item} - </Text> - </View> - ); -} - -const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as < - T extends ListDataItem, ->( - props: ListSectionHeaderProps<T> & { ref?: ListSectionHeaderRef }, -) => React.ReactElement; - -const ESTIMATED_ITEM_HEIGHT = { - titleOnly: Platform.select({ ios: 45, default: 57 }), - withSubTitle: 56, -}; - -function getStickyHeaderIndices<T extends ListDataItem>(data: T[]) { - if (!data) return []; - const indices: number[] = []; - for (let i = 0; i < data.length; i++) { - if (typeof data[i] === "string") { - indices.push(i); - } - } - return indices; -} - -export { - ESTIMATED_ITEM_HEIGHT, - List, - ListItem, - ListSectionHeader, - getStickyHeaderIndices, -}; -export type { - ListDataItem, - ListItemProps, - ListProps, - ListRenderItemInfo, - ListSectionHeaderProps, -}; diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx index 0b1dd76c..1a767675 100644 --- a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx +++ b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx @@ -1,7 +1,3 @@ -import type { - NativeSyntheticEvent, - TextInputFocusEventData, -} from "react-native"; import * as React from "react"; import { Pressable, TextInput, View, ViewStyle } from "react-native"; import Animated, { @@ -119,7 +115,7 @@ const SearchInput = React.forwardRef< onChangeText(""); } - function onFocus(e: NativeSyntheticEvent<TextInputFocusEventData>) { + function onFocus(e: Parameters<NonNullable<typeof onFocusProp>>[0]) { setShowCancel(true); onFocusProp?.(e); } diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx index fd122c25..722c93ab 100644 --- a/apps/mobile/components/ui/Toast.tsx +++ b/apps/mobile/components/ui/Toast.tsx @@ -1,7 +1,4 @@ -import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { Animated, View } from "react-native"; -import { Text } from "@/components/ui/Text"; -import { cn } from "@/lib/utils"; +import { toast as sonnerToast } from "sonner-native"; const toastVariants = { default: "bg-foreground", @@ -10,174 +7,41 @@ const toastVariants = { 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 transition-all - `} - style={{ - opacity, - transform: [ - { - translateY: opacity.interpolate({ - inputRange: [0, 1], - outputRange: [-20, 0], - }), - }, - ], - }} - > - <Text className="text-left font-semibold text-background">{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> - ); -} - +// Compatibility wrapper for sonner-native function useToast() { - const context = useContext(ToastContext); - if (!context) { - throw new Error("useToast must be used within ToastProvider"); - } - return context; + return { + toast: ({ + message, + variant = "default", + duration = 3000, + }: { + message: string; + variant?: ToastVariant; + duration?: number; + position?: "top" | "bottom"; + showProgress?: boolean; + }) => { + // Map variants to sonner-native methods + switch (variant) { + case "success": + sonnerToast.success(message, { duration }); + break; + case "destructive": + sonnerToast.error(message, { duration }); + break; + case "info": + sonnerToast.info(message, { duration }); + break; + default: + sonnerToast(message, { duration }); + } + }, + removeToast: () => { + // sonner-native handles dismissal automatically + }, + }; } -export { ToastProvider, ToastVariant, Toast, toastVariants, useToast }; +export { ToastVariant, toastVariants, useToast }; diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css index 992b92cd..82fa9eab 100644 --- a/apps/mobile/globals.css +++ b/apps/mobile/globals.css @@ -23,46 +23,6 @@ --border: 230 230 235; --input: 210 210 215; --ring: 230 230 235; - - --android-background: 250 252 255; - --android-foreground: 27 28 29; - --android-card: 255 255 255; - --android-card-foreground: 24 28 35; - --android-popover: 215 217 228; - --android-popover-foreground: 0 0 0; - --android-primary: 0 112 233; - --android-primary-foreground: 255 255 255; - --android-secondary: 176 201 255; - --android-secondary-foreground: 28 60 114; - --android-muted: 176 176 181; - --android-muted-foreground: 102 102 102; - --android-accent: 169 73 204; - --android-accent-foreground: 255 255 255; - --android-destructive: 186 26 26; - --android-destructive-foreground: 255 255 255; - --android-border: 118 122 127; - --android-input: 197 201 206; - --android-ring: 118 122 127; - - --web-background: 250 252 255; - --web-foreground: 27 28 29; - --web-card: 255 255 255; - --web-card-foreground: 24 28 35; - --web-popover: 215 217 228; - --web-popover-foreground: 0 0 0; - --web-primary: 0 112 233; - --web-primary-foreground: 255 255 255; - --web-secondary: 176 201 255; - --web-secondary-foreground: 28 60 114; - --web-muted: 216 226 255; - --web-muted-foreground: 0 26 65; - --web-accent: 169 73 204; - --web-accent-foreground: 255 255 255; - --web-destructive: 186 26 26; - --web-destructive-foreground: 255 255 255; - --web-border: 118 122 127; - --web-input: 197 201 206; - --web-ring: 118 122 127; } @media (prefers-color-scheme: dark) { @@ -86,46 +46,6 @@ --border: 40 40 40; --input: 51 51 51; --ring: 40 40 40; - - --android-background: 24 28 32; - --android-foreground: 221 227 233; - --android-card: 36 40 44; - --android-card-foreground: 197 201 206; - --android-popover: 70 74 78; - --android-popover-foreground: 197 201 206; - --android-primary: 0 69 148; - --android-primary-foreground: 214 224 255; - --android-secondary: 28 60 114; - --android-secondary-foreground: 255 255 255; - --android-muted: 112 112 115; - --android-muted-foreground: 226 226 231; - --android-accent: 83 0 111; - --android-accent-foreground: 255 255 255; - --android-destructive: 147 0 10; - --android-destructive-foreground: 255 255 255; - --android-border: 143 148 153; - --android-input: 70 74 78; - --android-ring: 143 148 153; - - --web-background: 24 28 32; - --web-foreground: 221 227 233; - --web-card: 70 74 78; - --web-card-foreground: 197 201 206; - --web-popover: 70 74 78; - --web-popover-foreground: 197 201 206; - --web-primary: 0 69 148; - --web-primary-foreground: 214 224 255; - --web-secondary: 28 60 114; - --web-secondary-foreground: 255 255 255; - --web-muted: 29 27 29; - --web-muted-foreground: 230 224 228; - --web-accent: 83 0 111; - --web-accent-foreground: 255 255 255; - --web-destructive: 147 0 10; - --web-destructive-foreground: 255 255 255; - --web-border: 143 148 153; - --web-input: 70 74 78; - --web-ring: 143 148 153; } } } diff --git a/apps/mobile/lib/hooks.ts b/apps/mobile/lib/hooks.ts index 38ecebea..c3cb9d22 100644 --- a/apps/mobile/lib/hooks.ts +++ b/apps/mobile/lib/hooks.ts @@ -1,12 +1,39 @@ -import { ImageURISource } from "react-native"; +import { useQuery } from "@tanstack/react-query"; import useAppSettings from "./settings"; import { buildApiHeaders } from "./utils"; -export function useAssetUrl(assetId: string): ImageURISource { +interface AssetSource { + uri: string; + headers: Record<string, string>; +} + +export function useAssetUrl(assetId: string): AssetSource { const { settings } = useAppSettings(); return { uri: `${settings.address}/api/assets/${assetId}`, headers: buildApiHeaders(settings.apiKey, settings.customHeaders), }; } + +export function useServerVersion() { + const { settings } = useAppSettings(); + + return useQuery({ + queryKey: ["serverVersion", settings.address], + queryFn: async () => { + const response = await fetch(`${settings.address}/api/version`, { + headers: buildApiHeaders(settings.apiKey, settings.customHeaders), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch server version: ${response.status}`); + } + + const data = await response.json(); + return data.version as string; + }, + enabled: !!settings.address, + staleTime: 1000 * 60 * 5, // Cache for 5 minutes + }); +} diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 938b8aeb..4a7def1d 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -1,9 +1,10 @@ import { useEffect } from "react"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { ToastProvider } from "@/components/ui/Toast"; +import { Toaster } from "sonner-native"; -import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { TRPCSettingsProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { ReaderSettingsProvider } from "./readerSettings"; import useAppSettings from "./settings"; export function Providers({ children }: { children: React.ReactNode }) { @@ -19,8 +20,11 @@ export function Providers({ children }: { children: React.ReactNode }) { } return ( - <TRPCProvider settings={settings}> - <ToastProvider>{children}</ToastProvider> - </TRPCProvider> + <TRPCSettingsProvider settings={settings}> + <ReaderSettingsProvider> + {children} + <Toaster /> + </ReaderSettingsProvider> + </TRPCSettingsProvider> ); } diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx new file mode 100644 index 00000000..9a3fc835 --- /dev/null +++ b/apps/mobile/lib/readerSettings.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useCallback } from "react"; +import { Platform } from "react-native"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { ReaderSettingsPartial } from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +import { useSettings } from "./settings"; + +// Mobile-specific font families for native Text components +// On Android, use generic font family names: "serif", "sans-serif", "monospace" +// On iOS, use specific font names like "Georgia" and "Courier" +// Note: undefined means use the system default font +export const MOBILE_FONT_FAMILIES: Record< + ZReaderFontFamily, + string | undefined +> = Platform.select({ + android: { + serif: "serif", + sans: undefined, + mono: "monospace", + }, + default: { + serif: "Georgia", + sans: undefined, + mono: "Courier", + }, +})!; + +// Font families for WebView HTML content (CSS font stacks) +export const WEBVIEW_FONT_FAMILIES: Record<ZReaderFontFamily, string> = { + serif: "Georgia, 'Times New Roman', serif", + sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace", +} as const; + +/** + * Mobile-specific provider for reader settings. + * Wraps the shared provider with mobile storage callbacks. + */ +export function ReaderSettingsProvider({ children }: { children: ReactNode }) { + // Read from zustand store directly to keep callback stable (empty deps). + const getLocalOverrides = useCallback((): ReaderSettingsPartial => { + const currentSettings = useSettings.getState().settings.settings; + return { + fontSize: currentSettings.readerFontSize, + lineHeight: currentSettings.readerLineHeight, + fontFamily: currentSettings.readerFontFamily, + }; + }, []); + + const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => { + const currentSettings = useSettings.getState().settings.settings; + // Remove reader settings keys first, then add back only defined ones + const { + readerFontSize: _fs, + readerLineHeight: _lh, + readerFontFamily: _ff, + ...rest + } = currentSettings; + + const newSettings = { ...rest }; + if (overrides.fontSize !== undefined) { + (newSettings as typeof currentSettings).readerFontSize = + overrides.fontSize; + } + if (overrides.lineHeight !== undefined) { + (newSettings as typeof currentSettings).readerLineHeight = + overrides.lineHeight; + } + if (overrides.fontFamily !== undefined) { + (newSettings as typeof currentSettings).readerFontFamily = + overrides.fontFamily; + } + + useSettings.getState().setSettings(newSettings); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + > + {children} + </BaseReaderSettingsProvider> + ); +} + +// Re-export the context hook as useReaderSettings for mobile consumers +export { useReaderSettingsContext as useReaderSettings }; diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 8eb646cb..d6470145 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -1,12 +1,17 @@ import { useCallback } from "react"; +import { useMutation } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; import useAppSettings from "./settings"; -import { api } from "./trpc"; export function useSession() { const { settings, setSettings } = useAppSettings(); + const api = useTRPC(); - const { mutate: deleteKey } = api.apiKeys.revoke.useMutation(); + const { mutate: deleteKey } = useMutation( + api.apiKeys.revoke.mutationOptions(), + ); const logout = useCallback(() => { if (settings.apiKeyId) { diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 40a33976..8da1d33d 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -1,7 +1,10 @@ +import { useEffect } from "react"; import * as SecureStore from "expo-secure-store"; import { z } from "zod"; import { create } from "zustand"; +import { zReaderFontFamilySchema } from "@karakeep/shared/types/users"; + const SETTING_NAME = "settings"; const zSettingsSchema = z.object({ @@ -16,6 +19,10 @@ const zSettingsSchema = z.object({ .default("reader"), showNotes: z.boolean().optional().default(false), customHeaders: z.record(z.string(), z.string()).optional().default({}), + // Reader settings (local device overrides) + readerFontSize: z.number().int().min(12).max(24).optional(), + readerLineHeight: z.number().min(1.2).max(2.5).optional(), + readerFontFamily: zReaderFontFamilySchema.optional(), }); export type Settings = z.infer<typeof zSettingsSchema>; @@ -71,5 +78,13 @@ const useSettings = create<AppSettingsState>((set, get) => ({ export default function useAppSettings() { const { settings, setSettings, load } = useSettings(); + useEffect(() => { + if (settings.isLoading) { + load(); + } + }, [load, settings.isLoading]); + return { ...settings, setSettings, load }; } + +export { useSettings }; diff --git a/apps/mobile/lib/trpc.ts b/apps/mobile/lib/trpc.ts deleted file mode 100644 index e56968b8..00000000 --- a/apps/mobile/lib/trpc.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; - -import type { AppRouter } from "@karakeep/trpc/routers/_app"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts index 06f007f7..2f323ddb 100644 --- a/apps/mobile/lib/upload.ts +++ b/apps/mobile/lib/upload.ts @@ -1,6 +1,7 @@ import ReactNativeBlobUtil from "react-native-blob-util"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { zUploadErrorSchema, @@ -8,7 +9,6 @@ import { } from "@karakeep/shared/types/uploads"; import type { Settings } from "./settings"; -import { api } from "./trpc"; import { buildApiHeaders } from "./utils"; export function useUploadAsset( @@ -18,13 +18,13 @@ export function useUploadAsset( onError?: (e: string) => void; }, ) { - const invalidateAllBookmarks = - api.useUtils().bookmarks.getBookmarks.invalidate; + const api = useTRPC(); + const queryClient = useQueryClient(); - const { mutate: createBookmark, isPending: isCreatingBookmark } = - api.bookmarks.createBookmark.useMutation({ + const { mutate: createBookmark, isPending: isCreatingBookmark } = useMutation( + api.bookmarks.createBookmark.mutationOptions({ onSuccess: (d) => { - invalidateAllBookmarks(); + queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter()); if (options.onSuccess) { options.onSuccess(d); } @@ -34,7 +34,8 @@ export function useUploadAsset( options.onError(e.message); } }, - }); + }), + ); const { mutate: uploadAsset, isPending: isUploading } = useMutation({ mutationFn: async (file: { type: string; name: string; uri: string }) => { diff --git a/apps/mobile/lib/useColorScheme.tsx b/apps/mobile/lib/useColorScheme.tsx index a00a445d..40e7ad53 100644 --- a/apps/mobile/lib/useColorScheme.tsx +++ b/apps/mobile/lib/useColorScheme.tsx @@ -46,13 +46,7 @@ function useInitialAndroidBarSync() { export { useColorScheme, useInitialAndroidBarSync }; function setNavigationBar(colorScheme: "light" | "dark") { - return Promise.all([ - NavigationBar.setButtonStyleAsync( - colorScheme === "dark" ? "light" : "dark", - ), - NavigationBar.setPositionAsync("absolute"), - NavigationBar.setBackgroundColorAsync( - colorScheme === "dark" ? "#00000030" : "#ffffff80", - ), - ]); + return NavigationBar.setButtonStyleAsync( + colorScheme === "dark" ? "light" : "dark", + ); } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f826300d..7f85a2f7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,52 +15,59 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@expo/metro-runtime": "~6.1.2", + "@expo/vector-icons": "^15.0.3", "@karakeep/shared": "workspace:^0.1.0", "@karakeep/shared-react": "workspace:^0.1.0", "@karakeep/trpc": "workspace:^0.1.0", - "@react-native-async-storage/async-storage": "1.23.1", - "@react-native-menu/menu": "^1.2.4", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-menu/menu": "^2.0.0", + "@react-navigation/native": "^7.1.8", "@rn-primitives/hooks": "^1.3.0", "@rn-primitives/slot": "^1.2.0", - "@shopify/flash-list": "^2.0.3", + "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "5.90.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", - "expo": "~53.0.19", - "expo-build-properties": "^0.14.6", - "expo-checkbox": "^4.1.4", - "expo-clipboard": "^7.1.4", - "expo-constants": "~17.1.6", - "expo-dev-client": "^5.2.0", - "expo-file-system": "~18.1.11", - "expo-haptics": "^14.1.4", - "expo-image": "^2.4.0", - "expo-image-picker": "^16.1.4", - "expo-linking": "~7.1.5", - "expo-navigation-bar": "^4.2.5", - "expo-router": "~5.0.7", - "expo-secure-store": "^14.2.3", - "expo-share-intent": "^4.0.0", - "expo-sharing": "~13.0.1", - "expo-status-bar": "~2.2.3", - "expo-system-ui": "^5.0.8", - "expo-web-browser": "^14.1.6", + "date-fns": "^3.6.0", + "expo": "~54.0.31", + "expo-build-properties": "~1.0.10", + "expo-checkbox": "~5.0.8", + "expo-clipboard": "~8.0.8", + "expo-constants": "~18.0.13", + "expo-dev-client": "~6.0.20", + "expo-file-system": "~19.0.21", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.10", + "expo-linking": "~8.0.11", + "expo-navigation-bar": "~5.0.10", + "expo-router": "~6.0.21", + "expo-secure-store": "~15.0.8", + "expo-share-intent": "^5.1.1", + "expo-sharing": "~14.0.8", + "expo-status-bar": "~3.0.9", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", "lucide-react-native": "^0.513.0", - "nativewind": "^4.1.23", - "react": "^19.1.0", - "react-native": "0.79.5", + "nativewind": "^4.2.1", + "react": "^19.2.1", + "react-native": "0.81.5", "react-native-awesome-slider": "^2.5.3", "react-native-blob-util": "^0.21.2", - "react-native-gesture-handler": "~2.24.0", + "react-native-css-interop": "0.2.1", + "react-native-gesture-handler": "~2.28.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "^1.18.5", "react-native-markdown-display": "^7.0.2", "react-native-pdf": "7.0.3", - "react-native-reanimated": "^3.17.5", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-svg": "^15.11.2", - "react-native-webview": "^13.13.5", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-webview": "13.15.0", + "react-native-worklets": "0.5.1", + "sonner-native": "^0.22.2", "tailwind-merge": "^2.2.1", "zod": "^3.24.2", "zustand": "^5.0.5" diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 74a9f30a..ee6214f0 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -1,4 +1,4 @@ -const { hairlineWidth, platformSelect } = require("nativewind/theme"); +const { hairlineWidth } = require("nativewind/theme"); /** @type {import('tailwindcss').Config} */ module.exports = { @@ -53,14 +53,8 @@ module.exports = { function withOpacity(variableName) { return ({ opacityValue }) => { if (opacityValue !== undefined) { - return platformSelect({ - ios: `rgb(var(--${variableName}) / ${opacityValue})`, - android: `rgb(var(--android-${variableName}) / ${opacityValue})`, - }); + return `rgb(var(--${variableName}) / ${opacityValue})`; } - return platformSelect({ - ios: `rgb(var(--${variableName}))`, - android: `rgb(var(--android-${variableName}))`, - }); + return `rgb(var(--${variableName}))`; }; } diff --git a/apps/mobile/theme/colors.ts b/apps/mobile/theme/colors.ts index 626bcb99..47c54a52 100644 --- a/apps/mobile/theme/colors.ts +++ b/apps/mobile/theme/colors.ts @@ -1,6 +1,4 @@ -import { Platform } from "react-native"; - -const IOS_SYSTEM_COLORS = { +const SYSTEM_COLORS = { white: "rgb(255, 255, 255)", black: "rgb(0, 0, 0)", light: { @@ -33,77 +31,6 @@ const IOS_SYSTEM_COLORS = { }, } as const; -const ANDROID_COLORS = { - white: "rgb(255, 255, 255)", - black: "rgb(0, 0, 0)", - light: { - grey6: "rgb(242, 242, 247)", - grey5: "rgb(230, 230, 235)", - grey4: "rgb(210, 210, 215)", - grey3: "rgb(199, 199, 204)", - grey2: "rgb(176, 176, 181)", - grey: "rgb(153, 153, 158)", - background: "rgb(250, 252, 255)", - foreground: "rgb(27, 28, 29)", - root: "rgb(250, 252, 255)", - card: "rgb(250, 252, 255)", - destructive: "rgb(186, 26, 26)", - primary: "rgb(0, 112, 233)", - }, - dark: { - grey6: "rgb(21, 21, 24)", - grey5: "rgb(40, 40, 40)", - grey4: "rgb(51, 51, 51)", - grey3: "rgb(70, 70, 70)", - grey2: "rgb(99, 99, 99)", - grey: "rgb(158, 158, 158)", - background: "rgb(24, 28, 32)", - foreground: "rgb(221, 227, 233)", - root: "rgb(24, 28, 32)", - card: "rgb(24, 28, 32)", - destructive: "rgb(147, 0, 10)", - primary: "rgb(0, 69, 148)", - }, -} as const; - -const WEB_COLORS = { - white: "rgb(255, 255, 255)", - black: "rgb(0, 0, 0)", - light: { - grey6: "rgb(250, 252, 255)", - grey5: "rgb(243, 247, 251)", - grey4: "rgb(236, 242, 248)", - grey3: "rgb(233, 239, 247)", - grey2: "rgb(229, 237, 245)", - grey: "rgb(226, 234, 243)", - background: "rgb(250, 252, 255)", - foreground: "rgb(27, 28, 29)", - root: "rgb(250, 252, 255)", - card: "rgb(250, 252, 255)", - destructive: "rgb(186, 26, 26)", - primary: "rgb(0, 112, 233)", - }, - dark: { - grey6: "rgb(25, 30, 36)", - grey5: "rgb(31, 38, 45)", - grey4: "rgb(35, 43, 52)", - grey3: "rgb(38, 48, 59)", - grey2: "rgb(40, 51, 62)", - grey: "rgb(44, 56, 68)", - background: "rgb(24, 28, 32)", - foreground: "rgb(221, 227, 233)", - root: "rgb(24, 28, 32)", - card: "rgb(24, 28, 32)", - destructive: "rgb(147, 0, 10)", - primary: "rgb(0, 69, 148)", - }, -} as const; - -const COLORS = - Platform.OS === "ios" - ? IOS_SYSTEM_COLORS - : Platform.OS === "android" - ? ANDROID_COLORS - : WEB_COLORS; +const COLORS = SYSTEM_COLORS; export { COLORS }; |
