aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/components')
-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
23 files changed, 826 insertions, 779 deletions
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 };