aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
authorSimon Kenny <digithree@users.noreply.github.com>2025-06-07 21:57:48 +0100
committerGitHub <noreply@github.com>2025-06-07 21:57:48 +0100
commitec31a971f6c2e3e058debf05fe8235c1d599b055 (patch)
treecfd1b75c1581ce45592238d622e810ce7e7bbad6 /apps/mobile
parent09e5dd659d2b42e81692cfa092ca79dfa42fd485 (diff)
downloadkarakeep-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.tsx117
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx183
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>
+ );
+}