aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/app/dashboard/(tabs)
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/app/dashboard/(tabs)')
-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
15 files changed, 870 insertions, 599 deletions
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>
- );
-}