From 39fcda015b467be6c08d134fd45ec94204b08a09 Mon Sep 17 00:00:00 2001 From: lexafaxine <40200356+lexafaxine@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:00:36 +0900 Subject: feat: adding search history #1541 (#1627) * feat: adding search history * fix popover should close when no matched history * remove unnecessary react import * replace current Input component with CommandInput for better UX * add i18n for recent searches label * fix bug * refactor local storage logic to make code reusable * using zod schema to validate search history and revert debounce change * Consolidate some of the files --------- Co-authored-by: Mohamed Bassem --- apps/mobile/app/dashboard/search.tsx | 129 +++++++++++++++++++++++++++++++---- apps/mobile/package.json | 1 + 2 files changed, 116 insertions(+), 14 deletions(-) (limited to 'apps/mobile') 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(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 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 }) => ( + handleSearchSubmit(item)} + className="border-b border-gray-200 p-3" + > + {item} + + ); + + const handleOnFocus = () => { + setIsInputFocused(true); + }; + + const handleOnBlur = () => { + setIsInputFocused(false); + if (search.trim().length > 0) { + addTerm(search); + } + }; + return ( handleSearchSubmit(search)} + returnKeyType="search" autoFocus autoCapitalize="none" /> @@ -47,8 +120,34 @@ export default function Search() { Cancel - {!data && } - {data && ( + + {isInputFocused ? ( + `${item}-${index}`} + ListHeaderComponent={ + + + Recent Searches + + {history.length > 0 && ( + + Clear + + )} + + } + ListEmptyComponent={ + + No matching searches. + + } + keyboardShouldPersistTaps="handled" + /> + ) : isFetching && query.length > 0 ? ( + + ) : data && query.length > 0 ? ( p.bookmarks)} fetchNextPage={fetchNextPage} @@ -56,6 +155,8 @@ export default function Search() { onRefresh={onRefresh} isRefreshing={isPending} /> + ) : ( + )} ); 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", -- cgit v1.2.3-70-g09d2