mirror of
https://github.com/zoriya/flood.git
synced 2025-12-05 23:06:20 +00:00
feat: add filter by filesystem location + bonus tooltip on overflowed filter names (#518)
This commit is contained in:
62
client/src/javascript/components/sidebar/LocationFilters.tsx
Normal file
62
client/src/javascript/components/sidebar/LocationFilters.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {FC, KeyboardEvent, MouseEvent, ReactNode, TouchEvent} from 'react';
|
||||
import {observer} from 'mobx-react';
|
||||
import {useLingui} from '@lingui/react';
|
||||
import {LocationTreeNode} from '@shared/types/Taxonomy';
|
||||
|
||||
import SidebarFilter from './SidebarFilter';
|
||||
import TorrentFilterStore from '../../stores/TorrentFilterStore';
|
||||
|
||||
const buildLocationFilterTree = (location: LocationTreeNode): ReactNode => {
|
||||
if (location.children.length === 1 && location.containedCount === location.children[0].containedCount) {
|
||||
const onlyChild = location.children[0];
|
||||
const separator = onlyChild.fullPath.includes('/') ? '/' : '\\';
|
||||
return buildLocationFilterTree({
|
||||
...onlyChild,
|
||||
directoryName: location.directoryName + separator + onlyChild.directoryName,
|
||||
});
|
||||
}
|
||||
|
||||
const children = location.children.map(buildLocationFilterTree);
|
||||
|
||||
return (
|
||||
<SidebarFilter
|
||||
handleClick={(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) =>
|
||||
TorrentFilterStore.setLocationFilters(filter, event)
|
||||
}
|
||||
count={location.containedCount}
|
||||
key={location.fullPath}
|
||||
isActive={
|
||||
(location.fullPath === '' && !TorrentFilterStore.locationFilter.length) ||
|
||||
TorrentFilterStore.locationFilter.includes(location.fullPath)
|
||||
}
|
||||
name={location.directoryName}
|
||||
slug={location.fullPath}
|
||||
size={location.containedSize}
|
||||
>
|
||||
{(children.length && children) || undefined}
|
||||
</SidebarFilter>
|
||||
);
|
||||
};
|
||||
|
||||
const LocationFilters: FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
if (TorrentFilterStore.taxonomy.locationTree.containedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filterElements = buildLocationFilterTree(TorrentFilterStore.taxonomy.locationTree);
|
||||
|
||||
const title = i18n._('filter.location.title');
|
||||
|
||||
return (
|
||||
<ul aria-label={title} className="sidebar-filter sidebar__item" role="menu">
|
||||
<li className="sidebar-filter__item sidebar-filter__item--heading" role="none">
|
||||
{title}
|
||||
</li>
|
||||
{filterElements}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
|
||||
export default LocationFilters;
|
||||
@@ -4,6 +4,7 @@ import {OverlayScrollbarsComponent} from 'overlayscrollbars-react';
|
||||
import DiskUsage from './DiskUsage';
|
||||
import FeedsButton from './FeedsButton';
|
||||
import LogoutButton from './LogoutButton';
|
||||
import LocationFilters from './LocationFilters';
|
||||
import NotificationsButton from './NotificationsButton';
|
||||
import SearchBox from './SearchBox';
|
||||
import SettingsButton from './SettingsButton';
|
||||
@@ -44,6 +45,7 @@ const Sidebar: FC = () => (
|
||||
<StatusFilters />
|
||||
<TagFilters />
|
||||
<TrackerFilters />
|
||||
<LocationFilters />
|
||||
<DiskUsage />
|
||||
<div style={{flexGrow: 1}} />
|
||||
<SidebarActions>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import classnames from 'classnames';
|
||||
import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react';
|
||||
import {createRef, FC, ReactNode, KeyboardEvent, MouseEvent, RefObject, TouchEvent, useEffect, useState} from 'react';
|
||||
import {useLingui} from '@lingui/react';
|
||||
import {Start} from '@client/ui/icons';
|
||||
|
||||
import Badge from '../general/Badge';
|
||||
import Size from '../general/Size';
|
||||
|
||||
const useRefTextOverflowed = (ref: RefObject<HTMLElement>) => {
|
||||
const [overflowed, setOverflowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const {current} = ref;
|
||||
setOverflowed(current.scrollWidth > current.clientWidth);
|
||||
}
|
||||
}, [ref, ref?.current?.scrollWidth, ref?.current?.clientWidth]);
|
||||
|
||||
return overflowed;
|
||||
};
|
||||
|
||||
interface SidebarFilterProps {
|
||||
children?: ReactNode[];
|
||||
name: string;
|
||||
icon?: ReactNode;
|
||||
isActive: boolean;
|
||||
@@ -16,6 +31,7 @@ interface SidebarFilterProps {
|
||||
}
|
||||
|
||||
const SidebarFilter: FC<SidebarFilterProps> = ({
|
||||
children,
|
||||
name: _name,
|
||||
icon,
|
||||
isActive,
|
||||
@@ -24,11 +40,32 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
|
||||
size,
|
||||
handleClick,
|
||||
}: SidebarFilterProps) => {
|
||||
const nameSpanRef = createRef<HTMLSpanElement>();
|
||||
const overflowed = useRefTextOverflowed(nameSpanRef);
|
||||
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const classNames = classnames('sidebar-filter__item', {
|
||||
'is-active': isActive,
|
||||
});
|
||||
const expanderClassNames = classnames('sidebar-filter__expander', {
|
||||
'is-active': isActive,
|
||||
expanded: expanded,
|
||||
});
|
||||
|
||||
const flexCss = children
|
||||
? {
|
||||
display: 'flex',
|
||||
}
|
||||
: {};
|
||||
const focusCss = {
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
},
|
||||
};
|
||||
|
||||
let name = _name;
|
||||
if (name === '') {
|
||||
@@ -48,23 +85,35 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className={classNames}
|
||||
css={{
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
type="button"
|
||||
onClick={(event) => handleClick(slug, event)}
|
||||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
<span className="name">{name}</span>
|
||||
<Badge>{count}</Badge>
|
||||
{size != null && <Size value={size} className="size" />}
|
||||
</button>
|
||||
<div css={flexCss}>
|
||||
{children && (
|
||||
<button
|
||||
className={expanderClassNames}
|
||||
css={focusCss}
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="switch"
|
||||
aria-checked={expanded}
|
||||
>
|
||||
<Start />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={classNames}
|
||||
css={focusCss}
|
||||
type="button"
|
||||
onClick={(event) => handleClick(slug, event)}
|
||||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
<span className="name" ref={nameSpanRef} title={overflowed ? name || '' : undefined}>
|
||||
{name}
|
||||
</span>
|
||||
<Badge>{count}</Badge>
|
||||
{size != null && <Size value={size} className="size" />}
|
||||
</button>
|
||||
</div>
|
||||
{children && expanded && <ul className="sidebar-filter__nested">{children}</ul>}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"filter.status.seeding": "Seeding",
|
||||
"filter.status.stopped": "Stopped",
|
||||
"filter.status.title": "Filter by Status",
|
||||
"filter.location.title": "Filter by Location",
|
||||
"filter.tag.title": "Filter by Tag",
|
||||
"filter.tracker.title": "Filter by Tracker",
|
||||
"filter.untagged": "Untagged",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {Taxonomy} from '@shared/types/Taxonomy';
|
||||
import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap';
|
||||
|
||||
class TorrentFilterStore {
|
||||
locationFilter: Array<string> = [];
|
||||
searchFilter = '';
|
||||
statusFilter: Array<TorrentStatus> = [];
|
||||
tagFilter: Array<string> = [];
|
||||
@@ -14,6 +15,7 @@ class TorrentFilterStore {
|
||||
filterTrigger = false;
|
||||
|
||||
taxonomy: Taxonomy = {
|
||||
locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0},
|
||||
statusCounts: {},
|
||||
tagCounts: {},
|
||||
tagSizes: {},
|
||||
@@ -22,7 +24,13 @@ class TorrentFilterStore {
|
||||
};
|
||||
|
||||
@computed get isFilterActive() {
|
||||
return this.searchFilter !== '' || this.statusFilter.length || this.tagFilter.length || this.trackerFilter.length;
|
||||
return (
|
||||
this.locationFilter.length ||
|
||||
this.searchFilter !== '' ||
|
||||
this.statusFilter.length ||
|
||||
this.tagFilter.length ||
|
||||
this.trackerFilter.length
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -30,6 +38,7 @@ class TorrentFilterStore {
|
||||
}
|
||||
|
||||
clearAllFilters() {
|
||||
this.locationFilter = [];
|
||||
this.searchFilter = '';
|
||||
this.statusFilter = [];
|
||||
this.tagFilter = [];
|
||||
@@ -50,6 +59,12 @@ class TorrentFilterStore {
|
||||
this.filterTrigger = !this.filterTrigger;
|
||||
}
|
||||
|
||||
setLocationFilters(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
|
||||
// keys: [] to disable shift-clicking as it doesn't make sense in a tree
|
||||
this.computeFilters([], this.locationFilter, filter, event);
|
||||
this.filterTrigger = !this.filterTrigger;
|
||||
}
|
||||
|
||||
setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
|
||||
this.computeFilters(torrentStatusMap, this.statusFilter, filter, event);
|
||||
this.filterTrigger = !this.filterTrigger;
|
||||
@@ -85,7 +100,7 @@ class TorrentFilterStore {
|
||||
) {
|
||||
if (newFilter === ('' as T)) {
|
||||
currentFilters.splice(0);
|
||||
} else if (event.shiftKey) {
|
||||
} else if (event.shiftKey && keys.length) {
|
||||
if (currentFilters.length) {
|
||||
const lastKey = currentFilters[currentFilters.length - 1];
|
||||
const lastKeyIndex = keys.indexOf(lastKey);
|
||||
|
||||
@@ -24,10 +24,17 @@ class TorrentStore {
|
||||
}
|
||||
|
||||
@computed get filteredTorrents(): Array<TorrentProperties> {
|
||||
const {searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
|
||||
const {locationFilter, searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
|
||||
|
||||
let filteredTorrents = Object.assign([], this.sortedTorrents) as Array<TorrentProperties>;
|
||||
|
||||
if (locationFilter.length) {
|
||||
filteredTorrents = filterTorrents(filteredTorrents, {
|
||||
type: 'location',
|
||||
filter: locationFilter,
|
||||
});
|
||||
}
|
||||
|
||||
if (searchFilter !== '') {
|
||||
filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
|
||||
|
||||
interface LocationFilter {
|
||||
type: 'location';
|
||||
filter: string[];
|
||||
}
|
||||
|
||||
interface StatusFilter {
|
||||
type: 'status';
|
||||
filter: TorrentStatus[];
|
||||
@@ -18,9 +23,13 @@ interface TagFilter {
|
||||
|
||||
function filterTorrents(
|
||||
torrentList: TorrentProperties[],
|
||||
opts: StatusFilter | TrackerFilter | TagFilter,
|
||||
opts: LocationFilter | StatusFilter | TrackerFilter | TagFilter,
|
||||
): TorrentProperties[] {
|
||||
if (opts.filter.length) {
|
||||
if (opts.type === 'location') {
|
||||
return torrentList.filter((torrent) => opts.filter.some((directory) => torrent.directory.startsWith(directory)));
|
||||
}
|
||||
|
||||
if (opts.type === 'status') {
|
||||
return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&__expander,
|
||||
&__item {
|
||||
@include themes.theme('color', 'sidebar-filter--foreground');
|
||||
cursor: pointer;
|
||||
@@ -75,9 +76,24 @@
|
||||
|
||||
.size {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__expander {
|
||||
display: block;
|
||||
width: 14px;
|
||||
padding: 0 0 0 20px;
|
||||
|
||||
&.expanded svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__nested {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@include themes.theme('background', 'sidebar-filter--count--background');
|
||||
@include themes.theme('color', 'sidebar-filter--count--foreground');
|
||||
|
||||
@@ -30,7 +30,9 @@ process.argv.push('--test');
|
||||
process.argv.push('--assets', 'false');
|
||||
|
||||
afterAll((done) => {
|
||||
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
|
||||
if (fs.existsSync(`${temporaryRuntimeDirectory}/rtorrent.pid`)) {
|
||||
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
|
||||
}
|
||||
if (process.env.CI !== 'true') {
|
||||
// TODO: This leads to test flakiness caused by ENOENT error
|
||||
// NeDB provides no method to close database connection
|
||||
|
||||
73
server/services/taxonomyService.test.ts
Normal file
73
server/services/taxonomyService.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {UserInDatabase} from '@shared/schema/Auth';
|
||||
import {LocationTreeNode} from '@shared/types/Taxonomy';
|
||||
|
||||
import TaxonomyService from '../../server/services/taxonomyService';
|
||||
|
||||
type LocationRecord = {[key: string]: LocationRecord | null};
|
||||
const toTreeNodes = (locations: LocationRecord, separator = '/', basePath = '') =>
|
||||
Object.keys(locations).reduce((parentNodes, locationKey) => {
|
||||
const fullPath = locationKey !== '' ? basePath + separator + locationKey : locationKey;
|
||||
const subLocations = locations[locationKey];
|
||||
if (subLocations) {
|
||||
const parent = {
|
||||
directoryName: locationKey,
|
||||
fullPath: fullPath,
|
||||
children: toTreeNodes(subLocations, separator, fullPath),
|
||||
containedCount: 0,
|
||||
containedSize: 0,
|
||||
};
|
||||
for (const child of parent.children) {
|
||||
parent.containedCount += child.containedCount;
|
||||
parent.containedSize += child.containedSize;
|
||||
}
|
||||
parentNodes.push(parent);
|
||||
} else {
|
||||
parentNodes.push({
|
||||
directoryName: locationKey,
|
||||
fullPath: fullPath,
|
||||
children: [],
|
||||
containedCount: '' !== locationKey ? 1 : 0,
|
||||
containedSize: '' !== locationKey ? 10 : 0,
|
||||
});
|
||||
}
|
||||
return parentNodes;
|
||||
}, [] as LocationTreeNode[]);
|
||||
|
||||
describe('taxonomyService', () => {
|
||||
describe('incrementLocationCountsAndSizes() - locationTree', () => {
|
||||
for (const locationsAndExpected of [
|
||||
// No torrents
|
||||
{locations: [] as string[], expected: toTreeNodes({'': null})[0]},
|
||||
// Single root
|
||||
{
|
||||
locations: ['/mnt/dir1/file1', '/mnt/dir1/file2', '/mnt/dir2/file3', '/mnt/file4'],
|
||||
expected: toTreeNodes({
|
||||
'': {
|
||||
mnt: {dir1: {file1: null, file2: null}, dir2: {file3: null}, file4: null},
|
||||
},
|
||||
})[0],
|
||||
},
|
||||
// Multiple roots including overlapping case
|
||||
{
|
||||
locations: ['/mnt/file1', '/mnt/file2', '/mount/directory1/file3', '/Mount/directory2/file4'],
|
||||
expected: toTreeNodes({
|
||||
'': {
|
||||
mnt: {file1: null, file2: null},
|
||||
mount: {directory1: {file3: null}},
|
||||
Mount: {directory2: {file4: null}},
|
||||
},
|
||||
})[0],
|
||||
},
|
||||
]) {
|
||||
const {locations, expected} = locationsAndExpected;
|
||||
|
||||
it(`builds case-sensitive location tree correctly from ${locations}`, () => {
|
||||
const taxonomyService = new TaxonomyService({} as UserInDatabase);
|
||||
|
||||
for (const location of locations) taxonomyService.incrementLocationCountsAndSizes(location, 10);
|
||||
|
||||
expect(taxonomyService.taxonomy.locationTree).toMatchObject(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
|
||||
import type {LocationTreeNode, Taxonomy} from '@shared/types/Taxonomy';
|
||||
import type {TorrentList, TorrentProperties} from '@shared/types/Torrent';
|
||||
import jsonpatch, {Operation} from 'fast-json-patch';
|
||||
|
||||
import type {TorrentStatus} from '../../shared/constants/torrentStatusMap';
|
||||
import torrentStatusMap from '../../shared/constants/torrentStatusMap';
|
||||
import type {Taxonomy} from '../../shared/types/Taxonomy';
|
||||
import type {TorrentList, TorrentProperties} from '../../shared/types/Torrent';
|
||||
import BaseService from './BaseService';
|
||||
|
||||
type TaxonomyServiceEvents = {
|
||||
@@ -12,6 +12,7 @@ type TaxonomyServiceEvents = {
|
||||
|
||||
class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
|
||||
taxonomy: Taxonomy = {
|
||||
locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0},
|
||||
statusCounts: {'': 0},
|
||||
tagCounts: {'': 0, untagged: 0},
|
||||
tagSizes: {},
|
||||
@@ -60,6 +61,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
|
||||
|
||||
handleProcessTorrentListStart = () => {
|
||||
this.lastTaxonomy = {
|
||||
locationTree: {...this.taxonomy.locationTree},
|
||||
statusCounts: {...this.taxonomy.statusCounts},
|
||||
tagCounts: {...this.taxonomy.tagCounts},
|
||||
tagSizes: {...this.taxonomy.tagSizes},
|
||||
@@ -71,6 +73,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
|
||||
this.taxonomy.statusCounts[status] = 0;
|
||||
});
|
||||
|
||||
this.taxonomy.locationTree = {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0};
|
||||
this.taxonomy.statusCounts[''] = 0;
|
||||
this.taxonomy.tagCounts = {'': 0, untagged: 0};
|
||||
this.taxonomy.tagSizes = {};
|
||||
@@ -96,6 +99,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
|
||||
};
|
||||
|
||||
handleProcessTorrent = (torrentProperties: TorrentProperties) => {
|
||||
this.incrementLocationCountsAndSizes(torrentProperties.directory, torrentProperties.sizeBytes);
|
||||
this.incrementStatusCounts(torrentProperties.status);
|
||||
this.incrementTagCounts(torrentProperties.tags);
|
||||
this.incrementTagSizes(torrentProperties.tags, torrentProperties.sizeBytes);
|
||||
@@ -103,6 +107,42 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
|
||||
this.incrementTrackerSizes(torrentProperties.trackerURIs, torrentProperties.sizeBytes);
|
||||
};
|
||||
|
||||
incrementLocationCountsAndSizes(
|
||||
directory: TorrentProperties['directory'],
|
||||
sizeBytes: TorrentProperties['sizeBytes'],
|
||||
) {
|
||||
const separator = directory.includes('/') ? '/' : '\\';
|
||||
|
||||
const countSizeAndBytesForHierarchy = (parent: LocationTreeNode, pathSplit: string[]) => {
|
||||
const [nodeName, ...restOfPath] = pathSplit;
|
||||
let nodeRoot = parent.children.find((treeNode) => treeNode.directoryName === nodeName);
|
||||
if (!nodeRoot) {
|
||||
nodeRoot = {
|
||||
directoryName: nodeName,
|
||||
fullPath: parent.fullPath + separator + nodeName,
|
||||
children: [],
|
||||
containedCount: 0,
|
||||
containedSize: 0,
|
||||
};
|
||||
parent.children.push(nodeRoot);
|
||||
}
|
||||
nodeRoot.containedCount += 1;
|
||||
nodeRoot.containedSize += sizeBytes;
|
||||
|
||||
if (restOfPath.length) {
|
||||
countSizeAndBytesForHierarchy(nodeRoot, restOfPath);
|
||||
}
|
||||
};
|
||||
|
||||
const pathSplit = directory.startsWith(separator)
|
||||
? directory.split(separator).slice(1)
|
||||
: directory.split(separator);
|
||||
|
||||
countSizeAndBytesForHierarchy(this.taxonomy.locationTree, pathSplit);
|
||||
this.taxonomy.locationTree.containedCount += 1;
|
||||
this.taxonomy.locationTree.containedSize += sizeBytes;
|
||||
}
|
||||
|
||||
incrementStatusCounts(statuses: Array<TorrentStatus>) {
|
||||
statuses.forEach((status) => {
|
||||
this.taxonomy.statusCounts[status] += 1;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export interface LocationTreeNode {
|
||||
directoryName: string;
|
||||
fullPath: string;
|
||||
children: LocationTreeNode[];
|
||||
containedCount: number;
|
||||
containedSize: number;
|
||||
}
|
||||
|
||||
export interface Taxonomy {
|
||||
locationTree: LocationTreeNode;
|
||||
statusCounts: Record<string, number>;
|
||||
tagCounts: Record<string, number>;
|
||||
tagSizes: Record<string, number>;
|
||||
|
||||
Reference in New Issue
Block a user