diff options
Diffstat (limited to 'apps/mobile/components')
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 }; |
