mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
Restore multi-copy digit capture and add AniList selection (#56)
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CharacterDictionarySelectionSnapshot, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createCharacterDictionaryModal } from './character-dictionary.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => entries.forEach((entry) => tokens.add(entry)),
|
||||
remove: (...entries: string[]) => entries.forEach((entry) => tokens.delete(entry)),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
return;
|
||||
}
|
||||
if (force) tokens.add(entry);
|
||||
else tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
return {
|
||||
className: '',
|
||||
textContent: '',
|
||||
type: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(),
|
||||
append(...children: unknown[]) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeStub(hidden = false) {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
textContent: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(hidden ? ['hidden'] : []),
|
||||
setAttribute: () => {},
|
||||
addEventListener: (event: string, listener: () => void) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||
},
|
||||
dispatchEvent: (event: string) => {
|
||||
for (const listener of listeners.get(event) ?? []) listener();
|
||||
},
|
||||
replaceChildren(...children: unknown[]) {
|
||||
this.children = [...children];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('character dictionary modal loads candidates and applies selected override', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const snapshot: CharacterDictionarySelectionSnapshot = {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||
override: null,
|
||||
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
|
||||
};
|
||||
const calls: number[] = [];
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const closeButton = createNodeStub();
|
||||
const candidates = createNodeStub();
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getCharacterDictionarySelection: async () => snapshot,
|
||||
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||
calls.push(mediaId);
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey: snapshot.seriesKey,
|
||||
selected: snapshot.candidates[0]!,
|
||||
staleMediaIds: [10607],
|
||||
};
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = createCharacterDictionaryModal(
|
||||
{
|
||||
state,
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: closeButton,
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: candidates,
|
||||
characterDictionaryStatus: status,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
modal.wireDomEvents();
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
assert.equal(overlay.classList.contains('interactive'), true);
|
||||
assert.equal(modalNode.classList.contains('hidden'), false);
|
||||
assert.equal(candidates.children.length, 1);
|
||||
|
||||
modal.handleCharacterDictionaryKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(calls, [21355]);
|
||||
assert.match(status.textContent, /Override saved/);
|
||||
|
||||
closeButton.dispatchEvent('click');
|
||||
assert.equal(state.characterDictionaryModalOpen, false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('character dictionary modal shows refresh errors without rejecting open', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getCharacterDictionarySelection: async () => {
|
||||
throw new Error('candidate lookup failed');
|
||||
},
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: false,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = createCharacterDictionaryModal(
|
||||
{
|
||||
state,
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: createNodeStub(),
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: createNodeStub(),
|
||||
characterDictionaryStatus: status,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
assert.equal(status.textContent, 'candidate lookup failed');
|
||||
assert.equal(status.classList.contains('error'), true);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
CharacterDictionaryCandidate,
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function formatCandidate(candidate: CharacterDictionaryCandidate | null): string {
|
||||
if (!candidate) return 'None';
|
||||
const episodes = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
return `${candidate.id} - ${candidate.title} (${episodes} episodes)`;
|
||||
}
|
||||
|
||||
function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string {
|
||||
const guess = snapshot.guessTitle ?? 'No active title';
|
||||
return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.state.characterDictionaryStatus = message;
|
||||
ctx.dom.characterDictionaryStatus.textContent = message;
|
||||
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
|
||||
const previousId =
|
||||
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
|
||||
?.id;
|
||||
ctx.state.characterDictionarySelection = snapshot;
|
||||
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
nextIndex >= 0 ? nextIndex : 0,
|
||||
snapshot.candidates.length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'character-dictionary-candidate';
|
||||
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'runtime-options-label';
|
||||
main.textContent = candidate.title;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'runtime-options-allowed';
|
||||
const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'character-dictionary-use';
|
||||
button.type = 'button';
|
||||
button.textContent = 'Use';
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'character-dictionary-candidate-body';
|
||||
body.append(main, meta);
|
||||
|
||||
item.append(body, button);
|
||||
item.addEventListener('click', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
render();
|
||||
});
|
||||
item.addEventListener('dblclick', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
if (!snapshot) {
|
||||
ctx.dom.characterDictionarySummary.textContent = '';
|
||||
ctx.dom.characterDictionaryCurrent.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot);
|
||||
ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate(
|
||||
snapshot.current,
|
||||
)} · Override: ${formatCandidate(snapshot.override)}`;
|
||||
|
||||
if (snapshot.candidates.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'character-dictionary-empty';
|
||||
empty.textContent = 'No AniList candidates found.';
|
||||
ctx.dom.characterDictionaryCandidates.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren(
|
||||
...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshSelection(): Promise<void> {
|
||||
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
|
||||
setSelection(snapshot);
|
||||
setStatus(
|
||||
snapshot.override
|
||||
? `Override active: ${formatCandidate(snapshot.override)}`
|
||||
: 'Select the correct AniList entry.',
|
||||
);
|
||||
}
|
||||
|
||||
async function applySelectedCandidate(): Promise<void> {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
|
||||
if (!candidate) return;
|
||||
|
||||
setStatus(`Saving override for ${candidate.title}...`);
|
||||
try {
|
||||
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
|
||||
if (!result.ok) {
|
||||
setStatus('Failed to save override', true);
|
||||
return;
|
||||
}
|
||||
await refreshSelection();
|
||||
const staleLabel =
|
||||
result.staleMediaIds.length > 0
|
||||
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
||||
: '';
|
||||
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function showShell(): void {
|
||||
ctx.state.characterDictionaryModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.characterDictionaryModal.classList.remove('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
|
||||
setStatus('Loading AniList candidates...');
|
||||
}
|
||||
|
||||
async function openCharacterDictionaryModal(): Promise<void> {
|
||||
if (!ctx.state.characterDictionaryModalOpen) {
|
||||
showShell();
|
||||
} else {
|
||||
setStatus('Refreshing AniList candidates...');
|
||||
}
|
||||
try {
|
||||
await refreshSelection();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCharacterDictionaryModal(): void {
|
||||
if (!ctx.state.characterDictionaryModalOpen) return;
|
||||
ctx.state.characterDictionaryModalOpen = false;
|
||||
ctx.state.characterDictionarySelection = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.characterDictionaryModal.classList.add('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
||||
setStatus('');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: -1 | 1): void {
|
||||
const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0;
|
||||
if (length <= 0) return;
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
ctx.state.characterDictionarySelectedIndex + delta,
|
||||
length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeCharacterDictionaryModal();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applySelectedCandidate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||
}
|
||||
|
||||
return {
|
||||
openCharacterDictionaryModal,
|
||||
closeCharacterDictionaryModal,
|
||||
handleCharacterDictionaryKeydown,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import {
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
} from './session-help.js';
|
||||
|
||||
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
|
||||
});
|
||||
|
||||
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
|
||||
'Shift subtitle delay to next cue',
|
||||
);
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
|
||||
'Shift subtitle delay to previous cue',
|
||||
);
|
||||
});
|
||||
|
||||
test('session help formats bracket keybindings as physical keys', () => {
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketRight'), 'Shift + ]');
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
|
||||
});
|
||||
@@ -44,6 +44,8 @@ const KEY_NAME_MAP: Record<string, string> = {
|
||||
Escape: 'Esc',
|
||||
Tab: 'Tab',
|
||||
Enter: 'Enter',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
CommandOrControl: 'Cmd/Ctrl',
|
||||
Ctrl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
@@ -94,6 +96,7 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
|
||||
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
@@ -131,7 +134,9 @@ function describeCommand(command: (string | number)[]): string {
|
||||
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
|
||||
}
|
||||
if (first === 'sub-seek' && typeof command[1] === 'number') {
|
||||
return `Shift subtitle by ${command[1]} ms`;
|
||||
if (command[1] > 0) return 'Jump to next subtitle';
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
@@ -139,6 +144,12 @@ function describeCommand(command: (string | number)[]): string {
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to next cue';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
|
||||
@@ -147,6 +158,11 @@ function describeCommand(command: (string | number)[]): string {
|
||||
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
|
||||
}
|
||||
|
||||
export {
|
||||
describeCommand as describeSessionHelpCommand,
|
||||
formatKeybinding as formatSessionHelpKeybinding,
|
||||
};
|
||||
|
||||
function sectionForCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Other shortcuts';
|
||||
|
||||
Reference in New Issue
Block a user