Skip to content

Commit

Permalink
feat: update search algo, use uFuzzy (#461)
Browse files Browse the repository at this point in the history
* feat: update search algo, use uFuzzy

* feat: update sort

* feat: update search and initial sorting

* feat: update opts
  • Loading branch information
smbdy authored Jun 5, 2024
1 parent 2dc0566 commit bf12e26
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 52 deletions.
15 changes: 6 additions & 9 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
},
"dependencies": {
"@bgd-labs/js-utils": "^1.1.1",
"@leeoniya/ufuzzy": "^1.0.14",
"clsx": "^2.1.0",
"fuse.js": "^7.0.0",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
Expand Down
61 changes: 59 additions & 2 deletions ui/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ import { type SearchItem } from '@/types';
import logo from '@/assets/logo.svg';
import { Address, isAddress } from 'viem';

const PRODUCTION_CHAIN_IDS = [
1, 8453, 42161, 43114, 250, 1666600000, 10, 137, 1088, 100, 56, 534352,
];
const VERSION_PRIORITY: { [key: string]: number } = {
AaveV3: 1,
AaveV2: 2,
AaveV1: 3,
};

function getVersionPriority(name: string): number {
for (const version in VERSION_PRIORITY) {
if (name.startsWith(version)) {
return VERSION_PRIORITY[version];
}
}
return 4;
}

const TAG_MAP: Record<string, string[]> = {
S_TOKEN: ['stable', 'debt'],
V_TOKEN: ['variable', 'debt'],
Expand Down Expand Up @@ -53,14 +71,53 @@ function flattenObject(
}

const addresses = flattenObject(addressBook);
const sortedAddresses = addresses.sort((a, b) => {
const aInProduction = PRODUCTION_CHAIN_IDS.includes(a.chainId ?? 0);
const bInProduction = PRODUCTION_CHAIN_IDS.includes(b.chainId ?? 0);

if (aInProduction && !bInProduction) {
return -1;
} else if (!aInProduction && bInProduction) {
return 1;
}

const aVersionPriority = getVersionPriority(a.searchPath);
const bVersionPriority = getVersionPriority(b.searchPath);

if (aVersionPriority !== bVersionPriority) {
return aVersionPriority - bVersionPriority;
}

const pathLengthDiff = a.path.length - b.path.length;
if (pathLengthDiff !== 0) {
return pathLengthDiff;
}

// A dirty hack to sligthly prioritize mainnet addresses
const aSearchPathLength = a.chainId === 1 ? a.searchPath.length - 6 : a.searchPath.length;
const bSearchPathLength = b.chainId === 1 ? b.searchPath.length - 6 : b.searchPath.length;

const searchPathLengthDiff = aSearchPathLength - bSearchPathLength;
if (searchPathLengthDiff !== 0) {
return searchPathLengthDiff;
}

return 0;
});
const searchPaths = sortedAddresses.map((a) => a.searchPath);

export default function Home() {
return (
<>
<main className="flex min-h-screen flex-col items-center justify-start pl-4 pr-2 pb-8 pt-16 sm:pt-36">
<Image src={logo} alt="Aave Search" className="mb-7 w-36 sm:w-44" />
<Image
src={logo}
alt="Aave Search"
className="mb-7 w-36 sm:w-44"
priority
/>
<Suspense fallback={<SearchSkeleton />}>
<Search addresses={addresses} />
<Search addresses={addresses} searchPaths={searchPaths} />
</Suspense>
<Footer />
</main>
Expand Down
67 changes: 44 additions & 23 deletions ui/src/components/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,69 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { cn } from '@/utils/cn';
import { type SearchItem } from '@/types';
import Fuse, { FuseResult } from 'fuse.js';
import { Box } from './Box';
import { SearchResult } from './SearchResult';
import uFuzzy from '@leeoniya/ufuzzy';

const fuseOptions = {
includeScore: true,
keys: ['searchPath'],
threshold: 0.6,
ignoreLocation: true,
useExtendedSearch: true,
};
const SEARCH_LIMIT = 100;
const DEBOUNCE_TIME = 100;

const DEBOUNCE_TIME = 150;
const getResultText = (results: any[], limit: number) => {
const resultCount = results.length;
if (resultCount === 0) return '';
const displayCount = resultCount < limit ? resultCount : `${limit}+`;
return `${displayCount} result${resultCount === 1 ? '' : 's'}`;
};

export const Search = ({ addresses }: { addresses: SearchItem[] }) => {
export const Search = ({
addresses,
searchPaths,
}: {
addresses: SearchItem[];
searchPaths: string[];
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();

const searchString = searchParams.get('q');

const [search, setSearch] = useState(searchString || '');
const [results, setResults] = useState<FuseResult<SearchItem>[]>([]);
const [results, setResults] = useState<SearchItem[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);

const refs = useRef<(HTMLAnchorElement | null)[]>([]);
const inputRef = useRef<HTMLInputElement>(null);

const timeoutId = useRef<ReturnType<typeof setInterval> | null>(null);

const fuseIndex = useMemo(
() => Fuse.createIndex(fuseOptions.keys, addresses),
[addresses],
);
const uf = useMemo(() => {
const opts = {
intraMode: 1,
intraChars: "[a-z\\d'_]"
};
return new uFuzzy(opts);
}, []);

const performSearch = useCallback(
(search: string) => {
const fuse = new Fuse(addresses, fuseOptions, fuseIndex);
if (search) {
// const limitedSearch = search.slice(0, SEARCH_LIMIT);
setResults(fuse.search(search, { limit: 100 }));
} else {
setResults([]);
const searchWords = search.trim().split(/\s+/);

let results = [];
for (let idx = 0; idx < searchPaths.length; idx++) {
const path = searchPaths[idx];
const isMatch = searchWords.every((word) => {
const idxs = uf.filter([path], word);
return idxs && idxs.length > 0;
});

if (isMatch) {
results.push(addresses[idx]);
}
}

setResults(results.slice(0, SEARCH_LIMIT));
},
[addresses, fuseIndex],
[searchPaths, addresses, uf],
);

const handleKeyDown = (event: React.KeyboardEvent) => {
Expand Down Expand Up @@ -141,11 +159,14 @@ export const Search = ({ addresses }: { addresses: SearchItem[] }) => {
ref={inputRef}
/>
</div>
<div className="absolute top-0 right-5 h-full flex items-center translate-y-[2px] justify-center text-brand-500 text-sm">
{getResultText(results, SEARCH_LIMIT)}
</div>
</Box>
{results.length !== 0 &&
results.map((result, index) => (
<SearchResult
key={result.item.searchPath}
key={result.searchPath}
result={result}
ref={(el) => (refs.current[index] = el)}
tabIndex={index === activeIndex ? 0 : -1}
Expand Down
47 changes: 30 additions & 17 deletions ui/src/components/SearchResult.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,67 @@
'use client';

import { useState, forwardRef } from 'react';
import { FuseResult } from 'fuse.js';
import { useState, useRef, forwardRef, useCallback, useEffect } from 'react';
import { Box } from '@/components/Box';
import { ChainIcon } from '@/components/ChainIcon';
import { cn } from '@/utils/cn';
import { type SearchItem } from '@/types';

type SearchResultProps = {
result: FuseResult<SearchItem>;
result: SearchItem;
tabIndex: number;
};

const COPY_TIMEOUT = 1500;

export const SearchResult = forwardRef<HTMLAnchorElement, SearchResultProps>(
({ result, tabIndex }, ref) => {
const [copied, setCopied] = useState(false);
const copyTimeoutId = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
return () => {
if (copyTimeoutId.current) {
clearTimeout(copyTimeoutId.current);
}
};
}, []);

const handleCopyClick = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
await navigator.clipboard.writeText(result.item.value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
};
const handleCopyClick = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
await navigator.clipboard.writeText(result.value);
setCopied(true);
copyTimeoutId.current = setTimeout(() => {
setCopied(false);
}, COPY_TIMEOUT);
},
[result.value],
);

return (
<Box className="border-b-brand-900" isHoverable>
<a
className="px-3 pt-4 pb-4 flex gap-3 cursor-pointer outline-none"
href={result.item.link}
href={result.link}
target="_blank"
ref={ref}
tabIndex={tabIndex}
>
<ChainIcon chainId={result.item.chainId} />
<ChainIcon chainId={result.chainId} />
<div className="leading-none">
<div className="mb-2 flex flex-wrap gap-1">
{result.item.path.map((p, i) => (
{result.path.map((p, i) => (
<span
key={i}
key={p}
className="text-brand-900 text-xs font-semibold leading-none rounded-sm bg-brand-100 border border-brand-300 py-1 px-1.5 truncate max-w-60 sm:max-w-full"
>
{p}
</span>
))}
</div>
<div className="font-mono text-xs text-brand-500 truncate px-0.5 w-60 sm:w-full">
{result.item.value}
{result.value}
</div>
</div>
<button
Expand Down

0 comments on commit bf12e26

Please sign in to comment.