feat(mobile): add offline caching for all formats + bug fix

This commit is contained in:
daniel31x13
2025-11-22 20:28:04 -05:00
parent 451d17a2cb
commit a4c55fb455
15 changed files with 558 additions and 169 deletions

View File

@@ -56,7 +56,6 @@
"iosAppGroupIdentifier": "group.app.linkwarden"
}
],
"./plugins/with-daynight-transparent-nav",
[
"expo-build-properties",
{
@@ -69,7 +68,9 @@
"buildToolsVersion": "35.0.0"
}
}
]
],
"./plugins/with-daynight-transparent-nav",
"./plugins/file-sharing"
],
"experiments": {
"typedRoutes": true

View File

@@ -25,8 +25,6 @@ import { rawTheme, ThemeName } from "@/lib/colors";
import { useShareIntent } from "expo-share-intent";
import useDataStore from "@/store/data";
import useAuthStore from "@/store/auth";
import { QueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { KeyboardProvider } from "react-native-keyboard-controller";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Compass, Ellipsis } from "lucide-react-native";
@@ -37,17 +35,8 @@ import {
MobileAuth,
} from "@linkwarden/types";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
import { deleteLinkCache } from "@/lib/cache";
import { queryClient } from "@/lib/queryClient";
export default function RootLayout() {
const [isLoading, setIsLoading] = useState(true);
@@ -66,20 +55,6 @@ export default function RootLayout() {
setData();
}, []);
useEffect(() => {
(async () => {
if (auth.status === "unauthenticated") {
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
const CACHE_DIR =
FileSystem.documentDirectory + "archivedData/readable/";
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
}
})();
}, [auth.status]);
useEffect(() => {
if (!rootNavState?.key) return;
@@ -307,7 +282,14 @@ const RootComponent = ({
style: "destructive",
onPress: () => {
deleteLink.mutate(
tmp.link?.id as number
tmp.link?.id as number,
{
onSuccess: async () => {
await deleteLinkCache(
tmp.link?.id as number
);
},
}
);
// go back
router.back();

View File

@@ -1,47 +1,23 @@
import React, { useEffect, useState } from "react";
import {
View,
ActivityIndicator,
Text,
ScrollView,
TouchableOpacity,
} from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { WebView } from "react-native-webview";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
import { decode } from "html-entities";
import { useGetLink } from "@linkwarden/router/links";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
async function ensureCacheDir() {
const info = await FileSystem.getInfoAsync(CACHE_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
}
import { ArchivedFormat } from "@linkwarden/types";
import ReadableFormat from "@/components/Formats/ReadableFormat";
import ImageFormat from "@/components/Formats/ImageFormat";
import PdfFormat from "@/components/Formats/PdfFormat";
import WebpageFormat from "@/components/Formats/WebpageFormat";
export default function LinkScreen() {
const { auth } = useAuthStore();
const { id, format } = useLocalSearchParams();
const { data: user } = useUser(auth);
const [url, setUrl] = useState<string>();
const [htmlContent, setHtmlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
@@ -63,116 +39,40 @@ export default function LinkScreen() {
}, [link, user]);
useEffect(() => {
async function loadCacheOrFetch() {
await ensureCacheDir();
const htmlFile = htmlPath(id as string);
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
if (format === "3" && htmlInfo.exists) {
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
setHtmlContent(rawHtml);
setIsLoading(false);
if (user?.id && link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
} else if (!url) {
if (link?.url) {
setUrl(link.url);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
await fetchLinkData();
}
}
if (user?.id && link?.id && !url) {
loadCacheOrFetch();
}
}, [user, link]);
async function fetchLinkData() {
// readable
if (link?.id && format === "3") {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${format}`;
setUrl(apiUrl);
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const html = (await response.json()).content;
setHtmlContent(html);
await FileSystem.writeAsStringAsync(htmlPath(id as string), html, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch HTML content", e);
} finally {
setIsLoading(false);
}
} else if (link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
} else if (link?.id) {
setUrl(link.url as string);
}
}
return (
<>
{format === "3" && htmlContent ? (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link?.name || link?.description || link?.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link?.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(
(link?.importDate || link?.createdAt) as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: htmlContent }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${id}`)}
/>
),
}}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
{link?.id && Number(format) === ArchivedFormat.readability ? (
<ReadableFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id &&
(Number(format) === ArchivedFormat.jpeg ||
Number(format) === ArchivedFormat.png) ? (
<ImageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
format={Number(format)}
/>
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
<PdfFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
<WebpageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : url ? (
<WebView
className={isLoading ? "opacity-0" : "flex-1"}

View File

@@ -0,0 +1,105 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
format: ArchivedFormat.png | ArchivedFormat.jpeg;
};
export default function ImageFormat({ link, setIsLoading, format }: Props) {
const FORMAT = format;
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/${extension}/link_${link.id}.${extension}`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
baseUrl: content,
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<img src="${content}" />
</body>
</html>
`,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function PdfFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.pdf;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<Pdf
style={{
flex: 1,
}}
source={{ uri: content }}
onLoadComplete={() => setIsLoading(false)}
onPressLink={(uri) => {
console.log(`Link pressed: ${uri}`);
}}
/>
)
);
}

View File

@@ -0,0 +1,139 @@
import React, { useEffect, useState } from "react";
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
import { decode } from "html-entities";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function ReadableFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.readability;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/readable/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
const rawContent = await FileSystem.readAsStringAsync(filePath);
setContent(rawContent);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const data = (await response.json()).content;
setContent(data);
await FileSystem.writeAsStringAsync(filePath, data, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link.name || link.description || link.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${link.id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(link?.importDate || link.createdAt).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: content }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${link.id}`)}
/>
),
}}
onHTMLLoaded={() => setIsLoading(false)}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
)
);
}

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function WebpageFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.monolith;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/webpage/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
uri: content,
baseUrl: FileSystem.documentDirectory,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -30,6 +30,7 @@ import { useColorScheme } from "nativewind";
import { CalendarDays, Folder } from "lucide-react-native";
import useDataStore from "@/store/data";
import { useEffect, useState } from "react";
import { deleteLinkCache } from "@/lib/cache";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -319,7 +320,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(link.id as number);
deleteLink.mutate(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
},
},
]

33
apps/mobile/lib/cache.ts Normal file
View File

@@ -0,0 +1,33 @@
import * as FileSystem from "expo-file-system";
export const clearCache = async () => {
await Promise.all([
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
idempotent: true,
}),
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
idempotent: true,
}),
]);
};
export const deleteLinkCache = async (linkId: number) => {
const readablePath =
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
const webpagePath =
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
const jpegPath =
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
const pngPath =
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
const pdfPath =
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
await Promise.all([
FileSystem.deleteAsync(readablePath, { idempotent: true }),
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
FileSystem.deleteAsync(pngPath, { idempotent: true }),
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
]);
};

View File

@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
export { queryClient };

View File

@@ -55,11 +55,13 @@
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-actions-sheet": "^0.9.7",
"react-native-blob-util": "^0.23.2",
"react-native-gesture-handler": "~2.20.2",
"react-native-ios-context-menu": "3.1.3",
"react-native-ios-utilities": "5.1.7",
"react-native-keyboard-controller": "^1.19.0",
"react-native-mmkv": "^3.2.0",
"react-native-pdf": "^7.0.3",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",

View File

@@ -0,0 +1,7 @@
module.exports = function withFileSharing(config) {
config.ios = config.ios || {};
config.ios.infoPlist = config.ios.infoPlist || {};
config.ios.infoPlist.UIFileSharingEnabled = true;
config.ios.infoPlist.LSSupportsOpeningDocumentsInPlace = true;
return config;
};

View File

@@ -4,6 +4,9 @@ import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
import * as FileSystem from "expo-file-system";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
@@ -105,7 +108,12 @@ const useAuthStore = create<AuthStore>((set) => ({
signOut: async () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
await clearCache();
set({
auth: {

View File

@@ -16,6 +16,7 @@ import { ArchivedFormat } from "@linkwarden/types";
export const config = {
api: {
bodyParser: false,
responseLimit: "50mb",
},
};

View File

@@ -4026,6 +4026,11 @@
hermes-parser "0.23.1"
nullthrows "^1.1.1"
"@react-native/normalize-color@*":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.1.0.tgz#939b87a9849e81687d3640c5efa2a486ac266f91"
integrity sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==
"@react-native/normalize-colors@0.76.8":
version "0.76.8"
resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.76.8.tgz#79380c178ec7437f4857bebeb860ee97bb069318"
@@ -5557,6 +5562,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base-64@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==
base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -6312,6 +6322,11 @@ crypt@0.0.2:
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==
crypto-js@4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
crypto-random-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
@@ -6623,6 +6638,15 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
deprecated-react-native-prop-types@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz#c10c6ee75ff2b6de94bb127f142b814e6e08d9ab"
integrity sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==
dependencies:
"@react-native/normalize-color" "*"
invariant "*"
prop-types "*"
dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
@@ -8667,7 +8691,7 @@ internal-slot@^1.0.3, internal-slot@^1.0.4:
has "^1.0.3"
side-channel "^1.0.4"
invariant@2.2.4, invariant@^2.2.4:
invariant@*, invariant@2.2.4, invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@@ -11459,7 +11483,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@*, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -11689,6 +11713,14 @@ react-native-actions-sheet@^0.9.7:
resolved "https://registry.yarnpkg.com/react-native-actions-sheet/-/react-native-actions-sheet-0.9.7.tgz#43ff2d9252f7af2da6dc051be5a5c375a844cdfb"
integrity sha512-rjUwxUr5dxbdSLDtLDUFAdSlFxpNSpJsbXLhHkBzEBMxEMPUhRT3zqbvKqsPj0JUkjwuRligxrhbIJZkg/6ZDw==
react-native-blob-util@^0.23.2:
version "0.23.2"
resolved "https://registry.yarnpkg.com/react-native-blob-util/-/react-native-blob-util-0.23.2.tgz#a5464947a4624a5def458e2dcbd0fae98f64da7b"
integrity sha512-ZsUUFQYyZ7BI57c31XdPCkPlteoH7+PvcVy2w6wh1OPSUWGtKL79pj7fa6MepMX0v87fn0V9Heq0n6OjEpLdCw==
dependencies:
base-64 "0.1.0"
glob "^10.3.10"
react-native-css-interop@0.1.22:
version "0.1.22"
resolved "https://registry.yarnpkg.com/react-native-css-interop/-/react-native-css-interop-0.1.22.tgz#70cc6ca7a8f14126e123e44a19ceed74dd2a167a"
@@ -11754,6 +11786,14 @@ react-native-mmkv@^3.2.0:
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.2.0.tgz#460723eb23b9cc92c65b0416eae9874a6ddf5b82"
integrity sha512-9y7K//1MaU46TFrXkyU0wT5VGk9Y2FDLFV6NPl+z3jTbm2G7SC1UIxneF6CfhSmBX5CfKze9SO7kwDuRx7flpQ==
react-native-pdf@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/react-native-pdf/-/react-native-pdf-7.0.3.tgz#f72691ac84014f2886e79ebbaf1b28a03ba8b8e9"
integrity sha512-zDtF6CGXPAfGptQZqX7LQK3CVQrIGsD+rYuBnMK0sVmd8mrq7ciwmWXINT+d92emMtZ7+PLnx1IQZIdsh0fphA==
dependencies:
crypto-js "4.2.0"
deprecated-react-native-prop-types "^2.3.0"
react-native-reanimated@3.16.2:
version "3.16.2"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.16.2.tgz#8ae2d632cbf02174ce3ef1329b3ce6e3cfe46aa2"