aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/app.config.js6
-rw-r--r--apps/mobile/app/_layout.tsx21
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx50
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/index.tsx (renamed from apps/mobile/app/dashboard/(tabs)/index.tsx)53
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx284
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx225
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx140
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx117
-rw-r--r--apps/mobile/app/dashboard/(tabs)/highlights.tsx56
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx179
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx135
-rw-r--r--apps/mobile/app/dashboard/(tabs)/tags.tsx140
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx23
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx41
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx6
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx100
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx42
-rw-r--r--apps/mobile/app/dashboard/bookmarks/new.tsx37
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/edit.tsx156
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/index.tsx (renamed from apps/mobile/app/dashboard/lists/[slug].tsx)50
-rw-r--r--apps/mobile/app/dashboard/lists/new.tsx30
-rw-r--r--apps/mobile/app/dashboard/search.tsx33
-rw-r--r--apps/mobile/app/dashboard/settings/reader-settings.tsx301
-rw-r--r--apps/mobile/app/dashboard/tags/[slug].tsx11
-rw-r--r--apps/mobile/app/server-address.tsx231
-rw-r--r--apps/mobile/app/sharing.tsx190
-rw-r--r--apps/mobile/app/signin.tsx173
-rw-r--r--apps/mobile/app/test-connection.tsx25
-rw-r--r--apps/mobile/components/SplashScreenController.tsx14
-rw-r--r--apps/mobile/components/bookmarks/BookmarkAssetImage.tsx15
-rw-r--r--apps/mobile/components/bookmarks/BookmarkAssetView.tsx2
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx115
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx85
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx21
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkView.tsx3
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx1
-rw-r--r--apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx24
-rw-r--r--apps/mobile/components/highlights/HighlightCard.tsx32
-rw-r--r--apps/mobile/components/highlights/HighlightList.tsx1
-rw-r--r--apps/mobile/components/navigation/stack.tsx7
-rw-r--r--apps/mobile/components/navigation/tabs.tsx28
-rw-r--r--apps/mobile/components/reader/ReaderPreview.tsx117
-rw-r--r--apps/mobile/components/settings/UserProfileHeader.tsx27
-rw-r--r--apps/mobile/components/sharing/ErrorAnimation.tsx41
-rw-r--r--apps/mobile/components/sharing/LoadingAnimation.tsx120
-rw-r--r--apps/mobile/components/sharing/SuccessAnimation.tsx140
-rw-r--r--apps/mobile/components/ui/Avatar.tsx112
-rw-r--r--apps/mobile/components/ui/CustomSafeAreaView.tsx21
-rw-r--r--apps/mobile/components/ui/List.tsx469
-rw-r--r--apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx6
-rw-r--r--apps/mobile/components/ui/Toast.tsx204
-rw-r--r--apps/mobile/globals.css80
-rw-r--r--apps/mobile/lib/hooks.ts31
-rw-r--r--apps/mobile/lib/providers.tsx14
-rw-r--r--apps/mobile/lib/readerSettings.tsx93
-rw-r--r--apps/mobile/lib/session.ts9
-rw-r--r--apps/mobile/lib/settings.ts15
-rw-r--r--apps/mobile/lib/trpc.ts5
-rw-r--r--apps/mobile/lib/upload.ts17
-rw-r--r--apps/mobile/lib/useColorScheme.tsx12
-rw-r--r--apps/mobile/package.json69
-rw-r--r--apps/mobile/tailwind.config.js12
-rw-r--r--apps/mobile/theme/colors.ts77
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&apos;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 };