aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/mobile/app/dashboard/search.tsx129
-rw-r--r--apps/mobile/package.json1
-rw-r--r--apps/web/components/dashboard/header/Header.tsx2
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx197
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json3
-rw-r--r--packages/shared-react/hooks/search-history.ts102
-rw-r--r--pnpm-lock.yaml29
7 files changed, 416 insertions, 47 deletions
diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx
index de3d0f46..5cc97575 100644
--- a/apps/mobile/app/dashboard/search.tsx
+++ b/apps/mobile/app/dashboard/search.tsx
@@ -1,5 +1,12 @@
-import { useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import { useMemo, useRef, useState } from "react";
+import {
+ FlatList,
+ Keyboard,
+ Pressable,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
import { router } from "expo-router";
import BookmarkList from "@/components/bookmarks/BookmarkList";
import FullPageError from "@/components/FullPageError";
@@ -7,39 +14,105 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import { Input } from "@/components/ui/Input";
import { api } from "@/lib/trpc";
+import AsyncStorage from "@react-native-async-storage/async-storage";
import { keepPreviousData } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
+import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
+
+const MAX_DISPLAY_SUGGESTIONS = 5;
+
export default function Search() {
const [search, setSearch] = useState("");
const [query] = useDebounce(search, 10);
+ const inputRef = useRef<TextInput>(null);
+
+ const [isInputFocused, setIsInputFocused] = useState(true);
+ const { history, addTerm, clearHistory } = useSearchHistory({
+ getItem: (k: string) => AsyncStorage.getItem(k),
+ setItem: (k: string, v: string) => AsyncStorage.setItem(k, v),
+ removeItem: (k: string) => AsyncStorage.removeItem(k),
+ });
const onRefresh = api.useUtils().bookmarks.searchBookmarks.invalidate;
- const { data, error, refetch, isPending, fetchNextPage, isFetchingNextPage } =
- api.bookmarks.searchBookmarks.useInfiniteQuery(
- { text: query },
- {
- placeholderData: keepPreviousData,
- gcTime: 0,
- initialCursor: null,
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
- );
+ const {
+ data,
+ error,
+ refetch,
+ isPending,
+ isFetching,
+ fetchNextPage,
+ isFetchingNextPage,
+ } = api.bookmarks.searchBookmarks.useInfiniteQuery(
+ { text: query },
+ {
+ placeholderData: keepPreviousData,
+ gcTime: 0,
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const filteredHistory = useMemo(() => {
+ if (search.trim().length === 0) {
+ // Show recent items when not typing
+ return history.slice(0, MAX_DISPLAY_SUGGESTIONS);
+ }
+ // Show filtered items when typing
+ return history
+ .filter((item) => item.toLowerCase().includes(search.toLowerCase()))
+ .slice(0, MAX_DISPLAY_SUGGESTIONS);
+ }, [search, history]);
if (error) {
return <FullPageError error={error.message} onRetry={() => refetch()} />;
}
+ const handleSearchSubmit = (searchTerm: string) => {
+ const term = searchTerm.trim();
+ if (term.length > 0) {
+ addTerm(term);
+ setSearch(term);
+ }
+ inputRef.current?.blur();
+ Keyboard.dismiss();
+ };
+
+ const renderHistoryItem = ({ item }: { item: string }) => (
+ <Pressable
+ onPress={() => handleSearchSubmit(item)}
+ className="border-b border-gray-200 p-3"
+ >
+ <Text className="text-foreground">{item}</Text>
+ </Pressable>
+ );
+
+ const handleOnFocus = () => {
+ setIsInputFocused(true);
+ };
+
+ const handleOnBlur = () => {
+ setIsInputFocused(false);
+ if (search.trim().length > 0) {
+ addTerm(search);
+ }
+ };
+
return (
<CustomSafeAreaView>
<View className="flex flex-row items-center gap-3 p-3">
<Input
+ ref={inputRef}
placeholder="Search"
className="flex-1"
value={search}
onChangeText={setSearch}
+ onFocus={handleOnFocus}
+ onBlur={handleOnBlur}
+ onSubmitEditing={() => handleSearchSubmit(search)}
+ returnKeyType="search"
autoFocus
autoCapitalize="none"
/>
@@ -47,8 +120,34 @@ export default function Search() {
<Text className="text-foreground">Cancel</Text>
</Pressable>
</View>
- {!data && <FullPageSpinner />}
- {data && (
+
+ {isInputFocused ? (
+ <FlatList
+ data={filteredHistory}
+ renderItem={renderHistoryItem}
+ keyExtractor={(item, index) => `${item}-${index}`}
+ ListHeaderComponent={
+ <View className="flex-row items-center justify-between p-3">
+ <Text className="text-sm font-bold text-gray-500">
+ Recent Searches
+ </Text>
+ {history.length > 0 && (
+ <Pressable onPress={clearHistory}>
+ <Text className="text-sm text-blue-500">Clear</Text>
+ </Pressable>
+ )}
+ </View>
+ }
+ ListEmptyComponent={
+ <Text className="p-3 text-center text-gray-500">
+ No matching searches.
+ </Text>
+ }
+ keyboardShouldPersistTaps="handled"
+ />
+ ) : isFetching && query.length > 0 ? (
+ <FullPageSpinner />
+ ) : data && query.length > 0 ? (
<BookmarkList
bookmarks={data.pages.flatMap((p) => p.bookmarks)}
fetchNextPage={fetchNextPage}
@@ -56,6 +155,8 @@ export default function Search() {
onRefresh={onRefresh}
isRefreshing={isPending}
/>
+ ) : (
+ <View />
)}
</CustomSafeAreaView>
);
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 2c68810e..d5c2262f 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -18,6 +18,7 @@
"@karakeep/shared": "workspace:^0.1.0",
"@karakeep/shared-react": "workspace:^0.1.0",
"@karakeep/trpc": "workspace:^0.1.0",
+ "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.6",
"@tanstack/react-query": "^5.69.0",
"class-variance-authority": "^0.7.0",
diff --git a/apps/web/components/dashboard/header/Header.tsx b/apps/web/components/dashboard/header/Header.tsx
index e882ebfc..f830beb6 100644
--- a/apps/web/components/dashboard/header/Header.tsx
+++ b/apps/web/components/dashboard/header/Header.tsx
@@ -20,7 +20,7 @@ export default async function Header() {
</Link>
</div>
<div className="flex flex-1 gap-2">
- <SearchInput className="min-w-40 bg-muted" />
+ <SearchInput className="min-w-40 rounded-md bg-muted" />
<GlobalActions />
</div>
<div className="flex items-center">
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index fad45672..0de7694a 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -1,20 +1,44 @@
"use client";
-import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
+import React, {
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+import {
+ Command,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";
-import { SearchIcon } from "lucide-react";
+import { History } from "lucide-react";
+
+import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
import { EditListModal } from "../lists/EditListModal";
import QueryExplainerTooltip from "./QueryExplainerTooltip";
+const MAX_DISPLAY_SUGGESTIONS = 5;
+
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
- onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
+ value: string,
+ setValue: (value: string) => void,
+ setPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>,
) {
useEffect(() => {
function handleKeyPress(e: KeyboardEvent) {
@@ -27,18 +51,12 @@ function useFocusSearchOnKeyPress(
// Move the cursor to the end of the input field, so you can continue typing
const length = inputRef.current.value.length;
inputRef.current.setSelectionRange(length, length);
+ setPopoverOpen(true);
}
- if (
- e.code === "Escape" &&
- e.target == inputRef.current &&
- inputRef.current.value !== ""
- ) {
+ if (e.code === "Escape" && e.target == inputRef.current && value !== "") {
e.preventDefault();
inputRef.current.blur();
- inputRef.current.value = "";
- onChange({
- target: inputRef.current,
- } as React.ChangeEvent<HTMLInputElement>);
+ setValue("");
}
}
@@ -46,7 +64,7 @@ function useFocusSearchOnKeyPress(
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
- }, [inputRef, onChange]);
+ }, [inputRef, value, setValue, setPopoverOpen]);
}
const SearchInput = React.forwardRef<
@@ -54,20 +72,81 @@ const SearchInput = React.forwardRef<
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { t } = useTranslation();
- const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } =
- useDoBookmarkSearch();
+ const {
+ debounceSearch,
+ searchQuery,
+ doSearch,
+ parsedSearchQuery,
+ isInSearchPage,
+ } = useDoBookmarkSearch();
+ const { addTerm, history } = useSearchHistory({
+ getItem: (k: string) => localStorage.getItem(k),
+ setItem: (k: string, v: string) => localStorage.setItem(k, v),
+ removeItem: (k: string) => localStorage.removeItem(k),
+ });
const [value, setValue] = React.useState(searchQuery);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
- const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- setValue(e.target.value);
- debounceSearch(e.target.value);
- };
+ const isHistorySelected = useRef(false);
- useFocusSearchOnKeyPress(inputRef, onChange);
+ const handleValueChange = useCallback(
+ (newValue: string) => {
+ setValue(newValue);
+ debounceSearch(newValue);
+ isHistorySelected.current = false; // Reset flag when user types
+ },
+ [debounceSearch],
+ );
+
+ const suggestions = useMemo(() => {
+ if (value.trim() === "") {
+ // Show recent items when not typing
+ return history.slice(0, MAX_DISPLAY_SUGGESTIONS);
+ } else {
+ // Show filtered items when typing
+ return history
+ .filter((item) => item.toLowerCase().includes(value.toLowerCase()))
+ .slice(0, MAX_DISPLAY_SUGGESTIONS);
+ }
+ }, [history, value]);
+
+ const isPopoverVisible = isPopoverOpen && suggestions.length > 0;
+ const handleHistorySelect = useCallback(
+ (term: string) => {
+ isHistorySelected.current = true;
+ setValue(term);
+ doSearch(term);
+ addTerm(term);
+ setIsPopoverOpen(false);
+ inputRef.current?.blur();
+ },
+ [doSearch],
+ );
+
+ const handleCommandKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ const selectedItem = document.querySelector(
+ '[cmdk-item][data-selected="true"]',
+ );
+ const isPlaceholderSelected =
+ selectedItem?.getAttribute("data-value") === "-";
+ if (!selectedItem || isPlaceholderSelected) {
+ e.preventDefault();
+ setIsPopoverOpen(false);
+ inputRef.current?.blur();
+ }
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setIsPopoverOpen(false);
+ inputRef.current?.blur();
+ }
+ }, []);
+
+ useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen);
useImperativeHandle(ref, () => inputRef.current!);
- const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
useEffect(() => {
if (!isInSearchPage) {
@@ -75,6 +154,21 @@ const SearchInput = React.forwardRef<
}
}, [isInSearchPage]);
+ const handleFocus = useCallback(() => {
+ setIsPopoverOpen(true);
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ // Only add to history if it wasn't a history selection
+ if (value && !isHistorySelected.current) {
+ addTerm(value);
+ }
+
+ // Reset the flag
+ isHistorySelected.current = false;
+ setIsPopoverOpen(false);
+ }, [value, addTerm]);
+
return (
<div className={cn("relative flex-1", className)}>
<EditListModal
@@ -103,14 +197,57 @@ const SearchInput = React.forwardRef<
{t("actions.save")}
</Button>
)}
- <Input
- startIcon={<SearchIcon size={18} className="text-muted-foreground" />}
- ref={inputRef}
- value={value}
- onChange={onChange}
- placeholder={t("common.search")}
- {...props}
- />
+ <Command
+ shouldFilter={false}
+ className="relative rounded-md bg-transparent"
+ onKeyDown={handleCommandKeyDown}
+ >
+ <Popover open={isPopoverVisible}>
+ <PopoverTrigger asChild>
+ <div className="relative">
+ <CommandInput
+ ref={inputRef}
+ placeholder={t("common.search")}
+ value={value}
+ onValueChange={handleValueChange}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ className={cn("h-10", className)}
+ {...props}
+ />
+ </div>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-[--radix-popover-trigger-width] p-0"
+ onOpenAutoFocus={(e) => e.preventDefault()}
+ onCloseAutoFocus={(e) => e.preventDefault()}
+ >
+ <CommandList>
+ <CommandGroup
+ heading={t("search.history")}
+ className="max-h-60 overflow-y-auto"
+ >
+ {/* prevent cmdk auto select the first suggestion -> https://github.com/pacocoursey/cmdk/issues/171*/}
+ <CommandItem value="-" className="hidden" />
+ {suggestions.map((term) => (
+ <CommandItem
+ key={term}
+ value={term}
+ onSelect={() => handleHistorySelect(term)}
+ onMouseDown={() => {
+ isHistorySelected.current = true;
+ }}
+ className="cursor-pointer"
+ >
+ <History className="mr-2 h-4 w-4" />
+ <span>{term}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </PopoverContent>
+ </Popover>
+ </Command>
</div>
);
});
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index f9e1d493..10b2f390 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -439,7 +439,8 @@
"is_from_feed": "Is from RSS Feed",
"is_not_from_feed": "Is not from RSS Feed",
"and": "And",
- "or": "Or"
+ "or": "Or",
+ "history": "Recent Searches"
},
"preview": {
"view_original": "View Original",
diff --git a/packages/shared-react/hooks/search-history.ts b/packages/shared-react/hooks/search-history.ts
new file mode 100644
index 00000000..bc3c4e3d
--- /dev/null
+++ b/packages/shared-react/hooks/search-history.ts
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { z } from "zod";
+
+const searchHistorySchema = z.array(z.string());
+
+const BOOKMARK_SEARCH_HISTORY_KEY = "karakeep_search_history";
+const MAX_STORED_ITEMS = 50;
+
+class SearchHistoryUtil {
+ constructor(
+ private storage: {
+ getItem(key: string): Promise<string | null> | string | null;
+ setItem(key: string, value: string): Promise<void> | void;
+ removeItem(key: string): Promise<void> | void;
+ },
+ ) {}
+
+ async getSearchHistory(): Promise<string[]> {
+ try {
+ const rawHistory = await this.storage.getItem(
+ BOOKMARK_SEARCH_HISTORY_KEY,
+ );
+ if (rawHistory) {
+ const parsed = JSON.parse(rawHistory) as unknown;
+ const result = searchHistorySchema.safeParse(parsed);
+ if (result.success) {
+ return result.data;
+ }
+ }
+ return [];
+ } catch (error) {
+ console.error("Failed to parse search history:", error);
+ return [];
+ }
+ }
+
+ async addSearchTermToHistory(term: string): Promise<void> {
+ if (!term || term.trim().length === 0) {
+ return;
+ }
+ try {
+ const currentHistory = await this.getSearchHistory();
+ const filteredHistory = currentHistory.filter(
+ (item) => item.toLowerCase() !== term.toLowerCase(),
+ );
+ const newHistory = [term, ...filteredHistory];
+ const finalHistory = newHistory.slice(0, MAX_STORED_ITEMS);
+ await this.storage.setItem(
+ BOOKMARK_SEARCH_HISTORY_KEY,
+ JSON.stringify(finalHistory),
+ );
+ } catch (error) {
+ console.error("Failed to save search history:", error);
+ }
+ }
+
+ async clearSearchHistory(): Promise<void> {
+ try {
+ await this.storage.removeItem(BOOKMARK_SEARCH_HISTORY_KEY);
+ } catch (error) {
+ console.error("Failed to clear search history:", error);
+ }
+ }
+}
+
+export function useSearchHistory(adapter: {
+ getItem(key: string): Promise<string | null> | string | null;
+ setItem(key: string, value: string): Promise<void> | void;
+ removeItem(key: string): Promise<void> | void;
+}) {
+ const [history, setHistory] = useState<string[]>([]);
+ const searchHistoryUtil = useMemo(() => new SearchHistoryUtil(adapter), []);
+
+ const loadHistory = useCallback(async () => {
+ const storedHistory = await searchHistoryUtil.getSearchHistory();
+ setHistory(storedHistory);
+ }, [searchHistoryUtil]);
+
+ useEffect(() => {
+ loadHistory();
+ }, [loadHistory]);
+
+ const addTerm = useCallback(
+ async (term: string) => {
+ await searchHistoryUtil.addSearchTermToHistory(term);
+ await loadHistory();
+ },
+ [searchHistoryUtil, loadHistory],
+ );
+
+ const clearHistory = useCallback(async () => {
+ await searchHistoryUtil.clearSearchHistory();
+ setHistory([]);
+ }, [searchHistoryUtil]);
+
+ return {
+ history,
+ addTerm,
+ clearHistory,
+ refreshHistory: loadHistory,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a8500335..e960968d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -323,6 +323,9 @@ importers:
'@karakeep/trpc':
specifier: workspace:^0.1.0
version: link:../../packages/trpc
+ '@react-native-async-storage/async-storage':
+ specifier: 1.23.1
+ version: 1.23.1(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))
'@react-native-menu/menu':
specifier: ^1.1.6
version: 1.1.6(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
@@ -1181,7 +1184,7 @@ importers:
version: 18.3.1
react-native:
specifier: ^0.76.3
- version: 0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)
+ version: 0.76.9(@babel/core@7.27.4)(@babel/preset-env@7.27.2(@babel/core@7.27.4))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)
superjson:
specifier: ^2.2.1
version: 2.2.1
@@ -4656,6 +4659,11 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@react-native-async-storage/async-storage@1.23.1':
+ resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==}
+ peerDependencies:
+ react-native: ^0.0.0-0 || >=0.60 <1.0
+
'@react-native-menu/menu@1.1.6':
resolution: {integrity: sha512-KRPBqa9jmYDFoacUxw8z1ucpbvmdlPuRO8tsFt2jM8JMC2s+YQwTtISG73PeqH9KD7BV+8igD/nizPfcipOmhQ==}
peerDependencies:
@@ -9251,6 +9259,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
+ is-plain-obj@2.1.0:
+ resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
+ engines: {node: '>=8'}
+
is-plain-obj@3.0.0:
resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==}
engines: {node: '>=10'}
@@ -10129,6 +10141,10 @@ packages:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
+ merge-options@3.0.4:
+ resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
+ engines: {node: '>=10'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -20039,6 +20055,11 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@react-native-async-storage/async-storage@1.23.1(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))':
+ dependencies:
+ merge-options: 3.0.4
+ react-native: 0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)
+
'@react-native-menu/menu@1.1.6(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -25597,6 +25618,8 @@ snapshots:
is-path-inside@3.0.3: {}
+ is-plain-obj@2.1.0: {}
+
is-plain-obj@3.0.0: {}
is-plain-obj@4.1.0: {}
@@ -26742,6 +26765,10 @@ snapshots:
merge-descriptors@2.0.0: {}
+ merge-options@3.0.4:
+ dependencies:
+ is-plain-obj: 2.1.0
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}