From eb9cf7f361bf9fb6a91ac472e40bb2f128df74c4 Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Thu, 11 Jan 2024 10:39:03 +0200 Subject: [PATCH] fuzzy search on search page --- .../src/Components/SearchBox/SearchBox.tsx | 54 +------------- .../src/Components/Trending/TrendingPosts.tsx | 2 +- .../src/Components/User/FollowListBase.tsx | 9 ++- .../src/Components/User/ProfilePreview.tsx | 11 +-- packages/app/src/Db/FuzzySearch.ts | 5 +- packages/app/src/Hooks/useProfileSearch.tsx | 72 +++++++++++++++++++ packages/app/src/Pages/SearchPage.tsx | 39 +++++----- 7 files changed, 109 insertions(+), 83 deletions(-) create mode 100644 packages/app/src/Hooks/useProfileSearch.tsx diff --git a/packages/app/src/Components/SearchBox/SearchBox.tsx b/packages/app/src/Components/SearchBox/SearchBox.tsx index 17122378..ca7d8bde 100644 --- a/packages/app/src/Components/SearchBox/SearchBox.tsx +++ b/packages/app/src/Components/SearchBox/SearchBox.tsx @@ -1,8 +1,6 @@ import "./SearchBox.css"; -import { unixNow } from "@snort/shared"; import { NostrLink, tryParseNostrLink } from "@snort/system"; -import { socialGraphInstance } from "@snort/system"; import { ChangeEvent, useEffect, useRef, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useLocation, useNavigate } from "react-router-dom"; @@ -10,11 +8,9 @@ import { useLocation, useNavigate } from "react-router-dom"; import Icon from "@/Components/Icons/Icon"; import Spinner from "@/Components/Icons/Spinner"; import ProfileImage from "@/Components/User/ProfileImage"; -import fuzzySearch, { FuzzySearchResult } from "@/Db/FuzzySearch"; +import useProfileSearch from "@/Hooks/useProfileSearch"; import { fetchNip05Pubkey } from "@/Utils/Nip05/Verifier"; -import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../../Feed/TimelineFeed"; - const MAX_RESULTS = 3; export default function SearchBox() { @@ -29,53 +25,7 @@ export default function SearchBox() { const [activeIndex, setActiveIndex] = useState(-1); const resultListRef = useRef(null); - const options: TimelineFeedOptions = { - method: "LIMIT_UNTIL", - window: undefined, - now: unixNow(), - }; - - const subject: TimelineSubject = { - type: "profile_keyword", - discriminator: search, - items: search ? [search] : [], - relay: undefined, - streams: false, - }; - - const { main } = useTimelineFeed(subject, options); - - const [results, setResults] = useState([]); - useEffect(() => { - const searchString = search.trim(); - const fuseResults = fuzzySearch.search(searchString); - - const followDistanceNormalizationFactor = 3; - - const combinedResults = fuseResults.map(result => { - const fuseScore = result.score === undefined ? 1 : result.score; - const followDistance = - socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor; - - const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some( - field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()), - ); - - const boostFactor = startsWithSearchString ? 0.25 : 1; - - const weightForFuseScore = 0.8; - const weightForFollowDistance = 0.2; - - const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor; - - return { ...result, combinedScore }; - }); - - // Sort by combined score, lower is better - combinedResults.sort((a, b) => a.combinedScore - b.combinedScore); - - setResults(combinedResults.map(r => r.item)); - }, [search, main]); + const results = useProfileSearch(search); useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { diff --git a/packages/app/src/Components/Trending/TrendingPosts.tsx b/packages/app/src/Components/Trending/TrendingPosts.tsx index f0581f0a..b87fa989 100644 --- a/packages/app/src/Components/Trending/TrendingPosts.tsx +++ b/packages/app/src/Components/Trending/TrendingPosts.tsx @@ -17,7 +17,7 @@ import useLogin from "@/Hooks/useLogin"; import useModeration from "@/Hooks/useModeration"; import { System } from "@/system"; -export default function TrendingNotes({ count = Infinity, small = false }: { count: number; small: boolean }) { +export default function TrendingNotes({ count = Infinity, small = false }: { count?: number; small?: boolean }) { const api = new NostrBandApi(); const { lang } = useLocale(); const trendingNotesUrl = api.trendingNotesUrl(lang); diff --git a/packages/app/src/Components/User/FollowListBase.tsx b/packages/app/src/Components/User/FollowListBase.tsx index 0f83c095..03c4c864 100644 --- a/packages/app/src/Components/User/FollowListBase.tsx +++ b/packages/app/src/Components/User/FollowListBase.tsx @@ -1,6 +1,6 @@ import { dedupe } from "@snort/shared"; import { HexKey } from "@snort/system"; -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { FormattedMessage } from "react-intl"; import { FollowsFeed } from "@/Cache"; @@ -35,6 +35,8 @@ export default function FollowListBase({ const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows })); const login = useLogin(); + const profilePreviewOptions = useMemo(() => ({ about: showAbout, profileCards: true }), [showAbout]); + async function followAll() { if (publisher) { const newFollows = dedupe([...pubkeys, ...follows.item]); @@ -57,12 +59,13 @@ export default function FollowListBase({ )}
- {pubkeys?.map(a => ( + {pubkeys?.map((a, index) => ( 10} /> ))}
diff --git a/packages/app/src/Components/User/ProfilePreview.tsx b/packages/app/src/Components/User/ProfilePreview.tsx index 083551e4..b6cfe17f 100644 --- a/packages/app/src/Components/User/ProfilePreview.tsx +++ b/packages/app/src/Components/User/ProfilePreview.tsx @@ -2,7 +2,7 @@ import "./ProfilePreview.css"; import { HexKey, UserMetadata } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; -import { ReactNode } from "react"; +import { memo, ReactNode } from "react"; import { useInView } from "react-intersection-observer"; import FollowButton from "@/Components/User/FollowButton"; @@ -19,10 +19,11 @@ export interface ProfilePreviewProps { actions?: ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void; + waitUntilInView?: boolean; } -export default function ProfilePreview(props: ProfilePreviewProps) { +export default memo(function ProfilePreview(props: ProfilePreviewProps) { const pubkey = props.pubkey; - const { ref, inView } = useInView({ triggerOnce: true }); + const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "500px" }); const user = useUserProfile(inView ? pubkey : undefined); const options = { about: true, @@ -43,7 +44,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) { className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref} onClick={handleClick}> - {inView && ( + {(!props.waitUntilInView || inView) && ( <> ); -} +}); diff --git a/packages/app/src/Db/FuzzySearch.ts b/packages/app/src/Db/FuzzySearch.ts index b8921223..5c2abce9 100644 --- a/packages/app/src/Db/FuzzySearch.ts +++ b/packages/app/src/Db/FuzzySearch.ts @@ -1,4 +1,4 @@ -import { CachedMetadata } from "@snort/system"; +import { CachedMetadata, NostrEvent } from "@snort/system"; import Fuse from "fuse.js"; export type FuzzySearchResult = { @@ -16,7 +16,7 @@ const fuzzySearch = new Fuse([], { const profileTimestamps = new Map(); // is this somewhere in cache? -export const addEventToFuzzySearch = ev => { +export const addEventToFuzzySearch = (ev: NostrEvent) => { if (ev.kind !== 0) { return; } @@ -26,6 +26,7 @@ export const addEventToFuzzySearch = ev => { if (existing > ev.created_at) { return; } + // for some reason we get duplicates even though this should be removing existing profile on update fuzzySearch.remove(doc => doc.pubkey === ev.pubkey); } profileTimestamps.set(ev.pubkey, ev.created_at); diff --git a/packages/app/src/Hooks/useProfileSearch.tsx b/packages/app/src/Hooks/useProfileSearch.tsx new file mode 100644 index 00000000..7fb9ac85 --- /dev/null +++ b/packages/app/src/Hooks/useProfileSearch.tsx @@ -0,0 +1,72 @@ +import { unixNow } from "@snort/shared"; +import { socialGraphInstance } from "@snort/system"; +import { useMemo } from "react"; + +import fuzzySearch from "@/Db/FuzzySearch"; +import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed"; + +export default function useProfileSearch(search: string) { + const options: TimelineFeedOptions = useMemo( + () => ({ + method: "LIMIT_UNTIL", + window: undefined, + now: unixNow(), + }), + [], + ); + + const subject: TimelineSubject = useMemo( + () => ({ + type: "profile_keyword", + discriminator: search, + items: search ? [search] : [], + relay: undefined, + streams: false, + }), + [search], + ); + + const { main } = useTimelineFeed(subject, options); + + const results = useMemo(() => { + const searchString = search.trim(); + const fuseResults = fuzzySearch.search(searchString); + + const followDistanceNormalizationFactor = 3; + const seenIds = new Set(); + + const combinedResults = fuseResults + .map(result => { + const fuseScore = result.score === undefined ? 1 : result.score; + const followDistance = + socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor; + + const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some( + field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()), + ); + + const boostFactor = startsWithSearchString ? 0.25 : 1; + + const weightForFuseScore = 0.8; + const weightForFollowDistance = 0.2; + + const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor; + + return { ...result, combinedScore }; + }) + // Sort by combined score, lower is better + .sort((a, b) => a.combinedScore - b.combinedScore) + .filter(r => { + // for some reason we get duplicates even though fuzzySearch should be removing existing profile on update + if (seenIds.has(r.item.pubkey)) { + return false; + } + seenIds.add(r.item.pubkey); + return true; + }); + + return combinedResults.map(r => r.item); + }, [search, main]); + + return results; +} diff --git a/packages/app/src/Pages/SearchPage.tsx b/packages/app/src/Pages/SearchPage.tsx index b433121c..d5812ebd 100644 --- a/packages/app/src/Pages/SearchPage.tsx +++ b/packages/app/src/Pages/SearchPage.tsx @@ -1,17 +1,25 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate, useParams } from "react-router-dom"; import Timeline from "@/Components/Feed/Timeline"; -import UsersFeed from "@/Components/Feed/UsersFeed"; import Tabs, { Tab } from "@/Components/Tabs/Tabs"; import TrendingNotes from "@/Components/Trending/TrendingPosts"; import TrendingUsers from "@/Components/Trending/TrendingUsers"; +import FollowListBase from "@/Components/User/FollowListBase"; +import useProfileSearch from "@/Hooks/useProfileSearch"; import { debounce } from "@/Utils"; const NOTES = 0; const PROFILES = 1; +const Profiles = ({ keyword }: { keyword: string }) => { + const results = useProfileSearch(keyword); + const ids = useMemo(() => results.map(r => r.pubkey), [results]); + const content = keyword ? : ; + return
{content}
; +}; + const SearchPage = () => { const params = useParams(); const { formatMessage } = useIntl(); @@ -37,8 +45,8 @@ const SearchPage = () => { }, [keyword]); useEffect(() => { - setKeyword(params.keyword); - setSearch(params.keyword); // Also update the search input field + setKeyword(params.keyword ?? ""); + setSearch(params.keyword ?? ""); // Also update the search input field }, [params.keyword]); useEffect(() => { @@ -46,34 +54,25 @@ const SearchPage = () => { }, [search]); function tabContent() { + if (tab.value === PROFILES) { + return ; + } + if (!keyword) { - switch (tab.value) { - case PROFILES: - return ; - case NOTES: - return ; - } - return null; + return ; } - if (tab.value == PROFILES) { - // render UsersFeed - return ; - } - - const pf = tab.value == PROFILES; return ( <> {sortOptions()}