mirror of
https://github.com/Wanxp/obsidian-douban.git
synced 2026-04-04 16:48:44 +08:00
feature: search next page
This commit is contained in:
parent
29db644ac1
commit
e19fbc648c
@ -1,4 +1,5 @@
|
|||||||
import {i18nHelper} from "../lang/helper";
|
import {i18nHelper} from "../lang/helper";
|
||||||
|
import DoubanSearchResultSubject from "../douban/data/model/DoubanSearchResultSubject";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 常量池
|
* 常量池
|
||||||
@ -162,6 +163,58 @@ export enum SyncItemStatus {
|
|||||||
fail= 'fail',
|
fail= 'fail',
|
||||||
unHandle='unHandle',
|
unHandle='unHandle',
|
||||||
}
|
}
|
||||||
|
export enum NavigateType {
|
||||||
|
previous = "previous",
|
||||||
|
next = "next",
|
||||||
|
|
||||||
|
nextNeedLogin = "nextNeedLogin"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoubanSearchResultSubjectPreviousPage:DoubanSearchResultSubject = {
|
||||||
|
cast: "",
|
||||||
|
datePublished: undefined,
|
||||||
|
desc: "",
|
||||||
|
genre: [],
|
||||||
|
id: "",
|
||||||
|
image: "",
|
||||||
|
publisher: "",
|
||||||
|
score: 0,
|
||||||
|
title: i18nHelper.getMessage("150102"),
|
||||||
|
type: "navigate",
|
||||||
|
url: NavigateType.previous
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoubanSearchResultSubjectNextPage:DoubanSearchResultSubject = {
|
||||||
|
cast: "",
|
||||||
|
datePublished: undefined,
|
||||||
|
desc: "",
|
||||||
|
genre: [],
|
||||||
|
id: "",
|
||||||
|
image: "",
|
||||||
|
publisher: "",
|
||||||
|
score: 0,
|
||||||
|
title: i18nHelper.getMessage("150103"),
|
||||||
|
type: "navigate",
|
||||||
|
url: NavigateType.next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoubanSearchResultSubjectNextPageNeedLogin:DoubanSearchResultSubject = {
|
||||||
|
cast: "",
|
||||||
|
datePublished: undefined,
|
||||||
|
desc: "",
|
||||||
|
genre: [],
|
||||||
|
id: "",
|
||||||
|
image: "",
|
||||||
|
publisher: "",
|
||||||
|
score: 0,
|
||||||
|
title: i18nHelper.getMessage("150104"),
|
||||||
|
type: "navigate",
|
||||||
|
url: NavigateType.nextNeedLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEARCH_ITEM_PAGE_SIZE:number = 20;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import SyncStatusHolder from "../../sync/model/SyncStatusHolder";
|
|||||||
import {SyncConfig} from "../../sync/model/SyncConfig";
|
import {SyncConfig} from "../../sync/model/SyncConfig";
|
||||||
import DoubanSubject from "./DoubanSubject";
|
import DoubanSubject from "./DoubanSubject";
|
||||||
import GlobalStatusHolder from "../../model/GlobalStatusHolder";
|
import GlobalStatusHolder from "../../model/GlobalStatusHolder";
|
||||||
|
import {SearchResultsPage} from "schema-dts";
|
||||||
|
import {SearchPageInfo} from "./SearchPageInfo";
|
||||||
|
|
||||||
export default interface HandleContext {
|
export default interface HandleContext {
|
||||||
plugin:DoubanPlugin;
|
plugin:DoubanPlugin;
|
||||||
@ -21,4 +23,6 @@ export default interface HandleContext {
|
|||||||
action:string;
|
action:string;
|
||||||
syncConfig?: SyncConfig;
|
syncConfig?: SyncConfig;
|
||||||
listItem?:DoubanSubject;
|
listItem?:DoubanSubject;
|
||||||
|
|
||||||
|
searchPage:SearchPageInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/org/wanxp/douban/data/model/SearchPage.ts
Normal file
17
src/org/wanxp/douban/data/model/SearchPage.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {SearchPageInfo} from "./SearchPageInfo";
|
||||||
|
|
||||||
|
export class SearchPage extends SearchPageInfo{
|
||||||
|
|
||||||
|
private _list:any[];
|
||||||
|
|
||||||
|
|
||||||
|
constructor(total: number, pageNum: number, pageSize: number, hasNext: boolean, list: any[]) {
|
||||||
|
super(total, pageNum, pageSize, hasNext);
|
||||||
|
this._list = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get list() {
|
||||||
|
return this._list;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
58
src/org/wanxp/douban/data/model/SearchPageInfo.ts
Normal file
58
src/org/wanxp/douban/data/model/SearchPageInfo.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export class SearchPageInfo {
|
||||||
|
private _total: number;
|
||||||
|
private _hasNext: boolean;
|
||||||
|
private _pageSize: number;
|
||||||
|
private _pageNum: number;
|
||||||
|
|
||||||
|
constructor(total: number, pageNum: number, pageSize: number, hasNext: boolean) {
|
||||||
|
this._total = total;
|
||||||
|
this._pageNum = pageNum;
|
||||||
|
this._pageSize = pageSize;
|
||||||
|
this._hasNext = hasNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextPage(): SearchPageInfo {
|
||||||
|
if (!this._hasNext) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (((this._pageNum + 1) * this._pageSize) > this.total) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return new SearchPageInfo(this.total, this._pageNum + 1,
|
||||||
|
this._pageSize, ((this._pageNum + 2) * this._pageSize) > this.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public previousPage(): SearchPageInfo {
|
||||||
|
if (this._pageNum == 0) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return new SearchPageInfo(this.total, this._pageNum - 1,
|
||||||
|
this._pageSize, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public get hasNext() {
|
||||||
|
return this._hasNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasPrevious() {
|
||||||
|
return this._pageNum > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public get start() {
|
||||||
|
return this._pageNum * this._pageSize + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get total() {
|
||||||
|
return this._total;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return this._pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageNum(): number {
|
||||||
|
return this._pageNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,21 @@
|
|||||||
import {FuzzySuggestModal} from "obsidian";
|
import {FuzzySuggestModal, request, RequestUrlParam} from "obsidian";
|
||||||
|
|
||||||
import DoubanPlugin from "../../../main";
|
import DoubanPlugin from "../../../main";
|
||||||
import DoubanSearchResultSubject from "../model/DoubanSearchResultSubject";
|
import DoubanSearchResultSubject from "../model/DoubanSearchResultSubject";
|
||||||
import {log} from "src/org/wanxp/utils/Logutil";
|
import {log} from "src/org/wanxp/utils/Logutil";
|
||||||
import {i18nHelper} from "../../../lang/helper";
|
import {i18nHelper} from "../../../lang/helper";
|
||||||
import HandleContext from "../model/HandleContext";
|
import HandleContext from "../model/HandleContext";
|
||||||
|
import {init} from "cjs-module-lexer";
|
||||||
|
import {
|
||||||
|
DoubanSearchResultSubjectNextPage,
|
||||||
|
DoubanSearchResultSubjectNextPageNeedLogin, DoubanSearchResultSubjectPreviousPage, NavigateType
|
||||||
|
} from "../../../constant/Constsant";
|
||||||
|
import {SearchPageInfo} from "../model/SearchPageInfo";
|
||||||
|
import {flat} from "builtin-modules";
|
||||||
|
import User from "../../user/User";
|
||||||
|
import {load} from "cheerio";
|
||||||
|
import Searcher from "./Search";
|
||||||
|
import {SearchPage} from "../model/SearchPage";
|
||||||
|
|
||||||
export {DoubanFuzzySuggester}
|
export {DoubanFuzzySuggester}
|
||||||
|
|
||||||
@ -14,11 +25,13 @@ class DoubanFuzzySuggester extends FuzzySuggestModal<DoubanSearchResultSubject>
|
|||||||
private plugin: DoubanPlugin;
|
private plugin: DoubanPlugin;
|
||||||
private doubanSearchResultExtract: DoubanSearchResultSubject[];
|
private doubanSearchResultExtract: DoubanSearchResultSubject[];
|
||||||
private context: HandleContext;
|
private context: HandleContext;
|
||||||
|
private searchItem:string;
|
||||||
|
|
||||||
constructor(plugin: DoubanPlugin, context: HandleContext) {
|
constructor(plugin: DoubanPlugin, context: HandleContext, searchItem:string) {
|
||||||
super(app);
|
super(app);
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.searchItem = searchItem;
|
||||||
this.setPlaceholder(i18nHelper.getMessage('150101'));
|
this.setPlaceholder(i18nHelper.getMessage('150101'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,11 +41,24 @@ class DoubanFuzzySuggester extends FuzzySuggestModal<DoubanSearchResultSubject>
|
|||||||
}
|
}
|
||||||
|
|
||||||
getItemText(item: DoubanSearchResultSubject): string {
|
getItemText(item: DoubanSearchResultSubject): string {
|
||||||
|
if (this.isNavigate(item)) {
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
let text: string = item.type + "/" + (item.score ? item.score : '-') + "/" + item.title + "/" + item.cast;
|
let text: string = item.type + "/" + (item.score ? item.score : '-') + "/" + item.title + "/" + item.cast;
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isNavigate(item: DoubanSearchResultSubject) {
|
||||||
|
return item.type == "navigate";
|
||||||
|
}
|
||||||
|
|
||||||
onChooseItem(item: DoubanSearchResultSubject, evt: MouseEvent | KeyboardEvent): void {
|
onChooseItem(item: DoubanSearchResultSubject, evt: MouseEvent | KeyboardEvent): void {
|
||||||
|
if(this.isNavigate(item)) {
|
||||||
|
if (this.handleNavigate(item)) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.plugin.showStatus(i18nHelper.getMessage('140204', item.title));
|
this.plugin.showStatus(i18nHelper.getMessage('140204', item.title));
|
||||||
this.context.listItem = item;
|
this.context.listItem = item;
|
||||||
if (item) {
|
if (item) {
|
||||||
@ -41,11 +67,58 @@ class DoubanFuzzySuggester extends FuzzySuggestModal<DoubanSearchResultSubject>
|
|||||||
this.plugin.doubanExtractHandler.handle(item, this.context);
|
this.plugin.doubanExtractHandler.handle(item, this.context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleNavigate(item: DoubanSearchResultSubject):Promise<boolean> {
|
||||||
|
const {searchPage} = this.context;
|
||||||
|
let currentPage:SearchPageInfo = searchPage;
|
||||||
|
let result:boolean = false;
|
||||||
|
switch (item.type) {
|
||||||
|
case NavigateType.previous:
|
||||||
|
currentPage = searchPage.previousPage();
|
||||||
|
result = true;
|
||||||
|
break;
|
||||||
|
case NavigateType.next:
|
||||||
|
currentPage = searchPage.nextPage();
|
||||||
|
result = true;
|
||||||
|
break;
|
||||||
|
case NavigateType.nextNeedLogin:
|
||||||
|
log.warn(i18nHelper.getMessage("140304"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const searchPageResult:SearchPage = await Searcher.loadSearchItem(this.searchItem, currentPage.start, this.plugin.settings, this.plugin.settingsManager);
|
||||||
|
this.updatePageResult(searchPageResult);
|
||||||
|
this.context.searchPage = new SearchPageInfo(searchPageResult.total, currentPage.pageNum, searchPageResult.pageSize, searchPageResult.hasNext);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePageResult(searchPageResult: SearchPage) {
|
||||||
|
this.initItems(searchPageResult.list);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public showSearchList(doubanSearchResultExtractList: DoubanSearchResultSubject[]) {
|
public showSearchList(doubanSearchResultExtractList: DoubanSearchResultSubject[]) {
|
||||||
this.doubanSearchResultExtract = doubanSearchResultExtractList;
|
this.initItems(doubanSearchResultExtractList);
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initItems(doubanSearchResultExtractList: DoubanSearchResultSubject[]) {
|
||||||
|
let doubanList: DoubanSearchResultSubject[] = doubanSearchResultExtractList;
|
||||||
|
const {searchPage} = this.context;
|
||||||
|
if (searchPage.hasNext) {
|
||||||
|
if (this.plugin.userComponent.isLogin()) {
|
||||||
|
doubanList.push(DoubanSearchResultSubjectNextPage)
|
||||||
|
}else {
|
||||||
|
doubanList.push(DoubanSearchResultSubjectNextPageNeedLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchPage.hasPrevious) {
|
||||||
|
doubanList.unshift(DoubanSearchResultSubjectPreviousPage);
|
||||||
|
}
|
||||||
|
this.doubanSearchResultExtract = doubanList;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
try {
|
try {
|
||||||
this.open();
|
this.open();
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import {load} from 'cheerio';
|
|||||||
import {DoubanPluginSetting} from "../../setting/model/DoubanPluginSetting";
|
import {DoubanPluginSetting} from "../../setting/model/DoubanPluginSetting";
|
||||||
import {DEFAULT_SETTINGS} from "../../../constant/DefaultSettings";
|
import {DEFAULT_SETTINGS} from "../../../constant/DefaultSettings";
|
||||||
import SettingsManager from "../../setting/SettingsManager";
|
import SettingsManager from "../../setting/SettingsManager";
|
||||||
|
import User from "../../user/User";
|
||||||
|
import {SearchPage} from "../model/SearchPage";
|
||||||
|
|
||||||
export default class Searcher {
|
export default class Searcher {
|
||||||
static search(searchItem: string, doubanSettings: DoubanPluginSetting, settingsManager:SettingsManager): Promise<DoubanSearchResultSubject[]> {
|
static search(searchItem: string, doubanSettings: DoubanPluginSetting, settingsManager:SettingsManager): Promise<DoubanSearchResultSubject[]> {
|
||||||
@ -40,4 +42,36 @@ export default class Searcher {
|
|||||||
;
|
;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static loadSearchItem(searchItem: string, start:number, doubanSettings: DoubanPluginSetting, settingsManager:SettingsManager): Promise<SearchPage> {
|
||||||
|
const myHeaders:Record<string, string> = JSON.parse(doubanSettings.searchHeaders);
|
||||||
|
if (doubanSettings.loginCookiesContent) {
|
||||||
|
myHeaders.Cookie = doubanSettings.loginCookiesContent
|
||||||
|
}
|
||||||
|
log.debug("请求更多页面");
|
||||||
|
let requestUrlParam: RequestUrlParam = {
|
||||||
|
url: `https://www.douban.com/j/search?q=${searchItem}&start=${start}&subtype=item`,
|
||||||
|
method: "GET",
|
||||||
|
headers: myHeaders,
|
||||||
|
throw: true
|
||||||
|
};
|
||||||
|
return requestUrl(requestUrlParam)
|
||||||
|
.then(requestUrlResponse => {
|
||||||
|
if (requestUrlResponse.status == 403) {
|
||||||
|
throw new Error(i18nHelper.getMessage('130106'));
|
||||||
|
}
|
||||||
|
return requestUrlResponse.text;
|
||||||
|
})
|
||||||
|
.then(e=>SearchParserHandler.parseSearchJson(e, start))
|
||||||
|
.catch(e => {
|
||||||
|
if(e.toString().indexOf('403') > 0) {
|
||||||
|
throw new Error(i18nHelper.getMessage('130106'));
|
||||||
|
}else {
|
||||||
|
throw log.error(i18nHelper.getMessage('130101').replace('{0}', e.toString()), e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import {CheerioAPI} from "cheerio";
|
import {CheerioAPI, load} from "cheerio";
|
||||||
import DoubanSearchResultSubject from "../model/DoubanSearchResultSubject";
|
import DoubanSearchResultSubject from "../model/DoubanSearchResultSubject";
|
||||||
|
import {SearchPage} from "../model/SearchPage";
|
||||||
|
import {SEARCH_ITEM_PAGE_SIZE} from "../../../constant/Constsant";
|
||||||
|
import {log} from "../../../utils/Logutil";
|
||||||
|
|
||||||
export default class SearchParserHandler {
|
export default class SearchParserHandler {
|
||||||
static parseSearch(dataHtml: CheerioAPI): DoubanSearchResultSubject[] {
|
static parseSearch(dataHtml: CheerioAPI): DoubanSearchResultSubject[] {
|
||||||
@ -33,4 +36,16 @@ export default class SearchParserHandler {
|
|||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static parseSearchJson(result: string, start:number): SearchPage {
|
||||||
|
log.debug("解析给多页面结果");
|
||||||
|
const data:{total:number, limit:number, more:boolean, items:string[]} = JSON.parse(result);
|
||||||
|
const list:string[] = data.items;
|
||||||
|
const resultList:DoubanSearchResultSubject[] = list
|
||||||
|
.map(e => load(e))
|
||||||
|
.map(e=>this.parseSearch(e))
|
||||||
|
.map(e => e? e[0]:null);
|
||||||
|
return new SearchPage(data.total, start / SEARCH_ITEM_PAGE_SIZE, data.limit, data.more, resultList);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,8 +251,13 @@ PS: This file could be delete if you want to.
|
|||||||
'140301': `Douban: Syncing[{0}]...`,
|
'140301': `Douban: Syncing[{0}]...`,
|
||||||
'140303': `Douban: User Info Expire, Please login again`,
|
'140303': `Douban: User Info Expire, Please login again`,
|
||||||
'140302': `Douban: Sync complete`,
|
'140302': `Douban: Sync complete`,
|
||||||
|
'140304': `Douban: Need Login, Please login in Obsidian-Douban plugin`,
|
||||||
|
|
||||||
|
|
||||||
'150101': `Choose an item...`,
|
'150101': `Choose an item...`,
|
||||||
|
'150102': `[Previous Page]...`,
|
||||||
|
'150103': `[Next Page]...`,
|
||||||
|
'150104': `[Next Page (Please Login First)]...`,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -253,9 +253,13 @@ export default {
|
|||||||
'140301': `Douban: 开始同步[{0}]...`,
|
'140301': `Douban: 开始同步[{0}]...`,
|
||||||
'140302': `Douban: 同步完成`,
|
'140302': `Douban: 同步完成`,
|
||||||
'140303': `Douban: 用户信息已过期,请至插件中重新登录`,
|
'140303': `Douban: 用户信息已过期,请至插件中重新登录`,
|
||||||
|
'140304': `Douban: 此功能需要登录, 请先至插件中登录豆瓣`,
|
||||||
|
|
||||||
'150101': `选择一项内容...`,
|
'150101': `选择一项内容...`,
|
||||||
'121902': `重置为默认值`,
|
'121902': `重置为默认值`,
|
||||||
|
'150102': `[上一页]...`,
|
||||||
|
'150103': `[下一页]...`,
|
||||||
|
'150104': `[下一页(插件中登录开启此功能)]...`,
|
||||||
|
|
||||||
//content
|
//content
|
||||||
'200101': `。`,
|
'200101': `。`,
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import {DoubanSyncModal} from "./douban/component/DoubanSyncModal";
|
|||||||
import SyncHandler from "./douban/sync/handler/SyncHandler";
|
import SyncHandler from "./douban/sync/handler/SyncHandler";
|
||||||
import {SyncConfig} from "./douban/sync/model/SyncConfig";
|
import {SyncConfig} from "./douban/sync/model/SyncConfig";
|
||||||
import GlobalStatusHolder from "./douban/model/GlobalStatusHolder";
|
import GlobalStatusHolder from "./douban/model/GlobalStatusHolder";
|
||||||
|
import DoubanSearchResultSubject from "./douban/data/model/DoubanSearchResultSubject";
|
||||||
|
import {SearchPageInfo} from "./douban/data/model/SearchPageInfo";
|
||||||
|
|
||||||
export default class DoubanPlugin extends Plugin {
|
export default class DoubanPlugin extends Plugin {
|
||||||
public settings: DoubanPluginSetting;
|
public settings: DoubanPluginSetting;
|
||||||
@ -111,8 +113,9 @@ export default class DoubanPlugin extends Plugin {
|
|||||||
async search(searchTerm: string, context: HandleContext) {
|
async search(searchTerm: string, context: HandleContext) {
|
||||||
try {
|
try {
|
||||||
this.showStatus(i18nHelper.getMessage('140201', searchTerm));
|
this.showStatus(i18nHelper.getMessage('140201', searchTerm));
|
||||||
const resultList = await Searcher.search(searchTerm, this.settings, context.plugin.settingsManager);
|
const resultList:DoubanSearchResultSubject[] = await Searcher.search(searchTerm, this.settings, context.plugin.settingsManager);
|
||||||
this.showStatus(i18nHelper.getMessage('140202', resultList.length.toString()));
|
this.showStatus(i18nHelper.getMessage('140202', resultList.length.toString()));
|
||||||
|
context.searchPage = new SearchPageInfo(0,0,20, true);
|
||||||
new DoubanFuzzySuggester(this, context).showSearchList(resultList);
|
new DoubanFuzzySuggester(this, context).showSearchList(resultList);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(i18nHelper.getMessage('140206').replace('{0}', e.message), e);
|
log.error(i18nHelper.getMessage('140206').replace('{0}', e.message), e);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user