From 84e839bcd7c93ab3e67b516f7ea252071b02f7f9 Mon Sep 17 00:00:00 2001 From: Amir Kedis <88613195+amir-kedis@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:33:31 +0200 Subject: [PATCH] feat(search): global search + integration with backend (#132) * fix: change avatar size to be normal instead of large * feat: change NoResultsFound to be more reusable * feat: add search logic infratstructure * feat: add search integration with the backend * feat: search tab smart rendering and refactor --- .../search/components/NoResultsFound.tsx | 16 +- .../features/search/components/SearchBar.tsx | 81 +++++----- .../features/search/components/SearchTab.tsx | 112 ++++++++++++++ .../search/components/TabedSearch.tsx | 6 +- .../components/result-items/ChannelResult.tsx | 2 +- .../components/result-items/FileResult.tsx | 2 +- .../components/result-items/MessageResult.tsx | 5 +- .../search/components/tabs/MessagesTab.tsx | 34 ----- .../search/data/tabs-components-map.tsx | 43 ++++-- app/src/features/search/hooks/useSearch.ts | 32 ++++ app/src/features/search/services/searchAPI.ts | 40 +++++ app/src/features/search/types/search.d.ts | 80 ++++++++++ .../search/utils/searchTabsHelpers.tsx | 138 ++++++++++++++++++ 13 files changed, 494 insertions(+), 97 deletions(-) create mode 100644 app/src/features/search/components/SearchTab.tsx delete mode 100644 app/src/features/search/components/tabs/MessagesTab.tsx create mode 100644 app/src/features/search/hooks/useSearch.ts create mode 100644 app/src/features/search/services/searchAPI.ts create mode 100644 app/src/features/search/types/search.d.ts create mode 100644 app/src/features/search/utils/searchTabsHelpers.tsx diff --git a/app/src/features/search/components/NoResultsFound.tsx b/app/src/features/search/components/NoResultsFound.tsx index 70c80d9e..a6e102be 100644 --- a/app/src/features/search/components/NoResultsFound.tsx +++ b/app/src/features/search/components/NoResultsFound.tsx @@ -29,14 +29,22 @@ const NoResultsSubText = styled.p` color: var(--color-text-secondary); `; -const NoResultsFound: React.FC = () => { +interface NoResultsFoundProps { + message?: string; + subMessage?: string; +} + +const NoResultsFound: React.FC = ({ + message = "No results found", + subMessage = "Try searching for something else", +}) => { return ( - + - No results found - Try searching for something else + {message} + {subMessage} ); }; diff --git a/app/src/features/search/components/SearchBar.tsx b/app/src/features/search/components/SearchBar.tsx index b7d87cdb..f273eec6 100644 --- a/app/src/features/search/components/SearchBar.tsx +++ b/app/src/features/search/components/SearchBar.tsx @@ -79,48 +79,9 @@ const SearchBar: React.FC = ({ onClose }) => { const currentChat = useSelector((state: RootState) => state.chats.chats.find((chat) => chat._id === chatId), ); - const messages = currentChat?.messages ?? []; const [isSearchLoading, setIsSearchLoading] = useState(false); - const performSearch = (term: string) => { - if (!term) { - dispatch(setSearchResults([])); - setIsSearchLoading(false); - return; - } - - const results = messages.reduce( - ( - acc: Array<{ - messageId: string; - highlightIndex: number; - }>, - message, - ) => { - const lowerCaseTerm = term.toLowerCase(); - const lowerCaseContent = message.content.toLowerCase(); - - if (lowerCaseContent.includes(lowerCaseTerm)) { - const matchIndices = findAllMatchIndices(message.content, term); - matchIndices.forEach((highlightIndex) => { - acc.push({ - messageId: message._id, - highlightIndex, - }); - }); - } - - return acc; - }, - [], - ); - - dispatch(setSearchTerm(term)); - dispatch(setSearchResults(results)); - setIsSearchLoading(false); - }; - const findAllMatchIndices = (text: string, term: string) => { const indices = []; let index = text.toLowerCase().indexOf(term.toLowerCase()); @@ -132,11 +93,51 @@ const SearchBar: React.FC = ({ onClose }) => { }; useEffect(() => { + const messages = currentChat?.messages ?? []; + + const performSearch = (term: string) => { + if (!term) { + dispatch(setSearchResults([])); + setIsSearchLoading(false); + return; + } + + const results = messages.reduce( + ( + acc: Array<{ + messageId: string; + highlightIndex: number; + }>, + message, + ) => { + const lowerCaseTerm = term.toLowerCase(); + const lowerCaseContent = message.content.toLowerCase(); + + if (lowerCaseContent.includes(lowerCaseTerm)) { + const matchIndices = findAllMatchIndices(message.content, term); + matchIndices.forEach((highlightIndex) => { + acc.push({ + messageId: message._id, + highlightIndex, + }); + }); + } + + return acc; + }, + [], + ); + + dispatch(setSearchTerm(term)); + dispatch(setSearchResults(results)); + setIsSearchLoading(false); + }; + const timer = setTimeout(() => { performSearch(searchTerm); }, 100); return () => clearTimeout(timer); - }, [searchTerm]); + }, [searchTerm, dispatch, currentChat?.messages]); const handleSearch = (e: React.ChangeEvent) => { dispatch(setSearchTerm(e.target.value)); diff --git a/app/src/features/search/components/SearchTab.tsx b/app/src/features/search/components/SearchTab.tsx new file mode 100644 index 00000000..d6e5a0a7 --- /dev/null +++ b/app/src/features/search/components/SearchTab.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from "react"; +import { RootState } from "@state/store"; +import { useSelector } from "react-redux"; +import { useSearch } from "@features/search/hooks/useSearch"; +import { SEARCH_TABS } from "@features/search/data/tabs-components-map"; +import NoResultsFound from "./NoResultsFound"; +import { + hasResults, + renderGlobalResults, + renderMessageResults, +} from "../utils/searchTabsHelpers"; +import ChannelResult from "./result-items/ChannelResult"; +import { + SearchResultChannel, + SearchResultGroup, + SearchResultUser, +} from "../types/search"; + +const SearchTab: React.FC = () => { + const { searchTerm, selectedTab } = useSelector( + (state: RootState) => state.globalSearch, + ); + + const currentTab = SEARCH_TABS.find((tab) => tab.title === selectedTab); + + const searchRequest = useMemo( + () => ({ + query: searchTerm, + filter: currentTab?.filter || [], + searchSpace: currentTab?.searchSpace || [], + isGlobalSearch: currentTab?.isGlobalSearch || false, + }), + [searchTerm, currentTab], + ); + + const { data, isLoading, error } = useSearch(searchRequest); + + if (isLoading) + return ( + + ); + + if (error) + return ( + + ); + + if (!hasResults(data)) return ; + + return ( +
+ {renderMessageResults(data!.searchResult, ["text"], searchTerm)} + {renderMessageResults( + data!.searchResult, + ["image", "video", "GIF", "sticker"], + searchTerm, + )} + {renderMessageResults(data!.searchResult, ["link"], searchTerm)} + {renderMessageResults(data!.searchResult, ["file"], searchTerm)} + {renderMessageResults(data!.searchResult, ["audio"], searchTerm)} + + {renderGlobalResults( + data?.globalSearchResult?.groups || [], + "Groups", + (group) => ( + + ), + )} + + {renderGlobalResults( + data?.globalSearchResult?.channels || [], + "Channels", + (channel) => ( + + ), + )} + + {renderGlobalResults( + data?.globalSearchResult?.users || [], + "Users", + (user) => ( + + ), + )} +
+ ); +}; + +export default SearchTab; diff --git a/app/src/features/search/components/TabedSearch.tsx b/app/src/features/search/components/TabedSearch.tsx index 1d783c15..198d7733 100644 --- a/app/src/features/search/components/TabedSearch.tsx +++ b/app/src/features/search/components/TabedSearch.tsx @@ -2,7 +2,7 @@ import { RootState } from "@state/store"; import { motion, AnimatePresence, LayoutGroup } from "motion/react"; import { useDispatch, useSelector } from "react-redux"; import styled from "styled-components"; -import { SEARCH_TABS_MOCK } from "../data/tabs-components-map"; +import { SEARCH_TABS } from "../data/tabs-components-map"; import { setSelectedTab } from "@state/messages/global-search"; const TabedSearchContainer = styled(motion.div)` @@ -78,7 +78,7 @@ const TabedSearch: React.FC = () => { > - {SEARCH_TABS_MOCK.map((tab) => ( + {SEARCH_TABS.map((tab) => ( dispatch(setSelectedTab(tab.title))} @@ -106,7 +106,7 @@ const TabedSearch: React.FC = () => { exit={{ opacity: 0 }} transition={{ type: "tween", duration: 0.2 }} > - {SEARCH_TABS_MOCK.find( + {SEARCH_TABS.find( (tab) => tab.title === selectedTab, )?.component()} diff --git a/app/src/features/search/components/result-items/ChannelResult.tsx b/app/src/features/search/components/result-items/ChannelResult.tsx index 192e22d3..691a73f4 100644 --- a/app/src/features/search/components/result-items/ChannelResult.tsx +++ b/app/src/features/search/components/result-items/ChannelResult.tsx @@ -56,7 +56,7 @@ const ChannelResult: React.FC = ({ return ( - + diff --git a/app/src/features/search/components/result-items/FileResult.tsx b/app/src/features/search/components/result-items/FileResult.tsx index 9b45ce95..d42ea588 100644 --- a/app/src/features/search/components/result-items/FileResult.tsx +++ b/app/src/features/search/components/result-items/FileResult.tsx @@ -53,7 +53,7 @@ const FileResult: React.FC = ({ return ( - + diff --git a/app/src/features/search/components/result-items/MessageResult.tsx b/app/src/features/search/components/result-items/MessageResult.tsx index f731adc7..7ede6b21 100644 --- a/app/src/features/search/components/result-items/MessageResult.tsx +++ b/app/src/features/search/components/result-items/MessageResult.tsx @@ -40,8 +40,6 @@ const DateSpan = styled.span` const Message = styled.p` font-size: 0.875rem; color: var(--color-text-secondary); - display: flex; - align-items: center; `; const MediaPreview = styled.img` @@ -55,7 +53,6 @@ const MediaPreview = styled.img` const Highlight = styled.span` background: #cae3f7; color: #000000; - padding: 0 0.125rem; border-radius: 2px; `; @@ -98,7 +95,7 @@ const MessageResult: React.FC = ({ return ( - + diff --git a/app/src/features/search/components/tabs/MessagesTab.tsx b/app/src/features/search/components/tabs/MessagesTab.tsx deleted file mode 100644 index dba14faf..00000000 --- a/app/src/features/search/components/tabs/MessagesTab.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { RootState } from "@state/store"; -import { useSelector } from "react-redux"; -import MessageResult from "../result-items/MessageResult"; -import NoResultsFound from "../NoResultsFound"; - -const MESSAGES = [ - { - title: "Telware Testing", - date: new Date().toString(), - image: "https://placecats.com/200/200", - message: "Hello hi there", - }, -]; - -const MessagesTab: React.FC = () => { - const { searchTerm } = useSelector((state: RootState) => state.globalSearch); - - return ( -
- {MESSAGES.length === 0 && } - {MESSAGES.map((message) => ( - - ))} -
- ); -}; - -export default MessagesTab; diff --git a/app/src/features/search/data/tabs-components-map.tsx b/app/src/features/search/data/tabs-components-map.tsx index 81311ee4..eb9704c0 100644 --- a/app/src/features/search/data/tabs-components-map.tsx +++ b/app/src/features/search/data/tabs-components-map.tsx @@ -3,39 +3,62 @@ import AudioResult from "../components/result-items/AudioResult"; import ChannelResult from "../components/result-items/ChannelResult"; import FileResult from "../components/result-items/FileResult"; import MessageResult from "../components/result-items/MessageResult"; -import MessagesTab from "../components/tabs/MessagesTab"; +import SearchTab from "../components/SearchTab"; +import { SearchTabType } from "../types/search"; -export const SEARCH_TABS_MOCK = [ +export const SEARCH_TABS: SearchTabType[] = [ { title: "Chats", - component: () => , + component: () => , + filter: ["text"], + searchSpace: ["groups", "channels"], + isGlobalSearch: true, }, { title: "Channnels", - component: () => , + component: () => , + filter: ["text"], + searchSpace: ["channels", "groups"], + isGlobalSearch: true, }, { title: "Media", - component: () => , + component: () => , + filter: ["text", "image", "video", "GIF", "sticker"], + searchSpace: ["groups", "channels"], + isGlobalSearch: false, }, { title: "Links", - component: () => , + component: () => , + filter: ["link"], + searchSpace: ["groups", "channels"], + isGlobalSearch: false, }, { title: "Files", - component: () => , + component: () => , + filter: ["file"], + searchSpace: ["groups", "channels"], + isGlobalSearch: false, }, { title: "Music", - component: () => , + component: () => , + filter: ["audio"], + searchSpace: ["groups", "channels"], + isGlobalSearch: false, }, { title: "Voice", - component: () => , + component: () => , + filter: ["audio"], + searchSpace: ["groups", "channels"], + isGlobalSearch: false, }, ]; -export const SEARCH_TABS = [ + +export const SEARCH_TABS_MOCK = [ { title: "Chats", component: () => ( diff --git a/app/src/features/search/hooks/useSearch.ts b/app/src/features/search/hooks/useSearch.ts new file mode 100644 index 00000000..8e45f6f8 --- /dev/null +++ b/app/src/features/search/hooks/useSearch.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { SearchRequest, SearchResponseData } from "../types/search"; +import { Search } from "../services/searchAPI"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; + +interface UseSearchResponse { + data: SearchResponseData | undefined; + isLoading: boolean; + error: Error | null; + refetch: () => void; +} + +function useSearch(searchRequest: SearchRequest): UseSearchResponse { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["global-search", searchRequest], + queryFn: () => Search(searchRequest), + enabled: searchRequest?.query.length > 0, + retry: 3, + retryDelay: 1000, + }); + + useEffect(() => { + if (error) { + toast.error("Failed to search"); + } + }, [error]); + + return { data, isLoading, error, refetch }; +} + +export { useSearch }; diff --git a/app/src/features/search/services/searchAPI.ts b/app/src/features/search/services/searchAPI.ts new file mode 100644 index 00000000..7c3aa174 --- /dev/null +++ b/app/src/features/search/services/searchAPI.ts @@ -0,0 +1,40 @@ +import { API_URL } from "@constants"; +import { SearchRequest, SearchResponseData } from "../types/search"; + +async function Search( + searchRequest: SearchRequest, +): Promise { + if (!searchRequest.query) { + return undefined; + } + + const filterString = searchRequest.filter.join(","); + const searchSpaceString = searchRequest.searchSpace.join(","); + + const res = await fetch(`${API_URL}/search/search-request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Session-Token": localStorage.getItem("sessionId") || "", + }, + body: JSON.stringify({ + query: searchRequest.query, + searchSpace: searchSpaceString, + filter: filterString, + isGlobalSearch: searchRequest.isGlobalSearch, + }), + credentials: "include", + }); + + const data = await res.json(); + + if (res.status !== 200) { + throw new Error(data.message); + } + + const resultData = data.data; + + return resultData; +} + +export { Search }; diff --git a/app/src/features/search/types/search.d.ts b/app/src/features/search/types/search.d.ts new file mode 100644 index 00000000..63095703 --- /dev/null +++ b/app/src/features/search/types/search.d.ts @@ -0,0 +1,80 @@ +export type SearchFilter = + | "text" + | "image" + | "GIF" + | "sticker" + | "audio" + | "video" + | "file" + | "link"; + +export type SearchSpace = "chats" | "groups" | "channels"; + +export type SearchRequest = { + query: string; + filter: SearchFilter[]; + searchSpace: SearchSpace[]; + isGlobalSearch: boolean; +}; + +export type SearchResultMessage = { + id: string; + content: string; + media: string; + contentType: SearchFilter; + senderId: { + username: string; + screenFirstName: string; + screenLastName: string; + photo: string; + }; + chatId: { + id: string; + name: string; + numberOfMembers: number; + type: string; + photo?: string; + }; + timestamp: string; +}; + +export type SearchResultGroup = { + id: string; + name: string; + photo: string; + type: string; + numberOfMembers: number; +}; + +export type SearchResultChannel = { + id: string; + name: string; + photo: string; + type: string; + numberOfMembers: number; +}; + +export type SearchResultUser = { + id: string; + username: string; + photo: string; + screenFirstName: string; + screenLastName: string; +}; + +export type SearchResponseData = { + searchResult: SearchResultMessage[]; + globalSearchResult: { + users: SearchResultUser[]; + groups: SearchResultGroup[]; + channels: SearchResultChannel[]; + }; +}; + +export type SearchTabType = { + title: string; + component: () => JSX.Element; + filter: SearchFilter[]; + searchSpace: SearchSpace[]; + isGlobalSearch: boolean; +}; diff --git a/app/src/features/search/utils/searchTabsHelpers.tsx b/app/src/features/search/utils/searchTabsHelpers.tsx new file mode 100644 index 00000000..25e419e0 --- /dev/null +++ b/app/src/features/search/utils/searchTabsHelpers.tsx @@ -0,0 +1,138 @@ +import styled from "styled-components"; +import AudioResult from "../components/result-items/AudioResult"; +import FileResult from "../components/result-items/FileResult"; +import MessageResult from "../components/result-items/MessageResult"; +import { + SearchFilter, + SearchResponseData, + SearchResultChannel, + SearchResultGroup, + SearchResultMessage, + SearchResultUser, +} from "../types/search"; + +const SearchSectionHeader = styled.div` + padding: 1rem; + padding-bottom: 0.5rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-secondary); + background-color: var(--color-background-secondary); +`; + +export const getTitle = ( + item: + | SearchResultMessage + | SearchResultGroup + | SearchResultChannel + | SearchResultUser, +) => { + return ( + (item as SearchResultMessage).chatId?.name || + `${(item as SearchResultUser).screenFirstName} ${(item as SearchResultUser).screenLastName}` || + `${(item as SearchResultMessage).senderId?.screenFirstName} ${(item as SearchResultMessage).senderId?.screenLastName}` + ); +}; + +export const getImage = ( + item: + | SearchResultMessage + | SearchResultGroup + | SearchResultChannel + | SearchResultUser, +) => { + return ( + (item as SearchResultMessage).chatId?.photo || + (item as SearchResultUser | SearchResultChannel | SearchResultChannel) + .photo || + (item as SearchResultMessage).senderId?.photo + ); +}; + +export const hasResults = (data: SearchResponseData | undefined) => { + return ( + !!data && + (data?.searchResult.length > 0 || + data?.globalSearchResult.users.length > 0 || + data?.globalSearchResult.groups.length > 0 || + data?.globalSearchResult.channels.length > 0) + ); +}; + +export const renderMessageResults = ( + messages: SearchResultMessage[], + contentTypes: SearchFilter[], + searchTerm: string, +) => { + return messages + .filter((message) => contentTypes.includes(message?.contentType)) + .map((message) => { + const commonProps = { + searchTerm, + message: message?.content, + title: getTitle(message), + image: getImage(message), + date: message?.timestamp, + }; + + switch (message?.contentType) { + case "text": + return ( + + ); + case "image": + case "video": + case "GIF": + case "sticker": + return ( + + ); + case "link": + return ( + + ); + case "file": + return ( + + ); + case "audio": + return ( + + ); + default: + return null; + } + }); +}; + +export const renderGlobalResults = ( + results: T[], + sectionTitle: string, + renderItem: (item: T) => JSX.Element, +) => { + if (!results || results.length === 0) return null; + return ( + <> + {sectionTitle} + {results.map(renderItem)} + + ); +};