diff --git a/package.json b/package.json index f748de73d..2237e2b60 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "express": "^4.17.1", "humanize-duration": "^3.27.0", "if-env": "^1.0.4", + "match-sorter": "^6.3.0", "parse-duration": "^1.0.0", "react-datetime-picker": "^3.2.1", "react-plastic": "^1.1.1", diff --git a/ui/component/blockList/index.js b/ui/component/blockList/index.js new file mode 100644 index 000000000..e9f61035d --- /dev/null +++ b/ui/component/blockList/index.js @@ -0,0 +1,2 @@ +import BlockList from './view'; +export default BlockList; diff --git a/ui/component/blockList/view.jsx b/ui/component/blockList/view.jsx new file mode 100644 index 000000000..3df4fb6db --- /dev/null +++ b/ui/component/blockList/view.jsx @@ -0,0 +1,204 @@ +// @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 classnames from 'classnames'; +import Button from 'component/button'; +import ClaimList from 'component/claimList'; +import Icon from 'component/common/icon'; +import Paginate from 'component/common/paginate'; +import Yrbl from 'component/yrbl'; +import * as ICONS from 'constants/icons'; +import useThrottle from 'effects/use-throttle'; + +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 = { + uris: Array, + help: string, + titleEmptyList: string, + subtitleEmptyList: string, + getActionButtons?: (url: string) => React$Node, + className: ?string, +}; + +export default function BlockList(props: Props) { + const { uris: list, help, titleEmptyList, subtitleEmptyList, getActionButtons, className } = props; + + // Keep a local list to allow for undoing actions in this component + const [localList, setLocalList] = React.useState(undefined); + const stringifiedList = JSON.stringify(list); + const hasLocalList = localList && localList.length > 0; + 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); + + let totalPages = 0; + let paginatedLocalList; + if (localList) { + paginatedLocalList = localList.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + totalPages = Math.ceil(localList.length / PAGE_SIZE); + } + + // ************************************************************************** + // ************************************************************************** + + function getRenderActions() { + if (getActionButtons) { + return (claim) =>
{getActionButtons(claim.permanent_url)}
; + } + return undefined; + } + + function formatSearchSuggestion(suggestion: string) { + return reduceUriToChannelName(suggestion); + } + + function filterSearchResults(results: ?Array) { + setSearchList(results); + } + + // ************************************************************************** + // ************************************************************************** + + React.useEffect(() => { + const list = stringifiedList && JSON.parse(stringifiedList); + if (!hasLocalList) { + setLocalList(list && list.length > 0 ? list : []); + } + }, [stringifiedList, hasLocalList]); + + React.useEffect(() => { + if (justBlocked && stringifiedList) { + setLocalList(JSON.parse(stringifiedList)); + } + }, [stringifiedList, justBlocked, setLocalList]); + + // ************************************************************************** + // ************************************************************************** + + if (paginatedLocalList === undefined) { + return null; + } + + if (!hasLocalList) { + return ( +
+ +
+ } + /> + + ); + } + + return ( + <> +
{help}
+
+ +
+
+ +
+ setPage(p)} /> + + ); +} + +// **************************************************************************** +// SearchList +// **************************************************************************** + +type LsbProps = { + list: ?Array, + placeholder?: string, + formatter?: (suggestion: string) => string, + onResultsUpdated?: (?Array) => 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 ( +
+ + + + + {results && ( + + {results.length > 0 ? ( + + {results.slice(0, 10).map((result, index) => ( + + ))} + + ) : ( + {__('No results found')} + )} + + )} + +
+ ); +} + +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 +} diff --git a/ui/component/common/paginate.jsx b/ui/component/common/paginate.jsx index f082285e6..dc1a8c67f 100644 --- a/ui/component/common/paginate.jsx +++ b/ui/component/common/paginate.jsx @@ -10,16 +10,18 @@ const PAGINATE_PARAM = 'page'; type Props = { totalPages: number, location: { search: string }, - history: { push: string => void }, - onPageChange?: number => void, + history: { push: (string) => void }, + onPageChange?: (number) => void, + disableHistory?: boolean, // Disables the use of '&page=' param and history stack. }; function Paginate(props: Props) { - const { totalPages = 1, location, history, onPageChange } = props; + const { totalPages = 1, location, history, onPageChange, disableHistory } = props; const { search } = location; const [textValue, setTextValue] = React.useState(''); const urlParams = new URLSearchParams(search); - const currentPage = Number(urlParams.get(PAGINATE_PARAM)) || 1; + const initialPage = disableHistory ? 1 : Number(urlParams.get(PAGINATE_PARAM)) || 1; + const [currentPage, setCurrentPage] = React.useState(initialPage); const isMobile = useIsMobile(); function handleChangePage(newPageNumber: number) { @@ -28,9 +30,13 @@ function Paginate(props: Props) { } if (currentPage !== newPageNumber) { - const params = new URLSearchParams(search); - params.set(PAGINATE_PARAM, newPageNumber.toString()); - history.push('?' + params.toString()); + setCurrentPage(newPageNumber); + + if (!disableHistory) { + const params = new URLSearchParams(search); + params.set(PAGINATE_PARAM, newPageNumber.toString()); + history.push('?' + params.toString()); + } } } @@ -58,7 +64,7 @@ function Paginate(props: Props) { nextClassName="pagination__item pagination__item--next" breakClassName="pagination__item pagination__item--break" marginPagesDisplayed={2} - onPageChange={e => handleChangePage(e.selected + 1)} + onPageChange={(e) => handleChangePage(e.selected + 1)} forcePage={currentPage - 1} initialPage={currentPage - 1} containerClassName="pagination" @@ -67,7 +73,7 @@ function Paginate(props: Props) { {!isMobile && ( setTextValue(e.target.value)} + onChange={(e) => setTextValue(e.target.value)} className="paginate-channel" label={__('Go to page:')} type="text" diff --git a/ui/page/listBlocked/view.jsx b/ui/page/listBlocked/view.jsx index 8036a8f1f..7e25a665b 100644 --- a/ui/page/listBlocked/view.jsx +++ b/ui/page/listBlocked/view.jsx @@ -5,7 +5,7 @@ import React from 'react'; import classnames from 'classnames'; import moment from 'moment'; import humanizeDuration from 'humanize-duration'; -import ClaimList from 'component/claimList'; +import BlockList from 'component/blockList'; import ClaimPreview from 'component/claimPreview'; import Page from 'component/page'; import Spinner from 'component/spinner'; @@ -13,7 +13,6 @@ import Button from 'component/button'; import usePersistedState from 'effects/use-persisted-state'; import ChannelBlockButton from 'component/channelBlockButton'; import ChannelMuteButton from 'component/channelMuteButton'; -import Yrbl from 'component/yrbl'; const VIEW = { BLOCKED: 'blocked', @@ -47,7 +46,7 @@ function ListBlocked(props: Props) { personalTimeoutMap, adminTimeoutMap, moderatorTimeoutMap, - moderatorBlockListDelegatorsMap, + moderatorBlockListDelegatorsMap: delegatorsMap, fetchingModerationBlockList, fetchModBlockedList, fetchModAmIList, @@ -56,55 +55,36 @@ function ListBlocked(props: Props) { } = props; const [viewMode, setViewMode] = usePersistedState('blocked-muted:display', VIEW.BLOCKED); - // Keep a local list to allow for undoing actions in this component - const [localPersonalList, setLocalPersonalList] = React.useState(undefined); - const [localAdminList, setLocalAdminList] = React.useState(undefined); - const [localModeratorList, setLocalModeratorList] = React.useState(undefined); - const [localModeratorListDelegatorsMap, setLocalModeratorListDelegatorsMap] = React.useState(undefined); - const [localMutedList, setLocalMutedList] = React.useState(undefined); + const [localDelegatorsMap, setLocalDelegatorsMap] = React.useState(undefined); - const hasLocalMuteList = localMutedList && localMutedList.length > 0; - const hasLocalPersonalList = localPersonalList && localPersonalList.length > 0; - - const stringifiedMutedList = JSON.stringify(mutedUris); - const stringifiedPersonalList = JSON.stringify(personalBlockList); - const stringifiedAdminList = JSON.stringify(adminBlockList); - const stringifiedModeratorList = JSON.stringify(moderatorBlockList); - const stringifiedModeratorListDelegatorsMap = JSON.stringify(moderatorBlockListDelegatorsMap); - - const stringifiedLocalAdminList = JSON.stringify(localAdminList); - const stringifiedLocalModeratorList = JSON.stringify(localModeratorList); - const stringifiedLocalModeratorListDelegatorsMap = JSON.stringify(localModeratorListDelegatorsMap); - - const justMuted = localMutedList && mutedUris && localMutedList.length < mutedUris.length; - const justPersonalBlocked = - localPersonalList && personalBlockList && localPersonalList.length < personalBlockList.length; + const stringifiedDelegatorsMap = JSON.stringify(delegatorsMap); + const stringifiedLocalDelegatorsMap = JSON.stringify(localDelegatorsMap); const isAdmin = myChannelClaims && myChannelClaims.some((c) => delegatorsById[c.claim_id] && delegatorsById[c.claim_id].global); + const isModerator = myChannelClaims && myChannelClaims.some( (c) => delegatorsById[c.claim_id] && Object.keys(delegatorsById[c.claim_id].delegators).length > 0 ); - const listForView = getLocalList(viewMode); - const showUris = listForView && listForView.length > 0; + // ************************************************************************** - function getLocalList(view) { + function getList(view) { switch (view) { case VIEW.BLOCKED: - return localPersonalList; + return personalBlockList; case VIEW.ADMIN: - return localAdminList; + return adminBlockList; case VIEW.MODERATOR: - return localModeratorList; + return moderatorBlockList; case VIEW.MUTED: - return localMutedList; + return mutedUris; } } - function getButtons(view, uri) { + function getActionButtons(uri) { const getDurationStr = (durationNs) => { const NANO_TO_MS = 1000000; return humanizeDuration(durationNs / NANO_TO_MS, { round: true }); @@ -127,7 +107,7 @@ function ListBlocked(props: Props) { ); }; - switch (view) { + switch (viewMode) { case VIEW.BLOCKED: return ( <> @@ -146,18 +126,21 @@ function ListBlocked(props: Props) { ); case VIEW.MODERATOR: - const delegatorUrisForBlockedUri = localModeratorListDelegatorsMap && localModeratorListDelegatorsMap[uri]; + const delegatorUrisForBlockedUri = localDelegatorsMap && localDelegatorsMap[uri]; if (!delegatorUrisForBlockedUri) return null; return ( <> {delegatorUrisForBlockedUri.map((delegatorUri) => { return (
-
    - + +
      +
      + + {moderatorTimeoutMap[uri] && getBanInfoElem(moderatorTimeoutMap[uri])} +
      +
    - - {moderatorTimeoutMap[uri] && getBanInfoElem(moderatorTimeoutMap[uri])}
); })} @@ -218,52 +201,43 @@ function ListBlocked(props: Props) { return source && (!local || local.length < source.length); } - React.useEffect(() => { - const jsonMutedChannels = stringifiedMutedList && JSON.parse(stringifiedMutedList); - if (!hasLocalMuteList && jsonMutedChannels && jsonMutedChannels.length > 0) { - setLocalMutedList(jsonMutedChannels); - } - }, [stringifiedMutedList, hasLocalMuteList]); + function getViewElem(view, label, icon) { + return ( +