diff options
| author | Simon Kenny <digithree@users.noreply.github.com> | 2025-06-07 21:57:48 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-07 21:57:48 +0100 |
| commit | ec31a971f6c2e3e058debf05fe8235c1d599b055 (patch) | |
| tree | cfd1b75c1581ce45592238d622e810ce7e7bbad6 /apps/mobile | |
| parent | 09e5dd659d2b42e81692cfa092ca79dfa42fd485 (diff) | |
| download | karakeep-ec31a971f6c2e3e058debf05fe8235c1d599b055.tar.zst | |
feat(mobile): add reader/screenshot/archive view to bookmark preview (#1509)
* feat(mobile): add reader view by default to bookmark detail view, retaining WebView fallback
* feat(mobile): add dark mode support for mobile reader view
* Add selectors for different views for bookmark link
---------
Co-authored-by: MohamedBassem <me@mbassem.com>
Diffstat (limited to 'apps/mobile')
| -rw-r--r-- | apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx | 117 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx | 183 |
2 files changed, 288 insertions, 12 deletions
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7edbd0b8..01ee61de 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -8,9 +8,15 @@ import { View, } from "react-native"; import ImageView from "react-native-image-viewing"; -import WebView from "react-native-webview"; +import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetImage from "@/components/bookmarks/BookmarkAssetImage"; +import { + BookmarkLinkArchivePreview, + BookmarkLinkBrowserPreview, + BookmarkLinkReaderPreview, + BookmarkLinkScreenshotPreview, +} from "@/components/bookmarks/BookmarkLinkPreview"; import BookmarkTextMarkdown from "@/components/bookmarks/BookmarkTextMarkdown"; import FullPageError from "@/components/FullPageError"; import { TailwindResolver } from "@/components/TailwindResolver"; @@ -21,7 +27,16 @@ import { Input } from "@/components/ui/Input"; import { useToast } from "@/components/ui/Toast"; import { useAssetUrl } from "@/lib/hooks"; import { api } from "@/lib/trpc"; -import { ClipboardList, Globe, Info, Tag, Trash2 } from "lucide-react-native"; +import { MenuView } from "@react-native-menu/menu"; +import { + ChevronDown, + ClipboardList, + Globe, + Info, + Tag, + Trash2, +} from "lucide-react-native"; +import { useColorScheme } from "nativewind"; import { useDeleteBookmark, @@ -29,6 +44,50 @@ import { } from "@karakeep/shared-react/hooks/bookmarks"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +type BookmarkLinkType = "browser" | "reader" | "screenshot" | "archive"; + +function BookmarkLinkTypeSelector({ + type, + onChange, +}: { + type: BookmarkLinkType; + onChange: (type: BookmarkLinkType) => void; +}) { + return ( + <MenuView + onPressAction={({ nativeEvent }) => { + Haptics.selectionAsync(); + onChange(nativeEvent.event as BookmarkLinkType); + }} + actions={[ + { + id: "reader", + title: "Reader", + state: type === "reader" ? "on" : undefined, + }, + { + id: "browser", + title: "Browser", + state: type === "browser" ? "on" : undefined, + }, + { + id: "screenshot", + title: "Screenshot", + state: type === "screenshot" ? "on" : undefined, + }, + { + id: "archive", + title: "Archive", + state: type === "archive" ? "on" : undefined, + }, + ]} + shouldOpenOnLongPress={false} + > + <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> + </MenuView> + ); +} + function BottomActions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); const router = useRouter(); @@ -151,17 +210,27 @@ function BottomActions({ bookmark }: { bookmark: ZBookmark }) { ); } -function BookmarkLinkView({ bookmark }: { bookmark: ZBookmark }) { +function BookmarkLinkView({ + bookmark, + bookmarkPreviewType, +}: { + bookmark: ZBookmark; + bookmarkPreviewType: BookmarkLinkType; +}) { if (bookmark.content.type !== BookmarkTypes.LINK) { throw new Error("Wrong content type rendered"); } - return ( - <WebView - startInLoadingState={true} - mediaPlaybackRequiresUserAction={true} - source={{ uri: bookmark.content.url }} - /> - ); + + switch (bookmarkPreviewType) { + case "browser": + return <BookmarkLinkBrowserPreview bookmark={bookmark} />; + case "reader": + return <BookmarkLinkReaderPreview bookmark={bookmark} />; + case "screenshot": + return <BookmarkLinkScreenshotPreview bookmark={bookmark} />; + case "archive": + return <BookmarkLinkArchivePreview bookmark={bookmark} />; + } } function BookmarkTextView({ bookmark }: { bookmark: ZBookmark }) { @@ -257,6 +326,11 @@ function BookmarkAssetView({ bookmark }: { bookmark: ZBookmark }) { export default function ListView() { const { slug } = useLocalSearchParams(); + const { colorScheme } = useColorScheme(); + + const [bookmarkLinkType, setBookmarkLinkType] = + useState<BookmarkLinkType>("reader"); + if (typeof slug !== "string") { throw new Error("Unexpected param type"); } @@ -265,7 +339,10 @@ export default function ListView() { data: bookmark, error, refetch, - } = api.bookmarks.getBookmark.useQuery({ bookmarkId: slug }); + } = api.bookmarks.getBookmark.useQuery({ + bookmarkId: slug, + includeContent: false, + }); if (error) { return <FullPageError error={error.message} onRetry={refetch} />; @@ -280,7 +357,12 @@ export default function ListView() { switch (bookmark.content.type) { case BookmarkTypes.LINK: title = bookmark.title ?? bookmark.content.title; - comp = <BookmarkLinkView bookmark={bookmark} />; + comp = ( + <BookmarkLinkView + bookmark={bookmark} + bookmarkPreviewType={bookmarkLinkType} + /> + ); break; case BookmarkTypes.TEXT: title = bookmark.title; @@ -298,6 +380,17 @@ export default function ListView() { headerTitle: title ?? "", headerBackTitle: "Back", headerTransparent: false, + headerTintColor: colorScheme === "dark" ? "#ffffff" : undefined, + headerStyle: { + backgroundColor: colorScheme === "dark" ? "#000000" : undefined, + }, + headerRight: () => + bookmark.content.type === BookmarkTypes.LINK ? ( + <BookmarkLinkTypeSelector + type={bookmarkLinkType} + onChange={(type) => setBookmarkLinkType(type)} + /> + ) : undefined, }} /> <View className="flex h-full"> diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx new file mode 100644 index 00000000..5e1e7aa7 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import ImageView from "react-native-image-viewing"; +import WebView from "react-native-webview"; +import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; +import { useAssetUrl } from "@/lib/hooks"; +import { api } from "@/lib/trpc"; +import { useColorScheme } from "nativewind"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import FullPageError from "../FullPageError"; +import FullPageSpinner from "../ui/FullPageSpinner"; +import BookmarkAssetImage from "./BookmarkAssetImage"; + +export function BookmarkLinkBrowserPreview({ + bookmark, +}: { + bookmark: ZBookmark; +}) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + throw new Error("Wrong content type rendered"); + } + + return ( + <WebView + startInLoadingState={true} + mediaPlaybackRequiresUserAction={true} + source={{ uri: bookmark.content.url }} + /> + ); +} + +export function BookmarkLinkReaderPreview({ + bookmark, +}: { + bookmark: ZBookmark; +}) { + const { colorScheme } = useColorScheme(); + + const { + data: bookmarkWithContent, + error, + isLoading, + refetch, + } = api.bookmarks.getBookmark.useQuery({ + bookmarkId: bookmark.id, + includeContent: true, + }); + + if (isLoading) { + return <FullPageSpinner />; + } + + if (error) { + return <FullPageError error={error.message} onRetry={refetch} />; + } + + if (bookmarkWithContent?.content.type !== BookmarkTypes.LINK) { + throw new Error("Wrong content type rendered"); + } + + const isDark = colorScheme === "dark"; + + return ( + <View className="flex-1 bg-background"> + <WebView + originWhitelist={["*"]} + source={{ + html: ` + <!DOCTYPE html> + <html> + <head> + <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; + color: ${isDark ? "#e5e7eb" : "#374151"}; + margin: 0; + padding: 16px; + background: ${isDark ? "#000000" : "#ffffff"}; + } + p { margin: 0 0 1em 0; } + h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.5em 0; line-height: 1.2; } + 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"}; + } + pre { + background: ${isDark ? "#1f2937" : "#f3f4f6"}; + padding: 1em; + border-radius: 6px; + overflow-x: auto; + } + </style> + </head> + <body> + ${bookmarkWithContent.content.htmlContent} + </body> + </html> + `, + }} + style={{ + flex: 1, + backgroundColor: isDark ? "#000000" : "#ffffff", + }} + showsVerticalScrollIndicator={false} + showsHorizontalScrollIndicator={false} + /> + </View> + ); +} + +export function BookmarkLinkArchivePreview({ + bookmark, +}: { + bookmark: ZBookmark; +}) { + const asset = + bookmark.assets.find((r) => r.assetType == "precrawledArchive") ?? + bookmark.assets.find((r) => r.assetType == "fullPageArchive"); + + const assetSource = useAssetUrl(asset?.id ?? ""); + + if (!asset) { + return ( + <View className="flex-1 bg-background">Asset has no offline archive</View> + ); + } + + const webViewUri: WebViewSourceUri = { + uri: assetSource.uri!, + headers: assetSource.headers, + }; + return ( + <WebView + startInLoadingState={true} + mediaPlaybackRequiresUserAction={true} + source={webViewUri} + /> + ); +} + +export function BookmarkLinkScreenshotPreview({ + bookmark, +}: { + bookmark: ZBookmark; +}) { + const asset = bookmark.assets.find((r) => r.assetType == "screenshot"); + + const assetSource = useAssetUrl(asset?.id ?? ""); + const [imageZoom, setImageZoom] = useState(false); + + if (!asset) { + return ( + <View className="flex-1 bg-background">Asset has no screenshot</View> + ); + } + + return ( + <View className="flex flex-1 gap-2"> + <ImageView + visible={imageZoom} + imageIndex={0} + onRequestClose={() => setImageZoom(false)} + doubleTapToZoomEnabled={true} + images={[assetSource]} + /> + <Pressable onPress={() => setImageZoom(true)}> + <BookmarkAssetImage + assetId={asset.id} + className="h-full w-full object-contain" + /> + </Pressable> + </View> + ); +} |
