From 47aec5437dd8dc03d70e9f8f2ab70743daaef54a Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Fri, 24 Nov 2023 15:50:40 +0200 Subject: [PATCH] fuse fuzzy user search --- packages/app/package.json | 1 + packages/app/src/Element/SearchBox.tsx | 45 +++++++++++++++++++++---- packages/app/src/FuzzySearch.ts | 43 +++++++++++++++++++++++ packages/app/src/Pages/NetworkGraph.tsx | 2 +- packages/app/src/index.tsx | 36 ++++++++++++++++++++ yarn.lock | 8 +++++ 6 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/FuzzySearch.ts diff --git a/packages/app/package.json b/packages/app/package.json index b91e59e60..2a59b9daf 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -21,6 +21,7 @@ "debug": "^4.3.4", "dexie": "^3.2.4", "emojilib": "^3.0.10", + "fuse.js": "^7.0.0", "highlight.js": "^11.8.0", "light-bolt11-decoder": "^2.1.0", "marked": "^9.1.0", diff --git a/packages/app/src/Element/SearchBox.tsx b/packages/app/src/Element/SearchBox.tsx index 0c516d36f..fd021c038 100644 --- a/packages/app/src/Element/SearchBox.tsx +++ b/packages/app/src/Element/SearchBox.tsx @@ -8,7 +8,9 @@ import { NostrLink, tryParseNostrLink } from "@snort/system"; import { useLocation, useNavigate } from "react-router-dom"; import { unixNow } from "@snort/shared"; import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed"; -import Note from "./Event/Note"; +import { fuzzySearch, FuzzySearchResult } from "@/index"; +import ProfileImage from "@/Element/User/ProfileImage"; +import { socialGraphInstance } from "@snort/system"; const MAX_RESULTS = 3; @@ -39,6 +41,37 @@ export default function SearchBox() { 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]); + + useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -92,8 +125,8 @@ export default function SearchBox() { case "Enter": if (activeIndex === 0) { navigate(`/search/${encodeURIComponent(search)}`); - } else if (activeIndex > 0 && main) { - const selectedResult = main[activeIndex - 1]; + } else if (activeIndex > 0 && results) { + const selectedResult = results[activeIndex - 1]; navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`); } else { executeSearch(); @@ -101,7 +134,7 @@ export default function SearchBox() { break; case "ArrowDown": e.preventDefault(); - setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, main ? main.length : 0))); + setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, results ? results.length : 0))); break; case "ArrowUp": e.preventDefault(); @@ -143,7 +176,7 @@ export default function SearchBox() { onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}> : {search} - {main?.slice(0, MAX_RESULTS).map((result, idx) => ( + {results?.slice(0, MAX_RESULTS).map((result, idx) => (
setActiveIndex(idx + 1)}> - +
))} diff --git a/packages/app/src/FuzzySearch.ts b/packages/app/src/FuzzySearch.ts new file mode 100644 index 000000000..9ffe810c6 --- /dev/null +++ b/packages/app/src/FuzzySearch.ts @@ -0,0 +1,43 @@ +import Fuse from "fuse.js"; +import { socialGraphInstance } from "@snort/system"; +import { System } from "."; + +export type FuzzySearchResult = { + pubkey: string; + name?: string; + username?: string; + nip05?: string; +}; + +export const fuzzySearch = new Fuse([], { + keys: ["name", "username", { name: "nip05", weight: 0.5 }], + threshold: 0.3, + // sortFn here? +}); + +const profileTimestamps = new Map(); // is this somewhere in cache? + +System.on("event", ev => { + if (ev.kind === 0) { + const existing = profileTimestamps.get(ev.pubkey); + if (existing) { + if (existing > ev.created_at) { + return; + } + fuzzySearch.remove(doc => doc.pubkey === ev.pubkey); + } + profileTimestamps.set(ev.pubkey, ev.created_at); + try { + const data = JSON.parse(ev.content); + if (ev.pubkey && (data.name || data.username || data.nip05)) { + data.pubkey = ev.pubkey; + fuzzySearch.add(data); + } + } catch (e) { + console.error(e); + } + } + if (ev.kind === 3) { + socialGraphInstance.handleFollowEvent(ev); + } +}); diff --git a/packages/app/src/Pages/NetworkGraph.tsx b/packages/app/src/Pages/NetworkGraph.tsx index 8e4506c35..31ef82997 100644 --- a/packages/app/src/Pages/NetworkGraph.tsx +++ b/packages/app/src/Pages/NetworkGraph.tsx @@ -77,7 +77,7 @@ const NetworkGraph = () => { setOpen(false); }; - const handleKeyDown = (event: { key: string; }) => { + const handleKeyDown = (event: { key: string }) => { if (event.key === "Escape") { handleCloseGraph(); } diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index b3696b87a..e10f0f44e 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -59,6 +59,7 @@ import { AboutPage } from "@/Pages/About"; import { OnboardingRoutes } from "@/Pages/onboarding"; import { setupWebLNWalletConfig } from "@/Wallet/WebLN"; import { Wallets } from "@/Wallet"; +import Fuse from "fuse.js"; declare global { interface Window { @@ -111,7 +112,42 @@ System.on("auth", async (c, r, cb) => { } }); +export type FuzzySearchResult = { + pubkey: string; + name?: string; + display_name?: string; + nip05?: string; +}; + +export const fuzzySearch = new Fuse([], { + keys: ["name", "display_name", { name: "nip05", weight: 0.5 }], + threshold: 0.3, + // sortFn here? +}); + +const profileTimestamps = new Map(); + +// how to also add entries from ProfileCache? System.on("event", ev => { + if (ev.kind === 0) { + const existing = profileTimestamps.get(ev.pubkey); + if (existing) { + if (existing > ev.created_at) { + return; + } + fuzzySearch.remove(doc => doc.pubkey === ev.pubkey); + } + profileTimestamps.set(ev.pubkey, ev.created_at); + try { + const data = JSON.parse(ev.content); + if (ev.pubkey && (data.name || data.display_name || data.nip05)) { + data.pubkey = ev.pubkey; + fuzzySearch.add(data); + } + } catch (e) { + console.error(e); + } + } if (ev.kind === 3) { socialGraphInstance.handleFollowEvent(ev); } diff --git a/yarn.lock b/yarn.lock index 926bbe6b0..3b608f395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2921,6 +2921,7 @@ __metadata: emojilib: ^3.0.10 eslint: ^8.48.0 eslint-plugin-formatjs: ^4.11.3 + fuse.js: ^7.0.0 highlight.js: ^11.8.0 light-bolt11-decoder: ^2.1.0 marked: ^9.1.0 @@ -6120,6 +6121,13 @@ __metadata: languageName: node linkType: hard +"fuse.js@npm:^7.0.0": + version: 7.0.0 + resolution: "fuse.js@npm:7.0.0" + checksum: d15750efec1808370c0cae92ec9473aa7261c59bca1f15f1cf60039ba6f804b8f95340b5cabd83a4ef55839c1034764856e0128e443921f072aa0d8a20e4cacf + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2"