forked from Kieran/snort
fuse fuzzy user search
This commit is contained in:
parent
2c14a8f404
commit
47aec5437d
@ -21,6 +21,7 @@
|
|||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"dexie": "^3.2.4",
|
"dexie": "^3.2.4",
|
||||||
"emojilib": "^3.0.10",
|
"emojilib": "^3.0.10",
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
"highlight.js": "^11.8.0",
|
"highlight.js": "^11.8.0",
|
||||||
"light-bolt11-decoder": "^2.1.0",
|
"light-bolt11-decoder": "^2.1.0",
|
||||||
"marked": "^9.1.0",
|
"marked": "^9.1.0",
|
||||||
|
@ -8,7 +8,9 @@ import { NostrLink, tryParseNostrLink } from "@snort/system";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { unixNow } from "@snort/shared";
|
import { unixNow } from "@snort/shared";
|
||||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
|
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;
|
const MAX_RESULTS = 3;
|
||||||
|
|
||||||
@ -39,6 +41,37 @@ export default function SearchBox() {
|
|||||||
|
|
||||||
const { main } = useTimelineFeed(subject, options);
|
const { main } = useTimelineFeed(subject, options);
|
||||||
|
|
||||||
|
const [results, setResults] = useState<FuzzySearchResult[]>([]);
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@ -92,8 +125,8 @@ export default function SearchBox() {
|
|||||||
case "Enter":
|
case "Enter":
|
||||||
if (activeIndex === 0) {
|
if (activeIndex === 0) {
|
||||||
navigate(`/search/${encodeURIComponent(search)}`);
|
navigate(`/search/${encodeURIComponent(search)}`);
|
||||||
} else if (activeIndex > 0 && main) {
|
} else if (activeIndex > 0 && results) {
|
||||||
const selectedResult = main[activeIndex - 1];
|
const selectedResult = results[activeIndex - 1];
|
||||||
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`);
|
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`);
|
||||||
} else {
|
} else {
|
||||||
executeSearch();
|
executeSearch();
|
||||||
@ -101,7 +134,7 @@ export default function SearchBox() {
|
|||||||
break;
|
break;
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
e.preventDefault();
|
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;
|
break;
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -143,7 +176,7 @@ export default function SearchBox() {
|
|||||||
onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}>
|
onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}>
|
||||||
<FormattedMessage defaultMessage="Search notes" id="EJbFi7" />: <b>{search}</b>
|
<FormattedMessage defaultMessage="Search notes" id="EJbFi7" />: <b>{search}</b>
|
||||||
</div>
|
</div>
|
||||||
{main?.slice(0, MAX_RESULTS).map((result, idx) => (
|
{results?.slice(0, MAX_RESULTS).map((result, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`p-2 cursor-pointer ${
|
className={`p-2 cursor-pointer ${
|
||||||
@ -152,7 +185,7 @@ export default function SearchBox() {
|
|||||||
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => setActiveIndex(idx + 1)}>
|
onMouseEnter={() => setActiveIndex(idx + 1)}>
|
||||||
<Note data={result} depth={0} related={[]} />
|
<ProfileImage pubkey={result.pubkey} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
43
packages/app/src/FuzzySearch.ts
Normal file
43
packages/app/src/FuzzySearch.ts
Normal file
@ -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<FuzzySearchResult>([], {
|
||||||
|
keys: ["name", "username", { name: "nip05", weight: 0.5 }],
|
||||||
|
threshold: 0.3,
|
||||||
|
// sortFn here?
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileTimestamps = new Map<string, number>(); // 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);
|
||||||
|
}
|
||||||
|
});
|
@ -77,7 +77,7 @@ const NetworkGraph = () => {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: { key: string; }) => {
|
const handleKeyDown = (event: { key: string }) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
handleCloseGraph();
|
handleCloseGraph();
|
||||||
}
|
}
|
||||||
|
@ -59,6 +59,7 @@ import { AboutPage } from "@/Pages/About";
|
|||||||
import { OnboardingRoutes } from "@/Pages/onboarding";
|
import { OnboardingRoutes } from "@/Pages/onboarding";
|
||||||
import { setupWebLNWalletConfig } from "@/Wallet/WebLN";
|
import { setupWebLNWalletConfig } from "@/Wallet/WebLN";
|
||||||
import { Wallets } from "@/Wallet";
|
import { Wallets } from "@/Wallet";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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<FuzzySearchResult>([], {
|
||||||
|
keys: ["name", "display_name", { name: "nip05", weight: 0.5 }],
|
||||||
|
threshold: 0.3,
|
||||||
|
// sortFn here?
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileTimestamps = new Map<string, number>();
|
||||||
|
|
||||||
|
// how to also add entries from ProfileCache?
|
||||||
System.on("event", ev => {
|
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) {
|
if (ev.kind === 3) {
|
||||||
socialGraphInstance.handleFollowEvent(ev);
|
socialGraphInstance.handleFollowEvent(ev);
|
||||||
}
|
}
|
||||||
|
@ -2921,6 +2921,7 @@ __metadata:
|
|||||||
emojilib: ^3.0.10
|
emojilib: ^3.0.10
|
||||||
eslint: ^8.48.0
|
eslint: ^8.48.0
|
||||||
eslint-plugin-formatjs: ^4.11.3
|
eslint-plugin-formatjs: ^4.11.3
|
||||||
|
fuse.js: ^7.0.0
|
||||||
highlight.js: ^11.8.0
|
highlight.js: ^11.8.0
|
||||||
light-bolt11-decoder: ^2.1.0
|
light-bolt11-decoder: ^2.1.0
|
||||||
marked: ^9.1.0
|
marked: ^9.1.0
|
||||||
@ -6120,6 +6121,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"gensync@npm:^1.0.0-beta.2":
|
||||||
version: 1.0.0-beta.2
|
version: 1.0.0-beta.2
|
||||||
resolution: "gensync@npm:1.0.0-beta.2"
|
resolution: "gensync@npm:1.0.0-beta.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user