feat(M2): FR-E7 块级操作 + FR-E11 查找替换——BlockOps 重写(Alt+↑/↓ 顶层块上移下移+Alt+Backspace 删除); FindReplace 加 区分大小写/整词/正则 复选框 + FindReplaceHighlight 装饰插件(全命中高亮+当前项) + 单次替换/全部替换; styles.css find-match 配色; editor-block-findreplace E2E 3 项
This commit is contained in:
@@ -1,13 +1,64 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Fragment, type Node } from '@tiptap/pm/model';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
|
||||
// 块操作扩展:Alt+↑/↓ 移动当前块,Alt+Backspace 删除当前块(FR-E7)
|
||||
// 块级操作(FR-E7):Alt+↑/↓ 上移/下移当前顶层块,Alt+Backspace 删除当前块
|
||||
export const BlockOps = Extension.create({
|
||||
name: 'blockOps',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Alt-ArrowUp': () => this.editor.commands.liftListItem('listItem'),
|
||||
'Alt-ArrowDown': () => this.editor.commands.sinkListItem('listItem'),
|
||||
'Alt-Backspace': () => { const e = this.editor; const from = e.state.selection.from; const $pos = e.state.doc.resolve(from); const start = $pos.before($pos.depth); const end = $pos.after($pos.depth); if (start >= 0) e.chain().focus().deleteRange({ from: start, to: end }).run(); return true; },
|
||||
'Alt-ArrowUp': () => moveBlock(this.editor, 'up'),
|
||||
'Alt-ArrowDown': () => moveBlock(this.editor, 'down'),
|
||||
'Alt-Backspace': () => deleteBlock(this.editor),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 取光标所在的顶层块(depth=1)范围与节点
|
||||
function topBlock(editor: { state: any }): { start: number; end: number; node: Node; idx: number } | null {
|
||||
const { doc, selection } = editor.state;
|
||||
const $pos = doc.resolve(selection.from);
|
||||
if ($pos.depth < 1) return null;
|
||||
const start = $pos.before(1);
|
||||
const node = doc.nodeAt(start);
|
||||
if (!node) return null;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < doc.childCount; i++) { if (doc.child(i) === node) { idx = i; break; } }
|
||||
return { start, end: start + node.nodeSize, node, idx };
|
||||
}
|
||||
|
||||
function moveBlock(editor: any, dir: 'up' | 'down'): boolean {
|
||||
const { doc, tr } = editor.state;
|
||||
const r = topBlock(editor);
|
||||
if (!r || r.idx < 0) return false;
|
||||
const swap = dir === 'up' ? r.idx - 1 : r.idx + 1;
|
||||
if (swap < 0 || swap >= doc.childCount) return false;
|
||||
const children: Node[] = [];
|
||||
for (let i = 0; i < doc.childCount; i++) children.push(doc.child(i));
|
||||
const tmp = children[r.idx]; children[r.idx] = children[swap]; children[swap] = tmp;
|
||||
// 移动后该块的新起始位置
|
||||
const movedNewStart = children.slice(0, swap).reduce((s, n) => s + n.nodeSize, 0);
|
||||
tr.replaceWith(0, doc.content.size, Fragment.from(children));
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(movedNewStart + 1)));
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteBlock(editor: any): boolean {
|
||||
const { doc, selection, tr } = editor.state;
|
||||
const $pos = doc.resolve(selection.from);
|
||||
if ($pos.depth < 1) return false;
|
||||
const start = $pos.before($pos.depth);
|
||||
const end = $pos.after($pos.depth);
|
||||
if (doc.childCount <= 1 && $pos.depth === 1) {
|
||||
// 最后一个顶层块:替换为空段落,避免空文档
|
||||
const p = doc.type.schema.nodes.paragraph.create();
|
||||
tr.replaceWith(start, end, p);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(start + 1)));
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
editor.chain().focus().deleteRange({ from: start, to: end }).run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorRef } from '../editor/editor-context';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import { useEditorRef } from './editor-context';
|
||||
import { collectMatches, findState, type FindOpts, type Match } from './find-helpers';
|
||||
|
||||
const findKey = new PluginKey('findReplaceHighlight');
|
||||
|
||||
// 查找条件变更后触发装饰重算(仅 meta 事务即可)
|
||||
export function refreshFind(editor: Editor) { editor.view.dispatch(editor.state.tr.setMeta(findKey, true)); }
|
||||
|
||||
// FR-E11:全命中高亮装饰插件
|
||||
export const FindReplaceHighlight = Extension.create({
|
||||
name: 'findReplaceHighlight',
|
||||
addProseMirrorPlugins() {
|
||||
return [new Plugin({
|
||||
key: findKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (_tr, _set, _old, newState) => {
|
||||
const { query, caseSensitive, wholeWord, regex, active } = findState;
|
||||
if (!query) return DecorationSet.empty;
|
||||
const ms = collectMatches(newState.doc, query, { caseSensitive, wholeWord, regex });
|
||||
const decos = ms.map((m, i) => Decoration.inline(m.from, m.to, { class: i === active ? 'find-match find-match-active' : 'find-match' }));
|
||||
return DecorationSet.create(newState.doc, decos);
|
||||
},
|
||||
},
|
||||
props: { decorations: (state) => findKey.getState(state) },
|
||||
})];
|
||||
},
|
||||
});
|
||||
|
||||
export function FindReplace() {
|
||||
const editor = useEditorRef();
|
||||
const [find, setFind] = useState('');
|
||||
const [replace, setReplace] = useState('');
|
||||
const [matchIdx, setMatchIdx] = useState(0);
|
||||
const [matches, setMatches] = useState<number[]>([]); // 各匹配起始 pos
|
||||
const [opts, setOpts] = useState<FindOpts>({ caseSensitive: false, wholeWord: false, regex: false });
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const doFind = () => {
|
||||
if (!editor || !find) { setMatches([]); setMatchIdx(0); return; }
|
||||
const positions: number[] = [];
|
||||
const text = editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n');
|
||||
// 遍历 doc 收集匹配(基于文本偏移简化)
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
let i = 0;
|
||||
while ((i = node.text.indexOf(find, i)) !== -1) {
|
||||
positions.push(pos + i);
|
||||
i += find.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
setMatches(positions);
|
||||
setMatchIdx(0);
|
||||
if (positions.length > 0) jumpTo(0, positions);
|
||||
const sync = (q: string, o: FindOpts): Match[] => {
|
||||
if (!editor) return [];
|
||||
findState.query = q; findState.caseSensitive = o.caseSensitive; findState.wholeWord = o.wholeWord; findState.regex = o.regex;
|
||||
const ms = collectMatches(editor.state.doc, q, o);
|
||||
setMatches(ms);
|
||||
const a = ms.length ? 0 : -1;
|
||||
setActive(a); findState.active = a;
|
||||
refreshFind(editor);
|
||||
return ms;
|
||||
};
|
||||
|
||||
const jumpTo = (idx: number, positions = matches) => {
|
||||
if (!editor || positions.length === 0) return;
|
||||
const pos = positions[idx];
|
||||
editor.commands.setTextSelection({ from: pos, to: pos + find.length });
|
||||
const jumpTo = (m: Match) => {
|
||||
if (!editor) return;
|
||||
editor.commands.setTextSelection({ from: m.from, to: m.to });
|
||||
editor.commands.scrollIntoView();
|
||||
editor.commands.focus();
|
||||
};
|
||||
|
||||
const next = () => { if (matches.length === 0) return; const i = (matchIdx + 1) % matches.length; setMatchIdx(i); jumpTo(i); };
|
||||
const prev = () => { if (matches.length === 0) return; const i = (matchIdx - 1 + matches.length) % matches.length; setMatchIdx(i); jumpTo(i); };
|
||||
|
||||
const doFind = () => { const ms = sync(find, opts); if (ms.length) jumpTo(ms[0]); };
|
||||
const next = () => { if (!editor || matches.length === 0) return; const i = (active + 1) % matches.length; setActive(i); findState.active = i; refreshFind(editor); jumpTo(matches[i]); };
|
||||
const prev = () => { if (!editor || matches.length === 0) return; const i = (active - 1 + matches.length) % matches.length; setActive(i); findState.active = i; refreshFind(editor); jumpTo(matches[i]); };
|
||||
const doReplace = () => {
|
||||
if (!editor || matches.length === 0) return;
|
||||
const pos = matches[matchIdx];
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + find.length }).insertContentAt(pos, replace).run();
|
||||
doFind(); // 重新查找
|
||||
const m = matches[active];
|
||||
const tr = editor.state.tr.delete(m.from, m.to).insertText(replace, m.from);
|
||||
editor.view.dispatch(tr);
|
||||
editor.commands.focus();
|
||||
const ms = sync(find, opts); if (ms.length) jumpTo(ms[Math.min(active, ms.length - 1)]);
|
||||
};
|
||||
const doReplaceAll = () => {
|
||||
if (!editor || matches.length === 0) return;
|
||||
const tr = editor.state.tr;
|
||||
for (const m of [...matches].sort((a, b) => b.from - a.from)) { tr.delete(m.from, m.to); tr.insertText(replace, m.from); }
|
||||
editor.view.dispatch(tr);
|
||||
editor.commands.focus();
|
||||
sync(find, opts);
|
||||
};
|
||||
const toggle = (k: keyof FindOpts) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const o = { ...opts, [k]: e.target.checked }; setOpts(o); sync(find, o);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, borderBottom: '1px solid #333', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid #333', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input value={find} onChange={e => setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} />
|
||||
<input value={replace} onChange={e => setReplace(e.target.value)} placeholder="替换" style={inp} />
|
||||
<label style={lab}><input type="checkbox" data-testid="fr-case" checked={opts.caseSensitive} onChange={toggle('caseSensitive')} /> Aa</label>
|
||||
<label style={lab}><input type="checkbox" data-testid="fr-word" checked={opts.wholeWord} onChange={toggle('wholeWord')} /> 整词</label>
|
||||
<label style={lab}><input type="checkbox" data-testid="fr-regex" checked={opts.regex} onChange={toggle('regex')} /> .*</label>
|
||||
<button style={btn} onClick={doFind}>查找</button>
|
||||
<button style={btn} onClick={prev}>↑</button>
|
||||
<button style={btn} onClick={next}>↓</button>
|
||||
<button style={btn} onClick={prev} title="上一个">↑</button>
|
||||
<button style={btn} onClick={next} title="下一个">↓</button>
|
||||
<button style={btn} onClick={doReplace}>替换</button>
|
||||
<span style={{ fontSize: 12, opacity: 0.5 }}>{matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : ''}</span>
|
||||
<button style={btn} onClick={doReplaceAll}>全部替换</button>
|
||||
<span style={{ fontSize: 12, opacity: 0.5 }}>{matches.length > 0 ? `${active + 1}/${matches.length}` : (find ? '0/0' : '')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', width: 120, fontSize: 13 };
|
||||
const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', width: 110, fontSize: 13 };
|
||||
const btn: React.CSSProperties = { minWidth: 30, height: 26, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 12 };
|
||||
const lab: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 3, fontSize: 12, color: '#aaa', cursor: 'pointer' };
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { EditorProvider } from './editor-context';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { FindReplace } from './FindReplace';
|
||||
import { FindReplace, FindReplaceHighlight } from './FindReplace';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
import { BlockOps } from './BlockOps';
|
||||
import { CodeBlockView } from './CodeBlockView';
|
||||
@@ -55,6 +55,7 @@ export function TiptapEditor() {
|
||||
Placeholder.configure({ placeholder: '开始写作…' }),
|
||||
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }),
|
||||
BlockOps,
|
||||
FindReplaceHighlight,
|
||||
],
|
||||
content: body,
|
||||
editorProps: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Node } from '@tiptap/pm/model';
|
||||
|
||||
export interface FindOpts { caseSensitive: boolean; wholeWord: boolean; regex: boolean }
|
||||
|
||||
// 高亮插件与 React UI 共享的当前查找状态
|
||||
export const findState: { query: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean; active: number } = {
|
||||
query: '', caseSensitive: false, wholeWord: false, regex: false, active: -1,
|
||||
};
|
||||
|
||||
export function buildMatcher(query: string, opts: FindOpts): RegExp | null {
|
||||
if (!query) return null;
|
||||
try {
|
||||
if (opts.regex) return new RegExp(query, opts.caseSensitive ? 'g' : 'gi');
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(opts.wholeWord ? `\\b${escaped}\\b` : escaped, opts.caseSensitive ? 'g' : 'gi');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export interface Match { from: number; to: number }
|
||||
|
||||
// 收集文档内全部匹配(按文本节点遍历,匹配不跨节点边界)
|
||||
export function collectMatches(doc: Node, query: string, opts: FindOpts): Match[] {
|
||||
const re = buildMatcher(query, opts);
|
||||
if (!re) return [];
|
||||
const out: Match[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(node.text)) !== null) {
|
||||
out.push({ from: pos + m.index, to: pos + m.index + m[0].length });
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -38,6 +38,9 @@ html, body, #root { margin: 0; height: 100%; }
|
||||
.tiptap-wrap .ProseMirror .hljs-deletion { color: #e06c75; }
|
||||
.tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; }
|
||||
.tiptap-wrap .ProseMirror .hljs-strong { font-weight: 700; }
|
||||
/* FR-E11 查找高亮 */
|
||||
.tiptap-wrap .ProseMirror .find-match { background: rgba(255, 213, 79, 0.35); border-radius: 2px; }
|
||||
.tiptap-wrap .ProseMirror .find-match-active { background: rgba(255, 145, 0, 0.65); }
|
||||
.tiptap-wrap .ProseMirror blockquote {
|
||||
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
async function launch(userData: string) {
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
return { app, page, invoke };
|
||||
}
|
||||
|
||||
async function newDoc(a: { page: any; invoke: any }, title: string) {
|
||||
await a.page.getByRole('button', { name: /新建文档/ }).click();
|
||||
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
|
||||
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
|
||||
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// FR-E7:Alt+↑/↓ 上移/下移当前顶层块
|
||||
test('FR-E7:Alt+↑/↓ 上移/下移当前块', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e7-'));
|
||||
const userData = nodePath.join(tmp, 'userdata');
|
||||
const vault = nodePath.join(tmp, 'vault');
|
||||
await fs.mkdir(userData, { recursive: true });
|
||||
await fs.mkdir(vault, { recursive: true });
|
||||
|
||||
const a = await launch(userData);
|
||||
await a.invoke('vault:switch', { root: vault });
|
||||
await newDoc(a, '块操作');
|
||||
|
||||
const editor = a.page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await a.page.keyboard.type('first');
|
||||
await a.page.keyboard.press('Enter');
|
||||
await a.page.keyboard.type('second');
|
||||
|
||||
// 光标在第二段 'second' -> Alt+↑ 上移 -> 顺序变 second, first
|
||||
await a.page.keyboard.press('Alt+ArrowUp');
|
||||
let p0 = (await a.page.locator('.ProseMirror p').nth(0).textContent() ?? '').trim();
|
||||
let p1 = (await a.page.locator('.ProseMirror p').nth(1).textContent() ?? '').trim();
|
||||
expect(p0).toBe('second');
|
||||
expect(p1).toBe('first');
|
||||
|
||||
// Alt+↓ 下移回去 -> first, second
|
||||
await a.page.keyboard.press('Alt+ArrowDown');
|
||||
p0 = (await a.page.locator('.ProseMirror p').nth(0).textContent() ?? '').trim();
|
||||
p1 = (await a.page.locator('.ProseMirror p').nth(1).textContent() ?? '').trim();
|
||||
expect(p0).toBe('first');
|
||||
expect(p1).toBe('second');
|
||||
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// FR-E11:查找全命中高亮 + 替换 + 全部替换
|
||||
test('FR-E11:查找高亮 + 替换 + 全部替换', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e11a-'));
|
||||
const userData = nodePath.join(tmp, 'userdata');
|
||||
const vault = nodePath.join(tmp, 'vault');
|
||||
await fs.mkdir(userData, { recursive: true });
|
||||
await fs.mkdir(vault, { recursive: true });
|
||||
|
||||
const a = await launch(userData);
|
||||
await a.invoke('vault:switch', { root: vault });
|
||||
await newDoc(a, '查找替换');
|
||||
|
||||
await a.page.locator('.ProseMirror').click();
|
||||
await a.page.keyboard.type('foo bar foo baz foo');
|
||||
|
||||
await a.page.getByPlaceholder('查找').fill('foo');
|
||||
await a.page.getByRole('button', { name: '查找' }).click();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 });
|
||||
await expect(a.page.locator('.ProseMirror .find-match-active')).toHaveCount(1);
|
||||
|
||||
await a.page.getByPlaceholder('替换').fill('Y');
|
||||
await a.page.getByRole('button', { name: '替换', exact: true }).click();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
await a.page.getByPlaceholder('替换').fill('X');
|
||||
await a.page.getByRole('button', { name: '全部替换' }).click();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(0, { timeout: 5000 });
|
||||
const body = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.textContent ?? '');
|
||||
expect(body).not.toContain('foo');
|
||||
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// FR-E11:整词 + 区分大小写选项
|
||||
test('FR-E11:整词 + 区分大小写选项', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e11b-'));
|
||||
const userData = nodePath.join(tmp, 'userdata');
|
||||
const vault = nodePath.join(tmp, 'vault');
|
||||
await fs.mkdir(userData, { recursive: true });
|
||||
await fs.mkdir(vault, { recursive: true });
|
||||
|
||||
const a = await launch(userData);
|
||||
await a.invoke('vault:switch', { root: vault });
|
||||
await newDoc(a, '查找选项');
|
||||
|
||||
await a.page.locator('.ProseMirror').click();
|
||||
await a.page.keyboard.type('foo foobar foo');
|
||||
|
||||
await a.page.getByPlaceholder('查找').fill('foo');
|
||||
await a.page.getByRole('button', { name: '查找' }).click();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 });
|
||||
|
||||
// 整词 -> 排除 foobar -> 2
|
||||
await a.page.getByTestId('fr-word').check();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
// 区分大小写 + 查 'Foo' -> 0
|
||||
await a.page.getByTestId('fr-case').check();
|
||||
await a.page.getByPlaceholder('查找').fill('Foo');
|
||||
await a.page.getByRole('button', { name: '查找' }).click();
|
||||
await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
await a.app.close();
|
||||
});
|
||||
Reference in New Issue
Block a user