Add search bar to BlockList
6834 - Only supports channel-name search, per 6834. Channel-title search would probably be too heavy on the client side. - Fuzzy search is possible, but is too slow on huge lists. Ended up with a simpler `matchSorter.rankings.CONTAINS`, which I think would cover typical cases.
This commit is contained in:
parent
868df44d2a
commit
03d44f772d
3 changed files with 118 additions and 2 deletions
|
@ -56,6 +56,7 @@
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"humanize-duration": "^3.27.0",
|
"humanize-duration": "^3.27.0",
|
||||||
"if-env": "^1.0.4",
|
"if-env": "^1.0.4",
|
||||||
|
"match-sorter": "^6.3.0",
|
||||||
"parse-duration": "^1.0.0",
|
"parse-duration": "^1.0.0",
|
||||||
"react-datetime-picker": "^3.2.1",
|
"react-datetime-picker": "^3.2.1",
|
||||||
"react-plastic": "^1.1.1",
|
"react-plastic": "^1.1.1",
|
||||||
|
|
|
@ -1,13 +1,34 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList, ComboboxOption } from '@reach/combobox';
|
||||||
|
// import '@reach/combobox/styles.css'; --> 'scss/third-party.scss'
|
||||||
|
import { matchSorter } from 'match-sorter';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import Button from 'component/button';
|
import Button from 'component/button';
|
||||||
import ClaimList from 'component/claimList';
|
import ClaimList from 'component/claimList';
|
||||||
|
import Icon from 'component/common/icon';
|
||||||
import Paginate from 'component/common/paginate';
|
import Paginate from 'component/common/paginate';
|
||||||
import Yrbl from 'component/yrbl';
|
import Yrbl from 'component/yrbl';
|
||||||
|
import * as ICONS from 'constants/icons';
|
||||||
|
import useThrottle from 'effects/use-throttle';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function reduceUriToChannelName(uri: string) {
|
||||||
|
// 'parseURI' is too slow to handle a large list. Since our list should be
|
||||||
|
// kosher in the first place, just do a quick substring call. Add a
|
||||||
|
// try-catch just in case.
|
||||||
|
try {
|
||||||
|
return uri.substring(uri.indexOf('@') + 1, uri.indexOf('#'));
|
||||||
|
} catch {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ****************************************************************************
|
||||||
|
// BlockList
|
||||||
|
// ****************************************************************************
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
uris: Array<string>,
|
uris: Array<string>,
|
||||||
help: string,
|
help: string,
|
||||||
|
@ -26,6 +47,7 @@ export default function BlockList(props: Props) {
|
||||||
const hasLocalList = localList && localList.length > 0;
|
const hasLocalList = localList && localList.length > 0;
|
||||||
const justBlocked = list && localList && localList.length < list.length;
|
const justBlocked = list && localList && localList.length < list.length;
|
||||||
|
|
||||||
|
const [searchList, setSearchList] = React.useState(null); // null: not searching; []: no results;
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
|
|
||||||
let totalPages = 0;
|
let totalPages = 0;
|
||||||
|
@ -45,6 +67,14 @@ export default function BlockList(props: Props) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSearchSuggestion(suggestion: string) {
|
||||||
|
return reduceUriToChannelName(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSearchResults(results: ?Array<string>) {
|
||||||
|
setSearchList(results);
|
||||||
|
}
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
|
@ -87,9 +117,17 @@ export default function BlockList(props: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="help--notice">{help}</div>
|
<div className="help--notice">{help}</div>
|
||||||
<div className={classnames('block-list', className)}>
|
<div className="section">
|
||||||
|
<SearchList
|
||||||
|
list={localList}
|
||||||
|
placeholder={__('e.g. odysee')}
|
||||||
|
formatter={formatSearchSuggestion}
|
||||||
|
onResultsUpdated={filterSearchResults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classnames('section block-list', className)}>
|
||||||
<ClaimList
|
<ClaimList
|
||||||
uris={paginatedLocalList}
|
uris={searchList || paginatedLocalList}
|
||||||
showUnresolvedClaims
|
showUnresolvedClaims
|
||||||
showHiddenByUser
|
showHiddenByUser
|
||||||
hideMenu
|
hideMenu
|
||||||
|
@ -100,3 +138,67 @@ export default function BlockList(props: Props) {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ****************************************************************************
|
||||||
|
// SearchList
|
||||||
|
// ****************************************************************************
|
||||||
|
|
||||||
|
type LsbProps = {
|
||||||
|
list: ?Array<string>,
|
||||||
|
placeholder?: string,
|
||||||
|
formatter?: (suggestion: string) => string,
|
||||||
|
onResultsUpdated?: (?Array<string>) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
function SearchList(props: LsbProps) {
|
||||||
|
const { list, placeholder, formatter, onResultsUpdated } = props;
|
||||||
|
const [term, setTerm] = React.useState('');
|
||||||
|
const results = useAuthorMatch(term, list);
|
||||||
|
const handleChange = (event) => setTerm(event.target.value);
|
||||||
|
const handleSelect = (e) => setTerm(e);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (onResultsUpdated) {
|
||||||
|
onResultsUpdated(results);
|
||||||
|
}
|
||||||
|
}, [results]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wunderbar__wrapper">
|
||||||
|
<label>{__('Search blocked channel name')}</label>
|
||||||
|
<Combobox className="wunderbar" onSelect={handleSelect}>
|
||||||
|
<Icon icon={ICONS.SEARCH} />
|
||||||
|
<ComboboxInput selectOnClick className="wunderbar__input" onChange={handleChange} placeholder={placeholder} />
|
||||||
|
{results && (
|
||||||
|
<ComboboxPopover className="wunderbar__suggestions" portal={false}>
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<ComboboxList>
|
||||||
|
{results.slice(0, 10).map((result, index) => (
|
||||||
|
<ComboboxOption
|
||||||
|
className="wunderbar__more-results"
|
||||||
|
key={index}
|
||||||
|
value={formatter ? formatter(result) : result}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ComboboxList>
|
||||||
|
) : (
|
||||||
|
<span style={{ display: 'block', margin: 8 }}>{__('No results found')}</span>
|
||||||
|
)}
|
||||||
|
</ComboboxPopover>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuthorMatch(term, list) {
|
||||||
|
const throttledTerm = useThrottle(term, 200);
|
||||||
|
return React.useMemo(() => {
|
||||||
|
return !throttledTerm || throttledTerm.trim() === ''
|
||||||
|
? null
|
||||||
|
: matchSorter(list, throttledTerm, {
|
||||||
|
keys: [(item) => reduceUriToChannelName(item)],
|
||||||
|
threshold: matchSorter.rankings.CONTAINS,
|
||||||
|
});
|
||||||
|
}, [throttledTerm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
}
|
||||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -10707,6 +10707,14 @@ marked@^1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.8.tgz#5008ece15cfa43e653e85845f3525af4beb6bdd4"
|
resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.8.tgz#5008ece15cfa43e653e85845f3525af4beb6bdd4"
|
||||||
integrity sha512-lzmFjGnzWHkmbk85q/ILZjFoHHJIQGF+SxGEfIdGk/XhiTPhqGs37gbru6Kkd48diJnEyYwnG67nru0Z2gQtuQ==
|
integrity sha512-lzmFjGnzWHkmbk85q/ILZjFoHHJIQGF+SxGEfIdGk/XhiTPhqGs37gbru6Kkd48diJnEyYwnG67nru0Z2gQtuQ==
|
||||||
|
|
||||||
|
match-sorter@^6.3.0:
|
||||||
|
version "6.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.0.tgz#454a1b31ed218cddbce6231a0ecb5fdc549fed01"
|
||||||
|
integrity sha512-efYOf/wUpNb8FgNY+cOD2EIJI1S5I7YPKsw0LBp7wqPh5pmMS6i/wr3ZWwfwrAw1NvqTA2KUReVRWDX84lUcOQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.12.5"
|
||||||
|
remove-accents "0.4.2"
|
||||||
|
|
||||||
matcher@^3.0.0:
|
matcher@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
|
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
|
||||||
|
@ -14047,6 +14055,11 @@ remark@^9.0.0:
|
||||||
remark-stringify "^5.0.0"
|
remark-stringify "^5.0.0"
|
||||||
unified "^6.0.0"
|
unified "^6.0.0"
|
||||||
|
|
||||||
|
remove-accents@0.4.2:
|
||||||
|
version "0.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
|
||||||
|
integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=
|
||||||
|
|
||||||
remove-markdown@^0.3.0:
|
remove-markdown@^0.3.0:
|
||||||
version "0.3.0"
|
version "0.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98"
|
resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98"
|
||||||
|
|
Loading…
Add table
Reference in a new issue