From 0fde10874da6b1b36317394e855dc68413268859 Mon Sep 17 00:00:00 2001 From: Code Love Date: Sat, 7 Jun 2025 20:13:29 -0400 Subject: feat(mobile): Add support for viewing PDFs (#1519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add react-native-pdf dependency Add react-native-pdf package to enable PDF viewing functionality in the mobile app. This will be used to display PDF bookmarks directly within the app. Part of #597 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat(mobile): add PDF viewer component with authentication support Add a dedicated PDFViewer component that handles downloading and displaying PDF assets with proper authentication headers. Features include: - Download progress indication - Error handling with user-friendly messages - Automatic cleanup of temporary files - Proper memory management to prevent leaks - Content type validation for PDF files Part of #597 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat(mobile): integrate PDF viewing in bookmark detail view Integrate the PDFViewer component to display PDF bookmarks inline. When a bookmark is a PDF asset, it now renders using the dedicated PDF viewer instead of treating it as an image. Also adds external PDF opening functionality with platform-specific handling for iOS and Android, proper filename handling, and temporary file cleanup. Additionally fixes dark mode styling for the navigation header to ensure proper contrast in both light and dark themes. Part of #597 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * revert the open externally button * use react query for downloading the pdf --------- Co-authored-by: Claude Co-authored-by: MohamedBassem --- .../app/dashboard/bookmarks/[slug]/index.tsx | 22 +++- apps/mobile/components/bookmarks/PDFViewer.tsx | 135 +++++++++++++++++++++ apps/mobile/package.json | 1 + pnpm-lock.yaml | 101 ++++++++++----- 4 files changed, 226 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/components/bookmarks/PDFViewer.tsx diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 01ee61de..1cf2ad3d 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -18,6 +18,7 @@ import { BookmarkLinkScreenshotPreview, } from "@/components/bookmarks/BookmarkLinkPreview"; import BookmarkTextMarkdown from "@/components/bookmarks/BookmarkTextMarkdown"; +import { PDFViewer } from "@/components/bookmarks/PDFViewer"; import FullPageError from "@/components/FullPageError"; import { TailwindResolver } from "@/components/TailwindResolver"; import { Button } from "@/components/ui/Button"; @@ -91,6 +92,7 @@ function BookmarkLinkTypeSelector({ function BottomActions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); const router = useRouter(); + const { mutate: deleteBookmark, isPending: isDeletionPending } = useDeleteBookmark({ onSuccess: () => { @@ -304,6 +306,20 @@ function BookmarkAssetView({ bookmark }: { bookmark: ZBookmark }) { throw new Error("Wrong content type rendered"); } const assetSource = useAssetUrl(bookmark.content.assetId); + + // Check if this is a PDF asset + if (bookmark.content.assetType === "pdf") { + return ( + + + + ); + } + + // Handle image assets as before return ( ("reader"); @@ -380,10 +397,11 @@ export default function ListView() { headerTitle: title ?? "", headerBackTitle: "Back", headerTransparent: false, - headerTintColor: colorScheme === "dark" ? "#ffffff" : undefined, + headerShown: true, headerStyle: { - backgroundColor: colorScheme === "dark" ? "#000000" : undefined, + backgroundColor: isDark ? "#000" : "#fff", }, + headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( ; +} + +export function PDFViewer({ source, headers }: PDFViewerProps) { + const [pdfRenderError, setPdfRenderError] = useState(null); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const colors = { + background: isDark ? "#000" : "#fff", + foreground: isDark ? "#fff" : "#000", + mutedForeground: isDark ? "#888" : "#666", + }; + + const { + data: localPath, + isLoading, + error: downloadError, + } = useQuery({ + queryKey: ["pdf", source], + queryFn: async () => { + // Create a temporary filename + const fileName = `temp_${Date.now()}.pdf`; + const { dirs } = ReactNativeBlobUtil.fs; + const path = `${dirs.DocumentDir}/${fileName}`; + + const response = await ReactNativeBlobUtil.config({ + fileCache: true, + path, + }).fetch("GET", source, headers ?? {}); + return response.path(); + }, + enabled: !!source, + }); + + // Merge download and render errors + const error = useMemo(() => { + if (downloadError) { + let errorMessage = "Failed to download PDF"; + if (downloadError.message.includes("Network request failed")) { + errorMessage = "Network error. Please check your connection."; + } else if ( + downloadError.message.includes("401") || + downloadError.message.includes("403") + ) { + errorMessage = "Authentication failed. Please sign in again."; + } else if (downloadError.message.includes("404")) { + errorMessage = "PDF not found."; + } + return errorMessage; + } + if (pdfRenderError) { + return pdfRenderError; + } + return null; + }, [downloadError, pdfRenderError]); + + // Cleanup function to remove temporary file on unmount + useEffect(() => { + return () => { + if (localPath) { + ReactNativeBlobUtil.fs.unlink(localPath).catch(() => ({})); + } + }; + }, [source, headers]); + + if (error) { + return ( + + + {error} + + + ); + } + + if (isLoading || !localPath) { + return ( + + + + + Downloading PDF... + + + + ); + } + + return ( + + ({})} + onError={() => setPdfRenderError("Failed to render PDF")} + trustAllCerts={false} + renderActivityIndicator={() => ( + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + ...StyleSheet.absoluteFillObject, + justifyContent: "center", + alignItems: "center", + zIndex: 1, + }, + loadingText: { + marginTop: 12, + fontSize: 16, + }, + errorText: { + fontSize: 16, + textAlign: "center", + padding: 20, + }, +}); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4ce6a718..4902249a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -48,6 +48,7 @@ "react-native-gesture-handler": "~2.21.2", "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", + "react-native-pdf": "^6.7.7", "react-native-reanimated": "^3.16.2", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92c633c2..fe100443 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,9 @@ importers: react-native-markdown-display: specifier: ^7.0.2 version: 7.0.2(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-pdf: + specifier: ^6.7.7 + version: 6.7.7(react-native-blob-util@0.21.2(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) react-native-reanimated: specifier: ^3.16.2 version: 3.16.2(@babel/core@7.26.0)(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) @@ -5042,6 +5045,9 @@ packages: peerDependencies: '@babel/core': '*' + '@react-native/normalize-color@2.1.0': + resolution: {integrity: sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==} + '@react-native/normalize-colors@0.76.3': resolution: {integrity: sha512-Yrpmrh4IDEupUUM/dqVxhAN8QW1VEUR3Qrk2lzJC1jB2s46hDe0hrMP2vs12YJqlzshteOthjwXQlY0TgIzgbg==} @@ -7723,6 +7729,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + deprecated-react-native-prop-types@2.3.0: + resolution: {integrity: sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -13065,6 +13074,13 @@ packages: react: '>=16.2.0' react-native: '>=0.50.4' + react-native-pdf@6.7.7: + resolution: {integrity: sha512-D0ga/eyPsVWSPEBm622sGVZLl3gibxPmfm2cxsLcUrZ4WDSGR5HyGmvvWaR/m9wXEyIbD4J6q9qzuG6yObcSXw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-blob-util: '>=0.13.7' + react-native-reanimated@3.16.2: resolution: {integrity: sha512-Jk8y+iOLcK3J8YK3Qj/U+zclwfetgM1fFhlYaxFrJ5TPvuwdRG5YY1pvO91FcZ3C1+0meGHR6BZGl9d/Z0xh3Q==} peerDependencies: @@ -15735,7 +15751,7 @@ snapshots: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -15769,7 +15785,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.22.5': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.27.0 dev: false '@babel/helper-annotate-as-pure@7.25.9': @@ -15779,7 +15795,7 @@ snapshots: '@babel/helper-builder-binary-assignment-operator-visitor@7.22.15': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.27.0 dev: false '@babel/helper-compilation-targets@7.25.9': @@ -15794,7 +15810,7 @@ snapshots: dependencies: '@babel/compat-data': 7.26.8 '@babel/helper-validator-option': 7.25.9 - browserslist: 4.24.2 + browserslist: 4.24.4 lru-cache: 5.1.1 semver: 6.3.1 dev: false @@ -15876,7 +15892,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -15979,20 +15995,20 @@ snapshots: '@babel/helper-simple-access@7.22.5': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.27.0 dev: false '@babel/helper-simple-access@7.25.9': dependencies: '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/types': 7.27.0 transitivePeerDependencies: - supports-color dev: false '@babel/helper-skip-transparent-expression-wrappers@7.22.5': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.27.0 dev: false '@babel/helper-skip-transparent-expression-wrappers@7.25.9': @@ -16061,7 +16077,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.26.5 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.27.0 transitivePeerDependencies: - supports-color dev: false @@ -16115,7 +16131,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.26.5 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.27.0 transitivePeerDependencies: - supports-color dev: false @@ -16412,7 +16428,7 @@ snapshots: '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-class-features-plugin': 7.27.0(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.26.5 transitivePeerDependencies: - supports-color @@ -16652,7 +16668,7 @@ snapshots: '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.26.5 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.27.0 transitivePeerDependencies: - supports-color dev: false @@ -16743,7 +16759,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.26.5 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.26.5(@babel/core@7.26.0) transitivePeerDependencies: - supports-color dev: false @@ -17228,8 +17244,8 @@ snapshots: '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/types': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + '@babel/types': 7.27.0 esutils: 2.0.3 dev: false @@ -17347,7 +17363,7 @@ snapshots: '@babel/parser': 7.26.2 '@babel/template': 7.25.9 '@babel/types': 7.26.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -17359,7 +17375,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18792,7 +18808,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -18848,7 +18864,7 @@ snapshots: ci-info: 3.9.0 compression: 1.7.4 connect: 3.7.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 env-editor: 0.4.2 fast-glob: 3.3.2 form-data: 3.0.1 @@ -18910,7 +18926,7 @@ snapshots: '@expo/plist': 0.2.0 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 getenv: 1.0.0 glob: 10.4.5 resolve-from: 5.0.0 @@ -18966,7 +18982,7 @@ snapshots: '@expo/env@0.4.0': dependencies: chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 dotenv: 16.4.5 dotenv-expand: 11.0.7 getenv: 1.0.0 @@ -18979,7 +18995,7 @@ snapshots: '@expo/spawn-async': 1.7.2 arg: 5.0.2 chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 find-up: 5.0.0 getenv: 1.0.0 minimatch: 3.1.2 @@ -19022,7 +19038,7 @@ snapshots: '@expo/json-file': 9.0.0 '@expo/spawn-async': 1.7.2 chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 fs-extra: 9.1.0 getenv: 1.0.0 glob: 10.4.5 @@ -19077,7 +19093,7 @@ snapshots: '@expo/image-utils': 0.6.3 '@expo/json-file': 9.0.0 '@react-native/normalize-colors': 0.76.3 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 fs-extra: 9.1.0 resolve-from: 5.0.0 semver: 7.6.3 @@ -19106,7 +19122,7 @@ snapshots: dependencies: '@remix-run/node': 2.15.0(typescript@5.7.3) abort-controller: 3.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 source-map-support: 0.5.21 transitivePeerDependencies: - supports-color @@ -19206,7 +19222,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -20935,6 +20951,9 @@ snapshots: - supports-color dev: false + '@react-native/normalize-color@2.1.0': + dev: false + '@react-native/normalize-colors@0.76.3': dev: false @@ -22056,7 +22075,7 @@ snapshots: '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.7.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -22092,7 +22111,7 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 eslint: 8.57.0 typescript: 5.7.3 transitivePeerDependencies: @@ -22128,7 +22147,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.7.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 eslint: 8.57.0 ts-api-utils: 1.2.1(typescript@5.7.3) typescript: 5.7.3 @@ -22158,7 +22177,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -24364,6 +24383,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + debug@4.4.0(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -24495,6 +24518,13 @@ snapshots: depd@2.0.0: dev: false + deprecated-react-native-prop-types@2.3.0: + dependencies: + '@react-native/normalize-color': 2.1.0 + invariant: 2.2.4 + prop-types: 15.8.1 + dev: false + dequal@2.0.3: {} destroy@1.2.0: @@ -31891,7 +31921,7 @@ snapshots: '@babel/helper-module-imports': 7.25.9 '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 lightningcss: 1.27.0 react: 18.3.1 react-native: 0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1) @@ -31965,6 +31995,15 @@ snapshots: react-native-fit-image: 1.5.5 dev: false + react-native-pdf@6.7.7(react-native-blob-util@0.21.2(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1): + dependencies: + crypto-js: 4.2.0 + deprecated-react-native-prop-types: 2.3.0 + react: 18.3.1 + react-native: 0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1) + react-native-blob-util: 0.21.2(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + dev: false + react-native-reanimated@3.16.2(@babel/core@7.26.0)(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.9(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1): dependencies: '@babel/core': 7.26.0 -- cgit v1.2.3-70-g09d2