aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
authorlexafaxine <40200356+lexafaxine@users.noreply.github.com>2025-07-14 09:00:36 +0900
committerGitHub <noreply@github.com>2025-07-14 01:00:36 +0100
commit39fcda015b467be6c08d134fd45ec94204b08a09 (patch)
treece78ec11e2cbbb349ec7d02ba69fad8fe16e19a1 /apps/mobile
parentecb13cec5d5c646308b34c714401a716f3cdf199 (diff)
downloadkarakeep-39fcda015b467be6c08d134fd45ec94204b08a09.tar.zst
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 <me@mbassem.com>
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/app/dashboard/search.tsx129
-rw-r--r--apps/mobile/package.json1
2 files changed, 116 insertions, 14 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",